@@ -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
81100impl 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 }
0 commit comments