Skip to content

Commit 1a28e95

Browse files
authored
feat: ledger signer support (#605)
1 parent 36ec1d6 commit 1a28e95

File tree

9 files changed

+924
-66
lines changed

9 files changed

+924
-66
lines changed

Cargo.lock

Lines changed: 578 additions & 63 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@ starknet-macros = { version = "0.2.0", path = "./starknet-macros" }
4343

4444
[dev-dependencies]
4545
serde_json = "1.0.74"
46+
starknet-signers = { version = "0.9.0", path = "./starknet-signers", features = ["ledger"] }
4647
tokio = { version = "1.15.0", features = ["full"] }
4748
url = "2.2.2"
4849

4950
[features]
51+
default = []
52+
ledger = ["starknet-signers/ledger"]
5053
no_unknown_fields = [
5154
"starknet-core/no_unknown_fields",
5255
"starknet-providers/no_unknown_fields",

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ starknet = { git = "https://github.com/xJonathanLEI/starknet-rs" }
3737
- [x] Smart contract deployment
3838
- [x] Signer for using [IAccount](https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/openzeppelin/account/IAccount.cairo) account contracts
3939
- [ ] Strongly-typed smart contract binding code generation from ABI
40+
- [x] Ledger hardware wallet support
4041

4142
## Crates
4243

@@ -95,9 +96,13 @@ Examples can be found in the [examples folder](./examples):
9596

9697
8. [Deploy an Argent X account to a pre-funded address](./examples/deploy_argent_account.rs)
9798

98-
9. [Parsing a JSON-RPC request on the server side](./examples/parse_jsonrpc_request.rs)
99+
9. [Inspect public key with Ledger](./examples/ledger_public_key.rs)
99100

100-
10. [Inspecting a erased provider-specific error type](./examples/downcast_provider_error.rs)
101+
10. [Deploy an OpenZeppelin account with Ledger](./examples/deploy_account_with_ledger.rs)
102+
103+
11. [Parsing a JSON-RPC request on the server side](./examples/parse_jsonrpc_request.rs)
104+
105+
12. [Inspecting a erased provider-specific error type](./examples/downcast_provider_error.rs)
101106

102107
## License
103108

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use starknet::{
2+
accounts::AccountFactory,
3+
core::chain_id,
4+
macros::felt,
5+
providers::{
6+
jsonrpc::{HttpTransport, JsonRpcClient},
7+
Url,
8+
},
9+
signers::LedgerSigner,
10+
};
11+
use starknet_accounts::OpenZeppelinAccountFactory;
12+
13+
#[tokio::main]
14+
async fn main() {
15+
// OpenZeppelin account contract v0.13.0 compiled with cairo v2.6.3
16+
let class_hash = felt!("0x00e2eb8f5672af4e6a4e8a8f1b44989685e668489b0a25437733756c5a34a1d6");
17+
18+
// Anything you like here as salt
19+
let salt = felt!("12345678");
20+
21+
let provider = JsonRpcClient::new(HttpTransport::new(
22+
Url::parse("https://starknet-sepolia.public.blastapi.io/rpc/v0_7").unwrap(),
23+
));
24+
25+
let signer = LedgerSigner::new(
26+
"m/2645'/1195502025'/1470455285'/0'/0'/0"
27+
.try_into()
28+
.expect("unable to parse path"),
29+
)
30+
.await
31+
.expect("failed to initialize Starknet Ledger app");
32+
33+
let factory = OpenZeppelinAccountFactory::new(class_hash, chain_id::SEPOLIA, signer, provider)
34+
.await
35+
.unwrap();
36+
37+
let deployment = factory.deploy_v1(salt);
38+
39+
let est_fee = deployment.estimate_fee().await.unwrap();
40+
41+
// In an actual application you might want to add a buffer to the amount
42+
println!(
43+
"Fund at least {} wei to {:#064x}",
44+
est_fee.overall_fee,
45+
deployment.address()
46+
);
47+
println!("Press ENTER after account is funded to continue deployment...");
48+
std::io::stdin().read_line(&mut String::new()).unwrap();
49+
50+
let result = deployment.send().await;
51+
match result {
52+
Ok(tx) => {
53+
println!("Transaction hash: {:#064x}", tx.transaction_hash);
54+
}
55+
Err(err) => {
56+
eprintln!("Error: {err}");
57+
}
58+
}
59+
}

examples/deploy_argent_account.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async fn main() {
4646
let result = deployment.send().await;
4747
match result {
4848
Ok(tx) => {
49-
dbg!(tx);
49+
println!("Transaction hash: {:#064x}", tx.transaction_hash);
5050
}
5151
Err(err) => {
5252
eprintln!("Error: {err}");

examples/ledger_public_key.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use starknet::signers::{LedgerSigner, Signer};
2+
3+
#[tokio::main]
4+
async fn main() {
5+
let path = "m/2645'/1195502025'/1470455285'/0'/0'/0";
6+
7+
let ledger = LedgerSigner::new(path.try_into().expect("unable to parse path"))
8+
.await
9+
.expect("failed to initialize Starknet Ledger app");
10+
11+
let public_key = ledger
12+
.get_public_key()
13+
.await
14+
.expect("failed to get public key");
15+
16+
println!("Path: {}", path);
17+
println!("Public key: {:#064x}", public_key.scalar());
18+
}

starknet-signers/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ auto_impl = "1.0.1"
2020
thiserror = "1.0.40"
2121
crypto-bigint = { version = "0.5.1", default-features = false }
2222
rand = { version = "0.8.5", features = ["std_rng"] }
23+
coins-bip32 = { version = "0.11.1", optional = true }
24+
25+
# Using a fork until https://github.com/summa-tx/coins/issues/137 is fixed
26+
coins-ledger = { git = "https://github.com/xJonathanLEI/coins", rev = "0e3be5db0b18b683433de6b666556b99c726e785", default-features = false, optional = true }
2327

2428
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
2529
eth-keystore = { version = "0.5.0", default-features = false }
@@ -29,3 +33,8 @@ getrandom = { version = "0.2.9", features = ["js"] }
2933

3034
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
3135
wasm-bindgen-test = "0.3.34"
36+
37+
[features]
38+
default = []
39+
40+
ledger = ["coins-bip32", "coins-ledger"]

starknet-signers/src/ledger.rs

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
use async_trait::async_trait;
2+
use coins_ledger::{
3+
common::{APDUData, APDUResponseCodes},
4+
transports::LedgerAsync,
5+
APDUAnswer, APDUCommand, Ledger,
6+
};
7+
use crypto_bigint::{ArrayEncoding, U256};
8+
use starknet_core::{crypto::Signature, types::Felt};
9+
10+
use crate::{Signer, VerifyingKey};
11+
12+
pub use coins_bip32::path::DerivationPath;
13+
14+
/// The Ledger application identifier for app-starknet.
15+
const CLA_STARKNET: u8 = 0x5a;
16+
17+
/// BIP-32 encoding of `2645'`
18+
const EIP_2645_PURPOSE: u32 = 0x80000a55;
19+
20+
const EIP_2645_PATH_LENGTH: usize = 6;
21+
22+
const PUBLIC_KEY_SIZE: usize = 65;
23+
const SIGNATURE_SIZE: usize = 65;
24+
25+
#[derive(Debug)]
26+
pub struct LedgerSigner {
27+
transport: Ledger,
28+
derivation_path: DerivationPath,
29+
}
30+
31+
#[derive(Debug, thiserror::Error)]
32+
pub enum LedgerError {
33+
#[error("derivation path is empty, not prefixed with m/2645', or is not 6-level long")]
34+
InvalidDerivationPath,
35+
#[error(transparent)]
36+
TransportError(coins_ledger::LedgerError),
37+
#[error("unknown response code from Ledger: {0}")]
38+
UnknownResponseCode(u16),
39+
#[error("failed Ledger request: {0}")]
40+
UnsuccessfulRequest(APDUResponseCodes),
41+
#[error("unexpected response length - expected: {expected}; actual: {actual}")]
42+
UnexpectedResponseLength { expected: usize, actual: usize },
43+
}
44+
45+
/// The `GetPubKey` Ledger command.
46+
struct GetPubKeyCommand {
47+
display: bool,
48+
path: DerivationPath,
49+
}
50+
51+
/// Part 1 of the `SignHash` command for setting path.
52+
struct SignHashCommand1 {
53+
path: DerivationPath,
54+
}
55+
56+
/// Part 2 of the `SignHash` command for setting hash.
57+
struct SignHashCommand2 {
58+
hash: [u8; 32],
59+
}
60+
61+
impl LedgerSigner {
62+
/// Initializes the Starknet Ledger app. Attempts to find and connect to a Ledger device. The
63+
/// device must be unlocked and have the Starknet app open.
64+
///
65+
/// The `derivation_path` passed in _must_ follow EIP-2645, i.e. having `2645'` as its "purpose"
66+
/// level as per BIP-44, as the Ledger app does not allow other paths to be used.
67+
///
68+
/// The path _must_ also be 6-level in length. An example path for Starknet would be:
69+
///
70+
/// `m/2645'/1195502025'/1470455285'/0'/0'/0`
71+
///
72+
/// where:
73+
///
74+
/// - `2645'` is the EIP-2645 prefix
75+
/// - `1195502025'`, decimal for `0x4741e9c9`, is the 31 lowest bits for `sha256(starknet)`
76+
/// - `1470455285'`, decimal for `0x57a55df5`, is the 31 lowest bits for `sha256(starkli)`
77+
///
78+
/// Currently, the Ledger app only enforces the length and the first level of the path.
79+
pub async fn new(derivation_path: DerivationPath) -> Result<Self, LedgerError> {
80+
let transport = Ledger::init().await?;
81+
82+
if !matches!(derivation_path.iter().next(), Some(&EIP_2645_PURPOSE))
83+
|| derivation_path.len() != EIP_2645_PATH_LENGTH
84+
{
85+
return Err(LedgerError::InvalidDerivationPath);
86+
}
87+
88+
Ok(Self {
89+
transport,
90+
derivation_path,
91+
})
92+
}
93+
}
94+
95+
#[async_trait]
96+
impl Signer for LedgerSigner {
97+
type GetPublicKeyError = LedgerError;
98+
type SignError = LedgerError;
99+
100+
async fn get_public_key(&self) -> Result<VerifyingKey, Self::GetPublicKeyError> {
101+
let response = self
102+
.transport
103+
.exchange(
104+
&GetPubKeyCommand {
105+
display: false,
106+
path: self.derivation_path.clone(),
107+
}
108+
.into(),
109+
)
110+
.await?;
111+
112+
let data = get_apdu_data(&response)?;
113+
if data.len() != PUBLIC_KEY_SIZE {
114+
return Err(LedgerError::UnexpectedResponseLength {
115+
expected: PUBLIC_KEY_SIZE,
116+
actual: data.len(),
117+
});
118+
}
119+
120+
// Unwrapping here is safe as length is fixed
121+
let pubkey_x = Felt::from_bytes_be(&data[1..33].try_into().unwrap());
122+
123+
Ok(VerifyingKey::from_scalar(pubkey_x))
124+
}
125+
126+
async fn sign_hash(&self, hash: &Felt) -> Result<Signature, Self::SignError> {
127+
get_apdu_data(
128+
&self
129+
.transport
130+
.exchange(
131+
&SignHashCommand1 {
132+
path: self.derivation_path.clone(),
133+
}
134+
.into(),
135+
)
136+
.await?,
137+
)?;
138+
139+
let response = self
140+
.transport
141+
.exchange(
142+
&SignHashCommand2 {
143+
hash: hash.to_bytes_be(),
144+
}
145+
.into(),
146+
)
147+
.await?;
148+
149+
let data = get_apdu_data(&response)?;
150+
151+
if data.len() != SIGNATURE_SIZE + 1 || data[0] != SIGNATURE_SIZE as u8 {
152+
return Err(LedgerError::UnexpectedResponseLength {
153+
expected: SIGNATURE_SIZE,
154+
actual: data.len(),
155+
});
156+
}
157+
158+
// Unwrapping here is safe as length is fixed
159+
let r = Felt::from_bytes_be(&data[1..33].try_into().unwrap());
160+
let s = Felt::from_bytes_be(&data[33..65].try_into().unwrap());
161+
162+
let signature = Signature { r, s };
163+
164+
Ok(signature)
165+
}
166+
}
167+
168+
impl From<coins_ledger::LedgerError> for LedgerError {
169+
fn from(value: coins_ledger::LedgerError) -> Self {
170+
Self::TransportError(value)
171+
}
172+
}
173+
174+
impl From<GetPubKeyCommand> for APDUCommand {
175+
fn from(value: GetPubKeyCommand) -> Self {
176+
let path = value
177+
.path
178+
.iter()
179+
.flat_map(|level| level.to_be_bytes())
180+
.collect::<Vec<_>>();
181+
182+
Self {
183+
cla: CLA_STARKNET,
184+
ins: 0x01,
185+
p1: if value.display { 0x01 } else { 0x00 },
186+
p2: 0x00,
187+
data: APDUData::new(&path),
188+
response_len: None,
189+
}
190+
}
191+
}
192+
193+
impl From<SignHashCommand1> for APDUCommand {
194+
fn from(value: SignHashCommand1) -> Self {
195+
let path = value
196+
.path
197+
.iter()
198+
.flat_map(|level| level.to_be_bytes())
199+
.collect::<Vec<_>>();
200+
201+
Self {
202+
cla: CLA_STARKNET,
203+
ins: 0x02,
204+
p1: 0x00,
205+
p2: 0x00,
206+
data: APDUData::new(&path),
207+
response_len: None,
208+
}
209+
}
210+
}
211+
212+
impl From<SignHashCommand2> for APDUCommand {
213+
fn from(value: SignHashCommand2) -> Self {
214+
// For some reasons, the Ledger app expects the input to be left shifted by 4 bits...
215+
let shifted_bytes: [u8; 32] = (U256::from_be_slice(&value.hash) << 4)
216+
.to_be_byte_array()
217+
.into();
218+
219+
Self {
220+
cla: CLA_STARKNET,
221+
ins: 0x02,
222+
p1: 0x01,
223+
p2: 0x00,
224+
data: APDUData::new(&shifted_bytes),
225+
response_len: None,
226+
}
227+
}
228+
}
229+
230+
fn get_apdu_data(answer: &APDUAnswer) -> Result<&[u8], LedgerError> {
231+
let ret_code = answer.retcode();
232+
233+
match TryInto::<APDUResponseCodes>::try_into(ret_code) {
234+
Ok(status) => {
235+
if status.is_success() {
236+
// Unwrapping here as we've already checked success
237+
Ok(answer.data().unwrap())
238+
} else {
239+
Err(LedgerError::UnsuccessfulRequest(status))
240+
}
241+
}
242+
Err(_) => Err(LedgerError::UnknownResponseCode(ret_code)),
243+
}
244+
}

0 commit comments

Comments
 (0)