Skip to content

Commit 52ad3c0

Browse files
committed
Add --ledger to stellar keys add.
1 parent 91f4689 commit 52ad3c0

8 files changed

Lines changed: 233 additions & 60 deletions

File tree

FULL_HELP_DOCS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,6 +1137,7 @@ Add a new identity (keypair, ledger, OS specific secure store)
11371137
This only supports seed phrases for now.
11381138

11391139
- `--public-key <PUBLIC_KEY>` — Add a public key, ed25519, or muxed account, e.g. G1.., M2..
1140+
- `--ledger` — Derive the address from a connected Ledger hardware wallet at `m/44'/148'/N'`, where `N` defaults to 0 and can be set with `--hd-path`. Persists the derived public key (and `--hd-path`, when provided) so later commands work without the device
11401141
- `--overwrite` — Overwrite existing identity if it already exists. When combined with --secure-store, also replaces the existing Secure Store entry
11411142
- `--hd-path <HD_PATH>` — When importing a seed phrase, which `hd_path` to derive the key at. Persisted on the identity so later commands derive the same account without re-passing the flag. Not valid with `--public-key` or a raw secret key
11421143

cmd/soroban-cli/src/commands/contract/arg_parsing.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,11 @@ fn resolve_address(addr_or_alias: &str, config: &config::Args) -> Result<String,
466466

467467
async fn resolve_signer(addr_or_alias: &str, config: &config::Args) -> Option<Signer> {
468468
let secret = config.locator.get_secret_key(addr_or_alias).ok()?;
469+
// Ledger co-signing must go through `--source`; opening the HID transport
470+
// here conflicts with the source-account signer (hidapi is per-process).
471+
if matches!(secret, crate::config::secret::Secret::Ledger { .. }) {
472+
return None;
473+
}
469474
let print = Print::new(false);
470475
let signer = secret.signer(config.hd_path(), print).await.ok()?;
471476
Some(signer)

cmd/soroban-cli/src/commands/keys/add.rs

Lines changed: 104 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ use crate::{
77
config::{
88
address::KeyName,
99
key, locator,
10-
secret::{self, Secret},
10+
secret::{self, HardwareKind, Secret},
1111
},
1212
print::Print,
13-
signer::secure_store,
13+
signer::{ledger, secure_store},
1414
};
1515

1616
#[derive(thiserror::Error, Debug)]
@@ -28,6 +28,9 @@ pub enum Error {
2828
#[error(transparent)]
2929
SeedPhrase(#[from] sep5::error::Error),
3030

31+
#[error(transparent)]
32+
Ledger(#[from] ledger::Error),
33+
3134
#[error("secret input error")]
3235
PasswordRead,
3336

@@ -42,6 +45,9 @@ pub enum Error {
4245

4346
#[error("--hd-path is not valid with a secret key; secret keys cannot be derived")]
4447
HdPathNotSupportedForSecretKey,
48+
49+
#[error("--hd-path {0} is out of range for a Ledger account index")]
50+
HdPathOutOfRange(usize),
4551
}
4652

4753
#[derive(Debug, clap::Parser, Clone)]
@@ -61,10 +67,23 @@ pub struct Cmd {
6167
long,
6268
conflicts_with = "seed_phrase",
6369
conflicts_with = "secret_key",
64-
conflicts_with = "hd_path"
70+
conflicts_with = "hd_path",
71+
conflicts_with = "ledger"
6572
)]
6673
pub public_key: Option<String>,
6774

75+
/// Derive the address from a connected Ledger hardware wallet at
76+
/// `m/44'/148'/N'`, where `N` defaults to 0 and can be set with
77+
/// `--hd-path`. Persists the derived public key (and `--hd-path`,
78+
/// when provided) so later commands work without the device.
79+
#[arg(
80+
long,
81+
conflicts_with = "secret_key",
82+
conflicts_with = "seed_phrase",
83+
conflicts_with = "secure_store"
84+
)]
85+
pub ledger: bool,
86+
6887
/// Overwrite existing identity if it already exists. When combined with
6988
/// --secure-store, also replaces the existing Secure Store entry.
7089
#[arg(long)]
@@ -79,7 +98,7 @@ pub struct Cmd {
7998
}
8099

81100
impl Cmd {
82-
pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
101+
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
83102
let print = Print::new(global_args.quiet);
84103

85104
if self.config_locator.read_identity(&self.name).is_ok() {
@@ -92,6 +111,8 @@ impl Cmd {
92111

93112
let key = if let Some(key) = self.public_key.as_ref() {
94113
key::Key::parse_public_only(key)?
114+
} else if self.ledger {
115+
self.derive_ledger_secret().await?.into()
95116
} else {
96117
self.read_secret(&print)?.into()
97118
};
@@ -103,6 +124,17 @@ impl Cmd {
103124
Ok(())
104125
}
105126

127+
async fn derive_ledger_secret(&self) -> Result<Secret, Error> {
128+
let raw = self.hd_path.unwrap_or(0);
129+
let index: u32 = raw.try_into().map_err(|_| Error::HdPathOutOfRange(raw))?;
130+
let public_key = ledger::new(index).await?.public_key().await?;
131+
Ok(Secret::Ledger {
132+
hardware: HardwareKind::Ledger,
133+
public_key: public_key.to_string(),
134+
hd_path: self.hd_path,
135+
})
136+
}
137+
106138
fn read_secret(&self, print: &Print) -> Result<Secret, Error> {
107139
if self.secrets.secure_store {
108140
if std::env::var("STELLAR_SECRET_KEY").is_ok() {
@@ -207,6 +239,7 @@ mod tests {
207239
},
208240
config_locator: locator.clone(),
209241
public_key: None,
242+
ledger: false,
210243
overwrite: false,
211244
hd_path: None,
212245
};
@@ -286,61 +319,107 @@ mod tests {
286319
}
287320

288321
#[test]
289-
fn test_run_accepts_public_key_without_hd_path() {
290-
let (_tmp, _locator, cmd) = cmd_with_public_key(PUBLIC_KEY, None);
291-
assert!(cmd.run(&global_args()).is_ok());
322+
fn test_clap_accepts_ledger_with_hd_path() {
323+
use clap::Parser;
324+
325+
let cmd = Cmd::try_parse_from(["add", "test_name", "--ledger", "--hd-path", "5"])
326+
.expect("--ledger + --hd-path must parse");
327+
assert!(cmd.ledger);
328+
assert_eq!(cmd.hd_path, Some(5));
329+
}
330+
331+
#[test]
332+
fn test_clap_rejects_ledger_with_public_key() {
333+
use clap::Parser;
334+
335+
let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--public-key", PUBLIC_KEY])
336+
.expect_err("clap must reject --ledger + --public-key");
337+
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
338+
}
339+
340+
#[test]
341+
fn test_clap_rejects_ledger_with_secret_key() {
342+
use clap::Parser;
343+
344+
let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--secret-key"])
345+
.expect_err("clap must reject --ledger + --secret-key");
346+
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
347+
}
348+
349+
#[test]
350+
fn test_clap_rejects_ledger_with_seed_phrase() {
351+
use clap::Parser;
352+
353+
let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--seed-phrase"])
354+
.expect_err("clap must reject --ledger + --seed-phrase");
355+
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
292356
}
293357

294358
#[test]
295-
fn public_key_flag_accepts_public_key() {
359+
fn test_clap_rejects_ledger_with_secure_store() {
360+
use clap::Parser;
361+
362+
let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--secure-store"])
363+
.expect_err("clap must reject --ledger + --secure-store");
364+
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
365+
}
366+
367+
#[tokio::test]
368+
async fn test_run_accepts_public_key_without_hd_path() {
369+
let (_tmp, _locator, cmd) = cmd_with_public_key(PUBLIC_KEY, None);
370+
assert!(cmd.run(&global_args()).await.is_ok());
371+
}
372+
373+
#[tokio::test]
374+
async fn public_key_flag_accepts_public_key() {
296375
let (_tmp, locator, mut cmd) = set_up_test();
297376
cmd.public_key = Some(PUBLIC_KEY.to_string());
298-
cmd.run(&global_args()).unwrap();
377+
cmd.run(&global_args()).await.unwrap();
299378
let stored = locator.read_identity("test_name").unwrap();
300379
assert!(matches!(stored, Key::PublicKey(_)));
301380
}
302381

303-
#[test]
304-
fn public_key_flag_accepts_muxed_account() {
382+
#[tokio::test]
383+
async fn public_key_flag_accepts_muxed_account() {
305384
let (_tmp, locator, mut cmd) = set_up_test();
306385
cmd.public_key = Some(MUXED_ACCOUNT.to_string());
307-
cmd.run(&global_args()).unwrap();
386+
cmd.run(&global_args()).await.unwrap();
308387
let stored = locator.read_identity("test_name").unwrap();
309388
assert!(matches!(stored, Key::MuxedAccount(_)));
310389
}
311390

312-
#[test]
313-
fn public_key_flag_rejects_secret_key() {
391+
#[tokio::test]
392+
async fn public_key_flag_rejects_secret_key() {
314393
let (_tmp, locator, mut cmd) = set_up_test();
315394
cmd.public_key = Some(SECRET_KEY.to_string());
316-
let err = cmd.run(&global_args()).unwrap_err();
395+
let err = cmd.run(&global_args()).await.unwrap_err();
317396
assert!(matches!(err, Error::Key(key_mod::Error::PublicKeyExpected)));
318397
assert!(locator.read_identity("test_name").is_err());
319398
}
320399

321-
#[test]
322-
fn public_key_flag_rejects_seed_phrase() {
400+
#[tokio::test]
401+
async fn public_key_flag_rejects_seed_phrase() {
323402
let (_tmp, locator, mut cmd) = set_up_test();
324403
cmd.public_key = Some(SEED_PHRASE.to_string());
325-
let err = cmd.run(&global_args()).unwrap_err();
404+
let err = cmd.run(&global_args()).await.unwrap_err();
326405
assert!(matches!(err, Error::Key(key_mod::Error::PublicKeyExpected)));
327406
assert!(locator.read_identity("test_name").is_err());
328407
}
329408

330-
#[test]
331-
fn public_key_flag_rejects_ledger() {
409+
#[tokio::test]
410+
async fn public_key_flag_rejects_ledger() {
332411
let (_tmp, locator, mut cmd) = set_up_test();
333412
cmd.public_key = Some("ledger".to_string());
334-
let err = cmd.run(&global_args()).unwrap_err();
335-
assert!(matches!(err, Error::Key(key_mod::Error::PublicKeyExpected)));
413+
let err = cmd.run(&global_args()).await.unwrap_err();
414+
assert!(matches!(err, Error::Key(key_mod::Error::Parse)));
336415
assert!(locator.read_identity("test_name").is_err());
337416
}
338417

339-
#[test]
340-
fn public_key_flag_rejects_secure_store() {
418+
#[tokio::test]
419+
async fn public_key_flag_rejects_secure_store() {
341420
let (_tmp, locator, mut cmd) = set_up_test();
342421
cmd.public_key = Some("secure_store:org.stellar.cli-alice".to_string());
343-
let err = cmd.run(&global_args()).unwrap_err();
422+
let err = cmd.run(&global_args()).await.unwrap_err();
344423
assert!(matches!(err, Error::Key(key_mod::Error::PublicKeyExpected)));
345424
assert!(locator.read_identity("test_name").is_err());
346425
}

cmd/soroban-cli/src/commands/keys/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ pub enum Error {
7979
impl Cmd {
8080
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
8181
match self {
82-
Cmd::Add(cmd) => cmd.run(global_args)?,
82+
Cmd::Add(cmd) => cmd.run(global_args).await?,
8383
Cmd::PublicKey(cmd) => cmd.run().await?,
8484
Cmd::Fund(cmd) => cmd.run(global_args).await?,
8585
Cmd::Generate(cmd) => cmd.run(global_args).await?,

cmd/soroban-cli/src/commands/message/verify.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,7 @@ mod tests {
323323
locator: setup_locator(),
324324
};
325325
let err = cmd.run(&global_args()).unwrap_err();
326-
assert!(matches!(
327-
err,
328-
Error::Key(crate::config::key::Error::PublicKeyExpected)
329-
));
326+
assert!(matches!(err, Error::Locator(_)));
330327
}
331328

332329
#[test]

cmd/soroban-cli/src/config/address.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ pub enum Error {
4747
InvalidKeyNameCharacters(String),
4848
#[error("Invalid key name: {0}\n keys cannot exceed 250 characters")]
4949
InvalidKeyNameLength(String),
50-
#[error("Invalid key name: {0}\n keys cannot be the word \"ledger\"")]
51-
InvalidKeyName(String),
5250
#[error(transparent)]
5351
Name(#[from] utils::Error),
5452
}
@@ -107,9 +105,6 @@ impl std::str::FromStr for KeyName {
107105
utils::Error::InvalidNameLength(s) => Error::InvalidKeyNameLength(s),
108106
utils::Error::InvalidNameCharacters(s) => Error::InvalidKeyNameCharacters(s),
109107
})?;
110-
if s == "ledger" {
111-
return Err(Error::InvalidKeyName(s.to_string()));
112-
}
113108
Ok(KeyName(s.to_string()))
114109
}
115110
}

cmd/soroban-cli/src/config/key.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ mod test {
227227
#[test]
228228
fn parse_public_only_rejects_ledger() {
229229
let err = Key::parse_public_only("ledger").unwrap_err();
230-
assert!(matches!(err, Error::PublicKeyExpected));
230+
assert!(matches!(err, Error::Parse));
231231
}
232232

233233
#[test]

0 commit comments

Comments
 (0)