Skip to content

Commit

Permalink
feat: ledger signer support (#605)
Browse files Browse the repository at this point in the history
  • Loading branch information
xJonathanLEI authored Jun 19, 2024
1 parent 36ec1d6 commit 1a28e95
Show file tree
Hide file tree
Showing 9 changed files with 924 additions and 66 deletions.
641 changes: 578 additions & 63 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,13 @@ starknet-macros = { version = "0.2.0", path = "./starknet-macros" }

[dev-dependencies]
serde_json = "1.0.74"
starknet-signers = { version = "0.9.0", path = "./starknet-signers", features = ["ledger"] }
tokio = { version = "1.15.0", features = ["full"] }
url = "2.2.2"

[features]
default = []
ledger = ["starknet-signers/ledger"]
no_unknown_fields = [
"starknet-core/no_unknown_fields",
"starknet-providers/no_unknown_fields",
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ starknet = { git = "https://github.com/xJonathanLEI/starknet-rs" }
- [x] Smart contract deployment
- [x] Signer for using [IAccount](https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/openzeppelin/account/IAccount.cairo) account contracts
- [ ] Strongly-typed smart contract binding code generation from ABI
- [x] Ledger hardware wallet support

## Crates

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

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

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

10. [Inspecting a erased provider-specific error type](./examples/downcast_provider_error.rs)
10. [Deploy an OpenZeppelin account with Ledger](./examples/deploy_account_with_ledger.rs)

11. [Parsing a JSON-RPC request on the server side](./examples/parse_jsonrpc_request.rs)

12. [Inspecting a erased provider-specific error type](./examples/downcast_provider_error.rs)

## License

Expand Down
59 changes: 59 additions & 0 deletions examples/deploy_account_with_ledger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use starknet::{
accounts::AccountFactory,
core::chain_id,
macros::felt,
providers::{
jsonrpc::{HttpTransport, JsonRpcClient},
Url,
},
signers::LedgerSigner,
};
use starknet_accounts::OpenZeppelinAccountFactory;

#[tokio::main]
async fn main() {
// OpenZeppelin account contract v0.13.0 compiled with cairo v2.6.3
let class_hash = felt!("0x00e2eb8f5672af4e6a4e8a8f1b44989685e668489b0a25437733756c5a34a1d6");

// Anything you like here as salt
let salt = felt!("12345678");

let provider = JsonRpcClient::new(HttpTransport::new(
Url::parse("https://starknet-sepolia.public.blastapi.io/rpc/v0_7").unwrap(),
));

let signer = LedgerSigner::new(
"m/2645'/1195502025'/1470455285'/0'/0'/0"
.try_into()
.expect("unable to parse path"),
)
.await
.expect("failed to initialize Starknet Ledger app");

let factory = OpenZeppelinAccountFactory::new(class_hash, chain_id::SEPOLIA, signer, provider)
.await
.unwrap();

let deployment = factory.deploy_v1(salt);

let est_fee = deployment.estimate_fee().await.unwrap();

// In an actual application you might want to add a buffer to the amount
println!(
"Fund at least {} wei to {:#064x}",
est_fee.overall_fee,
deployment.address()
);
println!("Press ENTER after account is funded to continue deployment...");
std::io::stdin().read_line(&mut String::new()).unwrap();

let result = deployment.send().await;
match result {
Ok(tx) => {
println!("Transaction hash: {:#064x}", tx.transaction_hash);
}
Err(err) => {
eprintln!("Error: {err}");
}
}
}
2 changes: 1 addition & 1 deletion examples/deploy_argent_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async fn main() {
let result = deployment.send().await;
match result {
Ok(tx) => {
dbg!(tx);
println!("Transaction hash: {:#064x}", tx.transaction_hash);
}
Err(err) => {
eprintln!("Error: {err}");
Expand Down
18 changes: 18 additions & 0 deletions examples/ledger_public_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use starknet::signers::{LedgerSigner, Signer};

#[tokio::main]
async fn main() {
let path = "m/2645'/1195502025'/1470455285'/0'/0'/0";

let ledger = LedgerSigner::new(path.try_into().expect("unable to parse path"))
.await
.expect("failed to initialize Starknet Ledger app");

let public_key = ledger
.get_public_key()
.await
.expect("failed to get public key");

println!("Path: {}", path);
println!("Public key: {:#064x}", public_key.scalar());
}
9 changes: 9 additions & 0 deletions starknet-signers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ auto_impl = "1.0.1"
thiserror = "1.0.40"
crypto-bigint = { version = "0.5.1", default-features = false }
rand = { version = "0.8.5", features = ["std_rng"] }
coins-bip32 = { version = "0.11.1", optional = true }

# Using a fork until https://github.com/summa-tx/coins/issues/137 is fixed
coins-ledger = { git = "https://github.com/xJonathanLEI/coins", rev = "0e3be5db0b18b683433de6b666556b99c726e785", default-features = false, optional = true }

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

[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3.34"

[features]
default = []

ledger = ["coins-bip32", "coins-ledger"]
244 changes: 244 additions & 0 deletions starknet-signers/src/ledger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
use async_trait::async_trait;
use coins_ledger::{
common::{APDUData, APDUResponseCodes},
transports::LedgerAsync,
APDUAnswer, APDUCommand, Ledger,
};
use crypto_bigint::{ArrayEncoding, U256};
use starknet_core::{crypto::Signature, types::Felt};

use crate::{Signer, VerifyingKey};

pub use coins_bip32::path::DerivationPath;

/// The Ledger application identifier for app-starknet.
const CLA_STARKNET: u8 = 0x5a;

/// BIP-32 encoding of `2645'`
const EIP_2645_PURPOSE: u32 = 0x80000a55;

const EIP_2645_PATH_LENGTH: usize = 6;

const PUBLIC_KEY_SIZE: usize = 65;
const SIGNATURE_SIZE: usize = 65;

#[derive(Debug)]
pub struct LedgerSigner {
transport: Ledger,
derivation_path: DerivationPath,
}

#[derive(Debug, thiserror::Error)]
pub enum LedgerError {
#[error("derivation path is empty, not prefixed with m/2645', or is not 6-level long")]
InvalidDerivationPath,
#[error(transparent)]
TransportError(coins_ledger::LedgerError),
#[error("unknown response code from Ledger: {0}")]
UnknownResponseCode(u16),
#[error("failed Ledger request: {0}")]
UnsuccessfulRequest(APDUResponseCodes),
#[error("unexpected response length - expected: {expected}; actual: {actual}")]
UnexpectedResponseLength { expected: usize, actual: usize },
}

/// The `GetPubKey` Ledger command.
struct GetPubKeyCommand {
display: bool,
path: DerivationPath,
}

/// Part 1 of the `SignHash` command for setting path.
struct SignHashCommand1 {
path: DerivationPath,
}

/// Part 2 of the `SignHash` command for setting hash.
struct SignHashCommand2 {
hash: [u8; 32],
}

impl LedgerSigner {
/// Initializes the Starknet Ledger app. Attempts to find and connect to a Ledger device. The
/// device must be unlocked and have the Starknet app open.
///
/// The `derivation_path` passed in _must_ follow EIP-2645, i.e. having `2645'` as its "purpose"
/// level as per BIP-44, as the Ledger app does not allow other paths to be used.
///
/// The path _must_ also be 6-level in length. An example path for Starknet would be:
///
/// `m/2645'/1195502025'/1470455285'/0'/0'/0`
///
/// where:
///
/// - `2645'` is the EIP-2645 prefix
/// - `1195502025'`, decimal for `0x4741e9c9`, is the 31 lowest bits for `sha256(starknet)`
/// - `1470455285'`, decimal for `0x57a55df5`, is the 31 lowest bits for `sha256(starkli)`
///
/// Currently, the Ledger app only enforces the length and the first level of the path.
pub async fn new(derivation_path: DerivationPath) -> Result<Self, LedgerError> {
let transport = Ledger::init().await?;

if !matches!(derivation_path.iter().next(), Some(&EIP_2645_PURPOSE))
|| derivation_path.len() != EIP_2645_PATH_LENGTH
{
return Err(LedgerError::InvalidDerivationPath);
}

Ok(Self {
transport,
derivation_path,
})
}
}

#[async_trait]
impl Signer for LedgerSigner {
type GetPublicKeyError = LedgerError;
type SignError = LedgerError;

async fn get_public_key(&self) -> Result<VerifyingKey, Self::GetPublicKeyError> {
let response = self
.transport
.exchange(
&GetPubKeyCommand {
display: false,
path: self.derivation_path.clone(),
}
.into(),
)
.await?;

let data = get_apdu_data(&response)?;
if data.len() != PUBLIC_KEY_SIZE {
return Err(LedgerError::UnexpectedResponseLength {
expected: PUBLIC_KEY_SIZE,
actual: data.len(),
});
}

// Unwrapping here is safe as length is fixed
let pubkey_x = Felt::from_bytes_be(&data[1..33].try_into().unwrap());

Ok(VerifyingKey::from_scalar(pubkey_x))
}

async fn sign_hash(&self, hash: &Felt) -> Result<Signature, Self::SignError> {
get_apdu_data(
&self
.transport
.exchange(
&SignHashCommand1 {
path: self.derivation_path.clone(),
}
.into(),
)
.await?,
)?;

let response = self
.transport
.exchange(
&SignHashCommand2 {
hash: hash.to_bytes_be(),
}
.into(),
)
.await?;

let data = get_apdu_data(&response)?;

if data.len() != SIGNATURE_SIZE + 1 || data[0] != SIGNATURE_SIZE as u8 {
return Err(LedgerError::UnexpectedResponseLength {
expected: SIGNATURE_SIZE,
actual: data.len(),
});
}

// Unwrapping here is safe as length is fixed
let r = Felt::from_bytes_be(&data[1..33].try_into().unwrap());
let s = Felt::from_bytes_be(&data[33..65].try_into().unwrap());

let signature = Signature { r, s };

Ok(signature)
}
}

impl From<coins_ledger::LedgerError> for LedgerError {
fn from(value: coins_ledger::LedgerError) -> Self {
Self::TransportError(value)
}
}

impl From<GetPubKeyCommand> for APDUCommand {
fn from(value: GetPubKeyCommand) -> Self {
let path = value
.path
.iter()
.flat_map(|level| level.to_be_bytes())
.collect::<Vec<_>>();

Self {
cla: CLA_STARKNET,
ins: 0x01,
p1: if value.display { 0x01 } else { 0x00 },
p2: 0x00,
data: APDUData::new(&path),
response_len: None,
}
}
}

impl From<SignHashCommand1> for APDUCommand {
fn from(value: SignHashCommand1) -> Self {
let path = value
.path
.iter()
.flat_map(|level| level.to_be_bytes())
.collect::<Vec<_>>();

Self {
cla: CLA_STARKNET,
ins: 0x02,
p1: 0x00,
p2: 0x00,
data: APDUData::new(&path),
response_len: None,
}
}
}

impl From<SignHashCommand2> for APDUCommand {
fn from(value: SignHashCommand2) -> Self {
// For some reasons, the Ledger app expects the input to be left shifted by 4 bits...
let shifted_bytes: [u8; 32] = (U256::from_be_slice(&value.hash) << 4)
.to_be_byte_array()
.into();

Self {
cla: CLA_STARKNET,
ins: 0x02,
p1: 0x01,
p2: 0x00,
data: APDUData::new(&shifted_bytes),
response_len: None,
}
}
}

fn get_apdu_data(answer: &APDUAnswer) -> Result<&[u8], LedgerError> {
let ret_code = answer.retcode();

match TryInto::<APDUResponseCodes>::try_into(ret_code) {
Ok(status) => {
if status.is_success() {
// Unwrapping here as we've already checked success
Ok(answer.data().unwrap())
} else {
Err(LedgerError::UnsuccessfulRequest(status))
}
}
Err(_) => Err(LedgerError::UnknownResponseCode(ret_code)),
}
}
Loading

0 comments on commit 1a28e95

Please sign in to comment.