Context
#2563 added stellar keys add --ledger, which persists a Secret::Ledger { hardware, public_key, hd_path } identity. Read-only operations (alias → address, keys public-key, keys address) now work without the device because the public key is cached on disk. Source-account signing also works via --source <ledger-alias> and the pre-existing --sign-with-ledger flag in sign_with.rs.
What does not work today: a Soroban contract function that calls require_auth() on an Address argument resolved from a Ledger-backed alias. The argument-parsing path collects co-signers eagerly into Vec<Signer>, and a SignerKind::Ledger in that list will panic at sign time when the device is connected.
Behavior matrix
| Device |
Function requires auth? |
Result |
| Disconnected |
No |
✅ Works (read-only address lookup uses cached pubkey) |
| Connected |
No |
✅ Works (no auth entries → signer Vec never iterated) |
| Disconnected |
Yes |
⚠️ Clean error: Missing signing key for account G… |
| Connected |
Yes |
🔥 Panic at todo!("ledger device is not implemented") in Signer::get_public_key |
The connected + auth-required case is the actual defect. The other rows are acceptable today.
Reproduction
// contracts/auth/src/lib.rs
#[contractimpl]
impl Contract {
pub fn auth(_env: Env, value: Address) -> Address {
value.require_auth();
value
}
}
stellar keys add my-ledger --ledger # device connected, hd-path 0
# unplug the device for the disconnected row, plug it in for connected
stellar contract invoke --id <C…> -- auth --value my-ledger
Steps inside the CLI (connected case):
parse_single_argument (cmd/soroban-cli/src/commands/contract/arg_parsing.rs:206–269) hits the Address arg and calls resolve_signer(trimmed_s, config).await (line 237).
resolve_signer (arg_parsing.rs:467–472) calls Secret::signer().await, which reaches ledger::new(hd_path).await (cmd/soroban-cli/src/config/secret.rs:225) and opens the HID transport during argument parsing.
- The collected
Vec<Signer> flows through build_host_function_parameters → invoke.rs::execute → sim_sign_and_send_tx (cmd/soroban-cli/src/tx.rs:33–120) → config.sign_soroban_authorizations(…) (cmd/soroban-cli/src/config/mod.rs:137–152) → signer::sign_soroban_authorizations (cmd/soroban-cli/src/signer/mod.rs:59–152).
- For each auth entry, the inner function loops every signer calling
signer.get_public_key() to find a match.
Signer::get_public_key is todo!("ledger device is not implemented") for SignerKind::Ledger (cmd/soroban-cli/src/signer/mod.rs:281). Process panics.
Constraints
hidapi is per-process. Every call to ledger::new() constructs a fresh HidApi::new() and TransportNativeHID::new(&hidapi) (cmd/crates/stellar-ledger/src/lib.rs:321–325 and cmd/soroban-cli/src/signer/ledger.rs:38–65). There is no pool, cache, OnceCell, or Mutex. Two concurrent transports in the same process conflict.
- Signer construction is eager.
Vec<Signer> is built during arg parsing and held until after sign_soroban_authorizations runs, so a Ledger transport stays open across the parse → simulate → RPC → sign window — much longer than necessary.
SignerKind::Ledger lacks a cached public key. It only holds the Ledger<TransportNativeHID> (no public_key field), so get_public_key() has nothing to return without device interaction. The cached pubkey lives on Secret::Ledger, not on SignerKind::Ledger.
Signer::sign_payload and Signer::get_public_key are unimplemented for SignerKind::Ledger (cmd/soroban-cli/src/signer/mod.rs:281, 288). Only Signer::sign_tx_hash has a Ledger implementation, via LedgerSigner::sign_transaction_hash at signer/mod.rs:303–306.
- Stellar Ledger app payload support.
sign_soroban_authorizations builds a 32-byte HashIdPreimageSorobanAuthorization hash. Need to verify whether the app accepts the Soroban auth preimage as a hash-signing target — likely the same primitive used by sign_transaction_hash, but worth confirming with the firmware team / app docs before relying on it.
Broader architectural gap
The --sign-with-* flag family (--sign-with-ledger, --sign-with-lab, --sign-with-key) operates only on the transaction envelope, never on auth entries. Auth entries are populated only by walking address arguments in arg_parsing.rs and auto-resolving each as a known signing identity. There is no escape hatch — no way to say "use this identity for this auth entry" — so the gap isn't strictly Ledger-specific:
keys add alice --secret-key … then contract invoke … -- auth --value alice — works, because Secret::SecretKey resolves cleanly in resolve_signer.
keys add alice --secure-store (locked keychain) then the same call — also fails to sign the auth entry, because Secret::signer().await errors and .ok()? swallows it. The user just sees the chain-side Missing signing key error.
The Ledger case is the most visible because connected-device + auth-required panics instead of erroring cleanly, but the underlying constraint (no explicit auth-entry signer flag) is broader. Worth considering a --sign-auth-with <alias> (repeatable) flag alongside the Ledger fix, separable but related.
Proposed fix
- Defer signer construction. Change the type collected by
parse_function_arguments from Vec<Signer> to a deferred form (e.g. Vec<DeferredSigner> carrying the Secret and an alias name, or just Vec<Secret>). Materialize the actual Signer only inside sign_soroban_authorizations, one at a time, and drop it before moving on. HID opens only inside the signing window — never during arg parsing — and only one Ledger transport exists at a time, so it cannot conflict with the source-account signer.
- Thread the cached public key onto
SignerKind::Ledger. Add a public_key: stellar_strkey::ed25519::PublicKey field so Signer::get_public_key can return synchronously without device I/O.
- Implement
Signer::sign_payload for SignerKind::Ledger. Wire it through LedgerSigner::sign_transaction_hash (or the appropriate auth-payload primitive once verified) and convert the result into Ed25519Signature.
- Make
signer::sign_soroban_authorizations async (or split into a sync "match auth → required pubkey" pass and an async "construct + sign" pass — two-pass is cleaner because it isolates HID I/O to the second pass).
- UX: each Ledger co-signer requires a device confirmation; print which alias is being prompted for (e.g.
Confirm on Ledger for my-ledger…). With N auth entries needing the same Ledger identity plus a Ledger source, the user confirms N+1 times — that's expected.
- Optional companion: add
--sign-auth-with <alias> (repeatable) so users can authorize from any identity regardless of whether it appears in argument position. Closes the broader architectural gap.
Affected files: arg_parsing.rs, invoke.rs, tx.rs, config/mod.rs, signer/mod.rs, signer/ledger.rs. Estimated scope: ~250–400 LOC.
Validation
Best done once an HID emulator is wired into soroban-test. Until then, manual testing against a real device covers the happy path; unit tests can cover the deferred-construction plumbing without touching HID.
Workaround in the meantime
Users who want to sign an auth entry as a Ledger identity should not pass that alias in argument position — the signer pipeline can't currently fulfill it. If the contract argument needs to be a literal address, look up stellar keys public-key <ledger-alias> and pass the G… string directly; an auth requirement for that address will surface as Missing signing key for account G… until the proper fix lands.
Context
#2563 added
stellar keys add --ledger, which persists aSecret::Ledger { hardware, public_key, hd_path }identity. Read-only operations (alias → address,keys public-key,keys address) now work without the device because the public key is cached on disk. Source-account signing also works via--source <ledger-alias>and the pre-existing--sign-with-ledgerflag insign_with.rs.What does not work today: a Soroban contract function that calls
require_auth()on anAddressargument resolved from a Ledger-backed alias. The argument-parsing path collects co-signers eagerly intoVec<Signer>, and aSignerKind::Ledgerin that list will panic at sign time when the device is connected.Behavior matrix
Missing signing key for account G…todo!("ledger device is not implemented")inSigner::get_public_keyThe connected + auth-required case is the actual defect. The other rows are acceptable today.
Reproduction
Steps inside the CLI (connected case):
parse_single_argument(cmd/soroban-cli/src/commands/contract/arg_parsing.rs:206–269) hits theAddressarg and callsresolve_signer(trimmed_s, config).await(line 237).resolve_signer(arg_parsing.rs:467–472) callsSecret::signer().await, which reachesledger::new(hd_path).await(cmd/soroban-cli/src/config/secret.rs:225) and opens the HID transport during argument parsing.Vec<Signer>flows throughbuild_host_function_parameters→invoke.rs::execute→sim_sign_and_send_tx(cmd/soroban-cli/src/tx.rs:33–120) →config.sign_soroban_authorizations(…)(cmd/soroban-cli/src/config/mod.rs:137–152) →signer::sign_soroban_authorizations(cmd/soroban-cli/src/signer/mod.rs:59–152).signer.get_public_key()to find a match.Signer::get_public_keyistodo!("ledger device is not implemented")forSignerKind::Ledger(cmd/soroban-cli/src/signer/mod.rs:281). Process panics.Constraints
hidapiis per-process. Every call toledger::new()constructs a freshHidApi::new()andTransportNativeHID::new(&hidapi)(cmd/crates/stellar-ledger/src/lib.rs:321–325andcmd/soroban-cli/src/signer/ledger.rs:38–65). There is no pool, cache,OnceCell, orMutex. Two concurrent transports in the same process conflict.Vec<Signer>is built during arg parsing and held until aftersign_soroban_authorizationsruns, so a Ledger transport stays open across the parse → simulate → RPC → sign window — much longer than necessary.SignerKind::Ledgerlacks a cached public key. It only holds theLedger<TransportNativeHID>(nopublic_keyfield), soget_public_key()has nothing to return without device interaction. The cached pubkey lives onSecret::Ledger, not onSignerKind::Ledger.Signer::sign_payloadandSigner::get_public_keyare unimplemented forSignerKind::Ledger(cmd/soroban-cli/src/signer/mod.rs:281, 288). OnlySigner::sign_tx_hashhas a Ledger implementation, viaLedgerSigner::sign_transaction_hashatsigner/mod.rs:303–306.sign_soroban_authorizationsbuilds a 32-byteHashIdPreimageSorobanAuthorizationhash. Need to verify whether the app accepts the Soroban auth preimage as a hash-signing target — likely the same primitive used bysign_transaction_hash, but worth confirming with the firmware team / app docs before relying on it.Broader architectural gap
The
--sign-with-*flag family (--sign-with-ledger,--sign-with-lab,--sign-with-key) operates only on the transaction envelope, never on auth entries. Auth entries are populated only by walking address arguments inarg_parsing.rsand auto-resolving each as a known signing identity. There is no escape hatch — no way to say "use this identity for this auth entry" — so the gap isn't strictly Ledger-specific:keys add alice --secret-key …thencontract invoke … -- auth --value alice— works, becauseSecret::SecretKeyresolves cleanly inresolve_signer.keys add alice --secure-store(locked keychain) then the same call — also fails to sign the auth entry, becauseSecret::signer().awaiterrors and.ok()?swallows it. The user just sees the chain-sideMissing signing keyerror.The Ledger case is the most visible because connected-device + auth-required panics instead of erroring cleanly, but the underlying constraint (no explicit auth-entry signer flag) is broader. Worth considering a
--sign-auth-with <alias>(repeatable) flag alongside the Ledger fix, separable but related.Proposed fix
parse_function_argumentsfromVec<Signer>to a deferred form (e.g.Vec<DeferredSigner>carrying theSecretand an alias name, or justVec<Secret>). Materialize the actualSigneronly insidesign_soroban_authorizations, one at a time, and drop it before moving on. HID opens only inside the signing window — never during arg parsing — and only one Ledger transport exists at a time, so it cannot conflict with the source-account signer.SignerKind::Ledger. Add apublic_key: stellar_strkey::ed25519::PublicKeyfield soSigner::get_public_keycan return synchronously without device I/O.Signer::sign_payloadforSignerKind::Ledger. Wire it throughLedgerSigner::sign_transaction_hash(or the appropriate auth-payload primitive once verified) and convert the result intoEd25519Signature.signer::sign_soroban_authorizationsasync (or split into a sync "match auth → required pubkey" pass and an async "construct + sign" pass — two-pass is cleaner because it isolates HID I/O to the second pass).Confirm on Ledger for my-ledger…). With N auth entries needing the same Ledger identity plus a Ledger source, the user confirms N+1 times — that's expected.--sign-auth-with <alias>(repeatable) so users can authorize from any identity regardless of whether it appears in argument position. Closes the broader architectural gap.Affected files:
arg_parsing.rs,invoke.rs,tx.rs,config/mod.rs,signer/mod.rs,signer/ledger.rs. Estimated scope: ~250–400 LOC.Validation
Best done once an HID emulator is wired into
soroban-test. Until then, manual testing against a real device covers the happy path; unit tests can cover the deferred-construction plumbing without touching HID.Workaround in the meantime
Users who want to sign an auth entry as a Ledger identity should not pass that alias in argument position — the signer pipeline can't currently fulfill it. If the contract argument needs to be a literal address, look up
stellar keys public-key <ledger-alias>and pass the G… string directly; an auth requirement for that address will surface asMissing signing key for account G…until the proper fix lands.