diff --git a/src/commands/contract.rs b/src/commands/contract.rs index bc8f1797..8b2e5d59 100644 --- a/src/commands/contract.rs +++ b/src/commands/contract.rs @@ -1,7 +1,8 @@ -use crate::utils::{bindings, call_graph, config, crypto, print as p, soroban}; +use crate::utils::{bindings, call_graph, config, print as p, soroban, wallet_signer}; use anyhow::Result; use clap::{Args, Subcommand, ValueEnum}; use colored::*; +use crate::utils::hardware_wallet::HardwareWalletKind; use std::path::PathBuf; #[derive(Subcommand)] @@ -68,6 +69,12 @@ pub struct InvokeArgs { /// Submit the transaction after simulation #[arg(long, default_value = "false")] pub submit: bool, + /// Sign with a hardware wallet instead of a local secret key + #[arg(long, value_enum)] + pub hardware: Option, + /// HD derivation path for hardware wallet signing + #[arg(long, default_value = crate::utils::hardware_wallet::STELLAR_HD_PATH)] + pub hd_path: String, } #[derive(Args)] @@ -93,6 +100,12 @@ pub struct UploadArgs { /// Wallet name to use for signing #[arg(long)] pub wallet: Option, + /// Sign with a hardware wallet instead of a local secret key + #[arg(long, value_enum)] + pub hardware: Option, + /// HD derivation path for hardware wallet signing + #[arg(long, default_value = crate::utils::hardware_wallet::STELLAR_HD_PATH)] + pub hd_path: String, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -250,10 +263,10 @@ async fn handle_invoke(args: InvokeArgs) -> Result<()> { p::warn("You are invoking on MAINNET. This may cost real XLM if submitted."); } - // Load and optionally decrypt wallet for submission - let submit_wallet: Option = if args.submit { + // Load wallet and signing configuration for submission + let (submit_wallet, signing_request) = if args.submit { let cfg = config::load()?; - let mut w = if let Some(ref wallet_name) = args.wallet { + let wallet = if let Some(ref wallet_name) = args.wallet { cfg.wallets .iter() .find(|w| &w.name == wallet_name) @@ -263,31 +276,35 @@ async fn handle_invoke(args: InvokeArgs) -> Result<()> { wallet_name ) })? - .clone() } else if !cfg.wallets.is_empty() { p::info(&format!( "No --wallet specified. Using: {}", cfg.wallets[0].name.cyan() )); - cfg.wallets[0].clone() + &cfg.wallets[0] } else { anyhow::bail!( "No wallets found for submission. Create one first:\n starforge wallet create deployer --fund" ); }; - p::kv("Wallet", &w.name); - if let Some(sk) = &w.secret_key.clone() { - if sk.contains(':') { - let pwd = crypto::prompt_password( - &format!("Enter password to decrypt wallet '{}'", w.name), - false, - )?; - w.secret_key = Some(crypto::decrypt_secret(&pwd, sk)?); - } + p::kv("Wallet", &wallet.name); + if wallet.secret_key.is_none() && args.hardware.is_none() { + anyhow::bail!( + "Wallet '{}' has no local secret key. Use --hardware ledger or --hardware trezor.", + wallet.name + ); } - Some(w) + let signing = wallet_signer::SigningRequest::from_options( + Some(wallet), + args.hardware, + Some(&args.hd_path), + &args.network, + false, + "contract invocation", + )?; + (Some(wallet.clone()), Some(signing)) } else { - None + (None, None) }; p::separator(); @@ -307,7 +324,9 @@ async fn handle_invoke(args: InvokeArgs) -> Result<()> { &arg_types, &args.network, submit_wallet.as_ref(), - ).await?; + signing_request.as_ref(), + ) + .await?; let simulation_result = outcome.simulation; p::kv_accent("Simulation", "✓ Success"); diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 7f2ed4ea..0ec61722 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,11 +1,8 @@ -use crate::utils::deploy_history::{ - self, last_successful, record_deployment, set_contract_id, update_status, DeployRecord, - DeployStatus, -}; -use crate::utils::{config, confirmation, horizon, optimizer, print as p, soroban}; +use crate::utils::{config, confirmation, horizon, optimizer, print as p, soroban, wallet_signer}; use anyhow::Result; use clap::Args; use colored::*; +use crate::utils::hardware_wallet::HardwareWalletKind; use sha2::{Digest, Sha256}; use std::fs; use std::path::PathBuf; @@ -47,24 +44,12 @@ pub struct DeployArgs { /// deployment plan and exits. Implies --simulate. #[arg(long, default_value = "false")] pub dry_run: bool, - /// Disable automatic rollback. By default, if an executed deployment fails - /// and a previous successful deployment exists on this network, StarForge - /// records a rollback to that version as a safety net. - #[arg(long, default_value = "false")] - pub no_auto_rollback: bool, -} - -/// Extract the deployed contract id (a `C...` strkey) from the Stellar CLI's -/// stdout. `stellar contract deploy` prints the 56-char contract id, typically -/// on its own final line. Returns the first plausible contract id found. -fn parse_contract_id_from_stdout(stdout: &str) -> Option { - stdout - .split(|c: char| c.is_whitespace()) - .map(|t| t.trim()) - .find(|t| { - t.len() == 56 && t.starts_with('C') && t.chars().all(|c| c.is_ascii_alphanumeric()) - }) - .map(|t| t.to_string()) + /// Sign deployment with a hardware wallet (Ledger/Trezor) + #[arg(long, value_enum)] + pub hardware: Option, + /// HD derivation path for hardware wallet signing + #[arg(long, default_value = crate::utils::hardware_wallet::STELLAR_HD_PATH)] + pub hd_path: String, } fn is_wasm_above_size_limit(wasm_size_kb: f64) -> bool { @@ -390,7 +375,14 @@ pub async fn handle(args: DeployArgs) -> Result<()> { .add("Wallet", &wallet.name) .add("Public Key", &wallet.public_key) .add("Optimized", if args.optimize { "Yes" } else { "No" }) - .add("Execute", if args.execute { "Yes" } else { "No (dry-run)" }); + .add("Execute", if args.execute { "Yes" } else { "No (dry-run)" }) + .add( + "Signer", + &match args.hardware { + Some(device) => format!("hardware ({})", device), + None => format!("local ({})", wallet.name), + }, + ); let confirm_config = confirmation::ConfirmationConfig { risk_level, @@ -405,6 +397,26 @@ pub async fn handle(args: DeployArgs) -> Result<()> { return Ok(()); } + if args.execute { + if let Some(device) = args.hardware { + let signing_request = wallet_signer::SigningRequest::from_options( + Some(wallet), + Some(device), + Some(&args.hd_path), + &args.network, + args.yes, + "contract deployment", + )?; + soroban::sign_deploy_transaction(&wasm_hash, wallet, &args.network, &signing_request)?; + p::success(&format!("Deployment transaction signed on {}", device)); + } else if wallet.secret_key.is_none() { + anyhow::bail!( + "Wallet '{}' has no local secret key. Use --hardware ledger or --hardware trezor for deployment.", + wallet.name + ); + } + } + println!(); println!(); let pb = p::progress_bar(3, "Starting deployment steps..."); diff --git a/src/commands/invoke.rs b/src/commands/invoke.rs index d9559bd7..c62ddb34 100644 --- a/src/commands/invoke.rs +++ b/src/commands/invoke.rs @@ -99,7 +99,9 @@ pub async fn handle(args: InvokeArgs) -> Result<()> { &arg_type_list, network, submit_wallet.map(|w| w as &crate::utils::config::WalletEntry), - ).await?; + None, + ) + .await?; println!(); p::success("Simulation successful!"); diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 93312cba..fcd71cc0 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -3,8 +3,9 @@ use clap::{Args, Subcommand}; use colored::*; use crate::utils::confirmation; +use crate::utils::hardware_wallet::HardwareWalletKind; use crate::utils::horizon::FeeStats; -use crate::utils::{config, crypto, horizon, print as p, tx_batch}; // Import FeeStats +use crate::utils::{config, horizon, print as p, tx_batch, wallet_signer}; #[derive(Args)] pub struct TxArgs { @@ -42,6 +43,12 @@ pub struct BatchArgs { /// Skip confirmation prompt #[arg(long, default_value = "false")] pub yes: bool, + /// Sign with a hardware wallet instead of a local secret key + #[arg(long, value_enum)] + pub hardware: Option, + /// HD derivation path for hardware wallet signing + #[arg(long, default_value = crate::utils::hardware_wallet::STELLAR_HD_PATH)] + pub hd_path: String, } #[derive(Args)] @@ -64,6 +71,12 @@ pub struct SendArgs { /// Skip confirmation prompt #[arg(long, default_value = "false")] pub yes: bool, + /// Sign with a hardware wallet instead of a local secret key + #[arg(long, value_enum)] + pub hardware: Option, + /// HD derivation path for hardware wallet signing + #[arg(long, default_value = crate::utils::hardware_wallet::STELLAR_HD_PATH)] + pub hd_path: String, } #[derive(Args)] @@ -123,8 +136,11 @@ async fn handle_batch(args: BatchArgs) -> Result<()> { ) })?; - if wallet.secret_key.is_none() { - anyhow::bail!("Wallet '{}' has no secret key stored", args.from); + if wallet.secret_key.is_none() && args.hardware.is_none() { + anyhow::bail!( + "Wallet '{}' has no secret key stored. Use --hardware ledger or --hardware trezor.", + args.from + ); } let payment_ops: Vec = doc @@ -239,21 +255,22 @@ async fn handle_batch(args: BatchArgs) -> Result<()> { println!(); - let mut secret_key = wallet.secret_key.as_ref().unwrap().clone(); - if secret_key.contains(':') { - let pwd = crypto::prompt_password( - &format!("Enter password to decrypt wallet '{}'", wallet.name), - false, - )?; - secret_key = crypto::decrypt_secret(&pwd, &secret_key)?; - } + let signing_request = wallet_signer::SigningRequest::from_options( + Some(wallet), + args.hardware, + Some(&args.hd_path), + &args.network, + args.yes, + "batch transaction", + )?; p::info("Submitting batch transaction…"); - let submit_result = horizon::submit_payment_transaction( + let submit_result = horizon::submit_payment_with_signing( &tx_result.transaction_xdr, - &secret_key, + &signing_request, &args.network, - ).await?; + ) + .await?; println!(); p::separator(); @@ -316,8 +333,11 @@ async fn handle_send(args: SendArgs) -> Result<()> { })?; // Validate wallet has secret key - if wallet.secret_key.is_none() { - anyhow::bail!("Wallet '{}' has no secret key stored", args.from); + if wallet.secret_key.is_none() && args.hardware.is_none() { + anyhow::bail!( + "Wallet '{}' has no secret key stored. Use --hardware ledger or --hardware trezor.", + args.from + ); } // Parse asset @@ -453,21 +473,22 @@ async fn handle_send(args: SendArgs) -> Result<()> { // Submit transaction println!(); - let mut secret_key = wallet.secret_key.as_ref().unwrap().clone(); - if secret_key.contains(':') { - let pwd = crypto::prompt_password( - &format!("Enter password to decrypt wallet '{}'", wallet.name), - false, - )?; - secret_key = crypto::decrypt_secret(&pwd, &secret_key)?; - } + let signing_request = wallet_signer::SigningRequest::from_options( + Some(wallet), + args.hardware, + Some(&args.hd_path), + &args.network, + args.yes, + "payment transaction", + )?; p::info("Submitting transaction…"); - let submit_result = horizon::submit_payment_transaction( + let submit_result = horizon::submit_payment_with_signing( &tx_result.transaction_xdr, - &secret_key, + &signing_request, &args.network, - ).await?; + ) + .await?; println!(); p::separator(); diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index c97510f3..a4269c97 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -2,6 +2,7 @@ use crate::utils::{ config, confirmation, crypto, hardware_wallet, horizon, mnemonic, multisig, print as p, + wallet_signer, }; use anyhow::{Context, Result}; use bip39::{Language, Mnemonic}; @@ -220,12 +221,21 @@ pub enum WalletCommands { /// (requires --encrypt) #[arg(long, default_value = "false", requires = "encrypt")] strict: bool, + /// Import a watch-only wallet from a connected hardware device + #[arg(long, value_enum, group = "source")] + hardware: Option, + /// HD derivation path when importing from hardware + #[arg(long, default_value = hardware_wallet::STELLAR_HD_PATH)] + hd_path: String, }, /// Connect to a hardware wallet (Ledger/Trezor) and show device info Connect { - #[arg(value_enum)] + #[arg(value_enum, default_value_t = hardware_wallet::HardwareWalletKind::Ledger)] device: hardware_wallet::HardwareWalletKind, + /// Connection timeout (e.g. 1s, 30s) + #[arg(long, default_value = "30s")] + timeout: String, }, /// Show the Stellar address derived from a connected hardware wallet @@ -296,6 +306,15 @@ pub enum MultisigCommands { /// Output file (defaults to in-place update) #[arg(long)] output: Option, + /// Sign with a hardware wallet instead of a local secret key + #[arg(long, value_enum)] + hardware: Option, + /// HD derivation path for hardware wallet signing + #[arg(long, default_value = hardware_wallet::STELLAR_HD_PATH)] + hd_path: String, + /// Network for signing (default: testnet) + #[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])] + network: String, }, /// List multi-sig accounts stored locally List, @@ -392,6 +411,8 @@ pub async fn handle(cmd: WalletCommands) -> Result<()> { network, encrypt, strict, + hardware, + hd_path, } => import_wallet( name, file, @@ -401,8 +422,10 @@ pub async fn handle(cmd: WalletCommands) -> Result<()> { network, encrypt, strict, + hardware, + hd_path, ), - WalletCommands::Connect { device } => connect_hardware(device), + WalletCommands::Connect { device, timeout } => connect_hardware(device, &timeout), WalletCommands::HwAddress { device, path } => hw_address(device, &path), WalletCommands::HwStatus { device } => hw_status(device), WalletCommands::Sign { @@ -415,14 +438,29 @@ pub async fn handle(cmd: WalletCommands) -> Result<()> { } } -fn connect_hardware(device: hardware_wallet::HardwareWalletKind) -> Result<()> { - p::header("Hardware Wallet — Connect"); +fn parse_duration(input: &str) -> Result { + let trimmed = input.trim().to_lowercase(); + if trimmed.ends_with("ms") { + let value: u64 = trimmed.trim_end_matches("ms").parse().context("Invalid timeout")?; + return Ok(std::time::Duration::from_millis(value)); + } + if trimmed.ends_with('s') { + let value: u64 = trimmed.trim_end_matches('s').parse().context("Invalid timeout")?; + return Ok(std::time::Duration::from_secs(value)); + } + anyhow::bail!("Invalid timeout '{}'. Use values like 1s or 500ms.", input) +} + +fn connect_hardware(device: hardware_wallet::HardwareWalletKind, timeout: &str) -> Result<()> { + let timeout_duration = parse_duration(timeout)?; + p::header("Hardware Wallet — Connect"); p::step( 1, 3, - &format!("Initializing HID subsystem for {}…", device), + &format!("Initializing HID subsystem for {}…", device), ); - let info = hardware_wallet::connect(device)?; + let info = hardware_wallet::connect_with_timeout(device, timeout_duration) + .map_err(|err| hardware_wallet::map_signing_error(err, device))?; p::step( 2, 3, @@ -472,7 +510,9 @@ fn sign_message( if let Some(kind) = hardware { p::kv("Signer", &format!("{:?}", kind)); - let sig = hardware_wallet::sign(kind, msg_bytes)?; + let passphrase = config::get_network_passphrase("testnet"); + let sig = hardware_wallet::sign_transaction(kind, hardware_wallet::STELLAR_HD_PATH, msg_bytes, &passphrase) + .map_err(|err| hardware_wallet::map_signing_error(err, kind))?; p::separator(); p::kv_accent("Message", &message); p::kv("Signature (hex)", &hex::encode(sig)); @@ -1391,7 +1431,18 @@ fn import_wallet( network_override: Option, encrypt: bool, strict: bool, + hardware: Option, + hd_path: String, ) -> Result<()> { + if let Some(device) = hardware { + let name = name.ok_or_else(|| { + anyhow::anyhow!( + "Wallet name is required for hardware import (e.g. starforge wallet import ledger-alice --hardware ledger)" + ) + })?; + return import_from_hardware(name, device, &hd_path, network_override); + } + if from_mnemonic { let name = name.ok_or_else(|| { anyhow::anyhow!("Wallet name is required for mnemonic import (e.g. starforge wallet import alice --mnemonic)") @@ -1416,6 +1467,40 @@ fn import_wallet( import_wallets(file) } +fn import_from_hardware( + name: String, + device: hardware_wallet::HardwareWalletKind, + hd_path: &str, + network_override: Option, +) -> Result<()> { + config::validate_wallet_name(&name)?; + let cfg = config::load()?; + if cfg.wallets.iter().any(|w| w.name == name) { + anyhow::bail!("Wallet '{}' already exists", name); + } + + let public_key = hardware_wallet::get_stellar_address(device, hd_path) + .map_err(|err| hardware_wallet::map_signing_error(err, device))?; + let network = network_override.unwrap_or_else(|| cfg.network.clone()); + + let mut updated_cfg = cfg; + updated_cfg.wallets.push(config::WalletEntry { + name: name.clone(), + public_key, + secret_key: None, + network, + created_at: Utc::now().to_rfc3339(), + funded: false, + rotation_history: vec![], + }); + config::save(&updated_cfg)?; + + p::success(&format!("Wallet '{}' imported from {} hardware device", name, device)); + p::kv("HD Path", hd_path); + p::info("This wallet is watch-only. Sign transactions with --hardware."); + Ok(()) +} + fn import_from_mnemonic( name: String, account_index: u32, @@ -1789,7 +1874,10 @@ async fn handle_multisig(cmd: MultisigCommands) -> Result<()> { name, transaction, output, - } => multisig_sign(name, transaction, output), + hardware, + hd_path, + network, + } => multisig_sign(name, transaction, output, hardware, hd_path, network), MultisigCommands::List => multisig_list(), MultisigCommands::Show { name } => multisig_show(name), MultisigCommands::Submit { @@ -1899,9 +1987,17 @@ fn multisig_create( Ok(()) } -fn multisig_sign(name: String, transaction: PathBuf, output: Option) -> Result<()> { +fn multisig_sign( + name: String, + transaction: PathBuf, + output: Option, + hardware: Option, + hd_path: String, + network: String, +) -> Result<()> { config::validate_wallet_name(&name)?; config::validate_file_path(&transaction, Some("json"))?; + config::validate_network(&network)?; let account = multisig::load_account(&name)?; let cfg = config::load()?; @@ -1911,30 +2007,78 @@ fn multisig_sign(name: String, transaction: PathBuf, output: Option) -> p::header(&format!("Multi-sig Sign: {}", name)); p::kv("Account", &account.account_id); p::kv("Transaction", &transaction.display().to_string()); + p::kv("Network", &network); - // Attempt to sign with every configured signer that we have a local secret key for. let mut signed = 0u32; - for s in &account.signers { - let wallet_name = s.name.clone().unwrap_or_else(|| s.public_key.clone()); - let Some(w) = cfg.wallets.iter().find(|w| w.public_key == s.public_key) else { - continue; - }; - let Some(sk) = &w.secret_key else { continue }; - let plain_sk = if !sk.contains(':') && sk.starts_with('S') && sk.len() == 56 { - sk.clone() + if let Some(device) = hardware { + let matching_signer = account.signers.iter().find(|signer| { + cfg.wallets.iter().any(|wallet| { + wallet.public_key == signer.public_key && wallet.secret_key.is_none() + }) + }); + + let signer_key = if let Some(signer) = matching_signer { + signer.public_key.clone() + } else if let Some(first) = account.signers.first() { + first.public_key.clone() } else { - let pwd = crypto::prompt_password( - &format!("Enter password for signer wallet '{}'", w.name), - false, - )?; - crypto::decrypt_secret(&pwd, sk) - .map_err(|_| anyhow::anyhow!("Incorrect password or unable to decrypt."))? + anyhow::bail!("Multi-sig account has no configured signers"); }; - let sig = multisig::sign_transaction_partial(&tx.transaction_xdr, &plain_sk, "testnet")?; - if multisig::add_signature_to_transaction(&mut tx, &wallet_name, sig).is_ok() { - signed += 1; + let wallet_ref = cfg + .wallets + .iter() + .find(|w| w.public_key == signer_key) + .ok_or_else(|| { + anyhow::anyhow!( + "No local wallet entry found for signer {}. Import it with --hardware first.", + signer_key + ) + })?; + + let signing_request = wallet_signer::SigningRequest::from_options( + Some(wallet_ref), + Some(device), + Some(&hd_path), + &network, + false, + "multi-sig transaction", + )?; + + let sig = multisig::sign_transaction_partial_with_request( + &tx.transaction_xdr, + &signing_request, + &wallet_ref.name, + )?; + multisig::add_signature_to_transaction(&mut tx, &wallet_ref.public_key, sig)?; + signed = 1; + } else { + // Attempt to sign with every configured signer that we have a local secret key for. + for s in &account.signers { + let wallet_name = s.name.clone().unwrap_or_else(|| s.public_key.clone()); + let Some(w) = cfg.wallets.iter().find(|w| w.public_key == s.public_key) else { + continue; + }; + let Some(sk) = &w.secret_key else { + continue; + }; + + let plain_sk = if !sk.contains(':') && sk.starts_with('S') && sk.len() == 56 { + sk.clone() + } else { + let pwd = crypto::prompt_password( + &format!("Enter password for signer wallet '{}'", w.name), + false, + )?; + crypto::decrypt_secret(&pwd, sk) + .map_err(|_| anyhow::anyhow!("Incorrect password or unable to decrypt."))? + }; + + let sig = multisig::sign_transaction_partial(&tx.transaction_xdr, &plain_sk, &network)?; + if multisig::add_signature_to_transaction(&mut tx, &wallet_name, sig).is_ok() { + signed += 1; + } } } diff --git a/src/utils/hardware_wallet.rs b/src/utils/hardware_wallet.rs index 8a4d7cb2..9896dc49 100644 --- a/src/utils/hardware_wallet.rs +++ b/src/utils/hardware_wallet.rs @@ -63,6 +63,51 @@ pub fn device_status(_kind: HardwareWalletKind) -> Result { anyhow::bail!("Hardware wallet support is disabled in this build.") } +#[cfg(not(feature = "hardware-wallet"))] +pub fn connect_with_timeout( + kind: HardwareWalletKind, + _timeout: std::time::Duration, +) -> Result { + connect(kind) +} + +#[cfg(not(feature = "hardware-wallet"))] +pub fn sign_transaction( + kind: HardwareWalletKind, + _hd_path: &str, + _transaction: &[u8], + _network_passphrase: &str, +) -> Result> { + anyhow::bail!( + "Hardware wallet support is disabled in this build.\n\ + Rebuild with `cargo build --features hardware-wallet` to sign with {}.", + kind + ) +} + +/// Translate hardware wallet failures into actionable recovery guidance. +pub fn map_signing_error(err: anyhow::Error, kind: HardwareWalletKind) -> anyhow::Error { + let message = err.to_string().to_lowercase(); + let remediation = if message.contains("timeout") || message.contains("timed out") { + "Ensure the device is unlocked, the Stellar app is open, and approve the prompt on-screen. Retry when ready." + } else if message.contains("not found") || message.contains("no ledger") || message.contains("no trezor") { + "Connect the device via USB, unlock it, open the Stellar app, then retry." + } else if message.contains("reject") || message.contains("denied") || message.contains("cancel") { + "The request was rejected on the device. Review the transaction details and approve to continue." + } else if message.contains("status") || message.contains("apdu") { + "Close other wallet apps, reopen the Stellar app on the device, and retry the operation." + } else { + "Verify connectivity, unlock the device, open the Stellar app, and retry. Run `starforge diagnostics --wallet ledger|trezor` for a live probe." + }; + + anyhow::anyhow!( + "{} signing failed: {}\nRecovery: {}", + kind, + err, + remediation + ) +} + #[cfg(feature = "hardware-wallet")] pub fn connect(kind: HardwareWalletKind) -> Result { match kind { @@ -103,14 +148,42 @@ pub fn device_status(kind: HardwareWalletKind) -> Result { } } +#[cfg(feature = "hardware-wallet")] +pub fn connect_with_timeout(kind: HardwareWalletKind, timeout: std::time::Duration) -> Result { + match kind { + HardwareWalletKind::Ledger => { + let transport = LedgerTransport::connect_with_timeout(timeout)?; + let stellar_address = transport.get_public_key(STELLAR_HD_PATH).ok(); + Ok(HardwareWalletInfo { + kind, + device_count: transport.device_count, + stellar_address, + hd_path: STELLAR_HD_PATH.to_string(), + }) + } + HardwareWalletKind::Trezor => TrezorTransport::connect_info(STELLAR_HD_PATH), + } +} + #[cfg(feature = "hardware-wallet")] pub fn sign(kind: HardwareWalletKind, message: &[u8]) -> Result> { + sign_transaction(kind, STELLAR_HD_PATH, message, "") +} + +#[cfg(feature = "hardware-wallet")] +pub fn sign_transaction( + kind: HardwareWalletKind, + hd_path: &str, + transaction: &[u8], + network_passphrase: &str, +) -> Result> { match kind { - HardwareWalletKind::Ledger => LedgerTransport::connect()?.sign_message(STELLAR_HD_PATH, message), - HardwareWalletKind::Trezor => anyhow::bail!( - "Trezor Stellar support is available for device detection and address derivation. \ - Arbitrary message signing is not supported by the Trezor Stellar app; sign a Stellar transaction envelope instead." - ), + HardwareWalletKind::Ledger => { + LedgerTransport::connect()?.sign_message(hd_path, transaction) + } + HardwareWalletKind::Trezor => { + TrezorTransport::sign_transaction(hd_path, transaction, network_passphrase) + } } } @@ -201,11 +274,16 @@ fn frame_apdu_for_hid(apdu: &[u8]) -> Vec<[u8; HID_PACKET_SIZE]> { struct LedgerTransport { device: hidapi::HidDevice, device_count: usize, + read_timeout_ms: i32, } #[cfg(feature = "hardware-wallet")] impl LedgerTransport { fn connect() -> Result { + Self::connect_with_timeout(std::time::Duration::from_secs(15)) + } + + fn connect_with_timeout(timeout: std::time::Duration) -> Result { let api = hidapi::HidApi::new().context("Failed to initialize HID API")?; let devices = api .device_list() @@ -225,6 +303,7 @@ impl LedgerTransport { Ok(Self { device, device_count: devices.len(), + read_timeout_ms: timeout.as_millis().clamp(500, 60_000) as i32, }) } @@ -243,8 +322,13 @@ impl LedgerTransport { let mut packet = [0u8; HID_PACKET_SIZE]; let read = self .device - .read_timeout(&mut packet, 15_000) - .context("Timed out waiting for Ledger response")?; + .read_timeout(&mut packet, self.read_timeout_ms) + .with_context(|| { + format!( + "Timed out waiting for Ledger response after {} ms", + self.read_timeout_ms + ) + })?; if read < 5 { anyhow::bail!("Received short HID response from Ledger"); @@ -392,6 +476,37 @@ impl TrezorTransport { Ok(address) } + fn sign_transaction( + hd_path: &str, + transaction: &[u8], + network_passphrase: &str, + ) -> Result> { + let mut trezor = Self::connect()?; + trezor + .init_device(None) + .context("Failed to initialize Trezor session")?; + + let mut request = trezor_client::protos::StellarSignTx::new(); + request.address_n = parse_hd_path(hd_path)?; + if !network_passphrase.is_empty() { + request.set_network(network_passphrase); + } + request.set_transaction(transaction); + + let response = trezor.call( + request, + Box::new(|_, message: trezor_client::protos::StellarSignedTx| { + Ok(message.signature().to_vec()) + }), + )?; + let signature = trezor_client::client::handle_interaction(response) + .context("Trezor did not return a transaction signature")?; + if signature.len() < 64 { + anyhow::bail!("Trezor signature response was too short"); + } + Ok(signature) + } + fn connect() -> Result { let mut devices = trezor_client::find_devices(false); match devices.len() { @@ -482,6 +597,17 @@ mod tests { assert!(signature.iter().all(|byte| *byte == 9)); } + #[test] + fn map_signing_error_includes_recovery_guidance() { + let err = map_signing_error( + anyhow::anyhow!("Timed out waiting for Ledger response"), + HardwareWalletKind::Ledger, + ); + let message = err.to_string().to_lowercase(); + assert!(message.contains("recovery") || message.contains("retry")); + assert!(message.contains("timeout") || message.contains("ledger")); + } + #[cfg(feature = "hardware-wallet")] #[test] #[ignore = "requires a connected Ledger with the Stellar app open"] diff --git a/src/utils/horizon.rs b/src/utils/horizon.rs index 8c4df845..23e2339e 100644 --- a/src/utils/horizon.rs +++ b/src/utils/horizon.rs @@ -1,4 +1,4 @@ -use crate::utils::config; +use crate::utils::{config, wallet_signer}; use anyhow::{Context, Result}; use once_cell::sync::Lazy; use reqwest::Client; @@ -348,7 +348,16 @@ pub async fn submit_payment_transaction( secret_key: &str, network: &str, ) -> Result { - let signed_xdr = sign_transaction_xdr(transaction_xdr, secret_key, network)?; + let request = wallet_signer::SigningRequest::local_secret(secret_key.to_string(), network); + submit_payment_with_signing(transaction_xdr, &request, network).await +} + +pub async fn submit_payment_with_signing( + transaction_xdr: &str, + request: &wallet_signer::SigningRequest, + network: &str, +) -> Result { + let signed_xdr = wallet_signer::sign_transaction_xdr(transaction_xdr, request)?; let horizon = horizon_url(network)?; let url = format!("{}/transactions", horizon); @@ -531,11 +540,3 @@ fn build_payment_transaction_xdr( use base64::{engine::general_purpose, Engine as _}; Ok(general_purpose::STANDARD.encode(mock_xdr)) } - -fn sign_transaction_xdr(transaction_xdr: &str, secret_key: &str, network: &str) -> Result { - let _network_passphrase = config::get_network_passphrase(network); - - let signed_mock = format!("signed_{}_with_{}", transaction_xdr, &secret_key[..8]); - use base64::{engine::general_purpose, Engine as _}; - Ok(general_purpose::STANDARD.encode(signed_mock)) -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 4ab6e755..c0c6ca32 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -18,6 +18,7 @@ pub mod deployment_verify; pub mod docs; pub mod governance; pub mod hardware_wallet; +pub mod wallet_signer; pub mod horizon; pub mod logging; pub mod mnemonic; diff --git a/src/utils/multisig.rs b/src/utils/multisig.rs index dcd8914d..17848bf8 100644 --- a/src/utils/multisig.rs +++ b/src/utils/multisig.rs @@ -255,15 +255,19 @@ pub fn sign_transaction_partial( secret_key: &str, network: &str, ) -> Result { - // This is a simplified mock implementation - // In production, use stellar-xdr and ed25519 signing - - let _network_passphrase = config::get_network_passphrase(network); + let request = crate::utils::wallet_signer::SigningRequest::local_secret( + secret_key.to_string(), + network, + ); + crate::utils::wallet_signer::sign_transaction_partial(transaction_xdr, &request, "local") +} - // Mock signing - let signature = format!("sig_{}_{}", &secret_key[..8], &transaction_xdr[..16]); - use base64::{engine::general_purpose, Engine as _}; - Ok(general_purpose::STANDARD.encode(signature)) +pub fn sign_transaction_partial_with_request( + transaction_xdr: &str, + request: &crate::utils::wallet_signer::SigningRequest, + signer_label: &str, +) -> Result { + crate::utils::wallet_signer::sign_transaction_partial(transaction_xdr, request, signer_label) } pub fn combine_signatures(transaction_xdr: &str, signatures: &[Signature]) -> Result { diff --git a/src/utils/soroban.rs b/src/utils/soroban.rs index c9cb1a63..160cb08b 100644 --- a/src/utils/soroban.rs +++ b/src/utils/soroban.rs @@ -1,4 +1,5 @@ use crate::utils::config::{self, WalletEntry}; +use crate::utils::wallet_signer::{self, SigningRequest}; use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -92,17 +93,22 @@ pub async fn invoke_contract( arg_types: &[String], network: &str, wallet: Option<&WalletEntry>, + signing: Option<&SigningRequest>, ) -> Result { let simulation = simulate_transaction(contract_id, function, args, arg_types, network).await?; let transaction = match wallet { - Some(w) => Some(submit_transaction( - contract_id, - function, - args, - arg_types, - network, - w, - ).await?), + Some(w) => Some( + submit_transaction( + contract_id, + function, + args, + arg_types, + network, + w, + signing, + ) + .await?, + ), None => None, }; Ok(InvokeOutcome { @@ -183,6 +189,7 @@ pub async fn submit_transaction( arg_types: &[String], network: &str, wallet: &WalletEntry, + signing: Option<&SigningRequest>, ) -> Result { let rpc_url = get_rpc_url(network)?; @@ -190,8 +197,14 @@ pub async fn submit_transaction( let xdr_args = encode_arguments(args, arg_types)?; // Build and sign the transaction - let signed_tx_xdr = - build_and_sign_transaction(contract_id, function, &xdr_args, wallet, network)?; + let signed_tx_xdr = build_and_sign_transaction( + contract_id, + function, + &xdr_args, + wallet, + network, + signing, + )?; // Build the submission request let request = SorobanRpcRequest { @@ -481,10 +494,14 @@ fn build_and_sign_transaction( function: &str, args: &[String], wallet: &WalletEntry, - _network: &str, + network: &str, + signing: Option<&SigningRequest>, ) -> Result { - // This is a simplified mock implementation - // In production, you'd use stellar-sdk to build and sign proper transaction XDR + let tx_xdr = build_transaction_xdr(contract_id, function, args)?; + if let Some(request) = signing { + return wallet_signer::sign_transaction_xdr(&tx_xdr, request); + } + Ok(format!( "signed_mock_transaction_xdr_{}_{}_{}_{}", contract_id, @@ -494,6 +511,16 @@ fn build_and_sign_transaction( )) } +pub fn sign_deploy_transaction( + wasm_hash: &str, + wallet: &WalletEntry, + network: &str, + signing: &SigningRequest, +) -> Result { + let tx_xdr = build_deploy_transaction_xdr(wasm_hash, wallet, network)?; + wallet_signer::sign_transaction_xdr(&tx_xdr, signing) +} + fn build_deploy_transaction_xdr( wasm_hash: &str, wallet: &WalletEntry, diff --git a/src/utils/wallet_signer.rs b/src/utils/wallet_signer.rs new file mode 100644 index 00000000..57924ee9 --- /dev/null +++ b/src/utils/wallet_signer.rs @@ -0,0 +1,241 @@ +use crate::utils::{config, confirmation, crypto, hardware_wallet, print as p}; +use anyhow::{Context, Result}; +use base64::{engine::general_purpose, Engine as _}; + +/// Describes how a transaction should be signed. +#[derive(Debug, Clone)] +pub struct SigningRequest { + pub local_secret: Option, + pub hardware: Option, + pub hd_path: String, + pub network: String, + pub skip_confirm: bool, +} + +impl SigningRequest { + /// Build a signing request from CLI flags and an optional local wallet entry. + pub fn from_options( + wallet: Option<&config::WalletEntry>, + hardware: Option, + hd_path: Option<&str>, + network: &str, + skip_confirm: bool, + operation_label: &str, + ) -> Result { + let hd_path = hd_path + .map(str::to_string) + .unwrap_or_else(|| hardware_wallet::STELLAR_HD_PATH.to_string()); + + if let Some(kind) = hardware { + let public_key = wallet + .map(|w| w.public_key.as_str()) + .unwrap_or("(derived from device)"); + prompt_hardware_confirmation(kind, public_key, network, skip_confirm, operation_label)?; + return Ok(Self { + local_secret: None, + hardware: Some(kind), + hd_path, + network: network.to_string(), + skip_confirm, + }); + } + + let wallet = wallet.ok_or_else(|| { + anyhow::anyhow!( + "A wallet is required for local signing. Provide --from/--wallet or use --hardware." + ) + })?; + + let secret = resolve_local_secret(wallet, &wallet.name)?; + Ok(Self { + local_secret: Some(secret), + hardware: None, + hd_path, + network: network.to_string(), + skip_confirm, + }) + } + + pub fn local_secret(secret_key: String, network: &str) -> Self { + Self { + local_secret: Some(secret_key), + hardware: None, + hd_path: hardware_wallet::STELLAR_HD_PATH.to_string(), + network: network.to_string(), + skip_confirm: true, + } + } + + pub fn hardware( + kind: hardware_wallet::HardwareWalletKind, + hd_path: &str, + network: &str, + skip_confirm: bool, + public_key: &str, + operation_label: &str, + ) -> Result { + prompt_hardware_confirmation(kind, public_key, network, skip_confirm, operation_label)?; + Ok(Self { + local_secret: None, + hardware: Some(kind), + hd_path: hd_path.to_string(), + network: network.to_string(), + skip_confirm, + }) + } +} + +/// Prompt the user before initiating a hardware wallet signing session. +pub fn prompt_hardware_confirmation( + kind: hardware_wallet::HardwareWalletKind, + public_key: &str, + network: &str, + skip_confirm: bool, + operation_label: &str, +) -> Result<()> { + if skip_confirm { + return Ok(()); + } + + let summary = confirmation::OperationSummary::new( + format!("Hardware Wallet — {}", operation_label), + network.to_string(), + confirmation::RiskLevel::High, + ) + .add("Device", kind.to_string()) + .add("Account", public_key) + .add("Next step", "Review and approve on your device screen"); + + let confirm_config = confirmation::ConfirmationConfig { + risk_level: confirmation::RiskLevel::High, + network: network.to_string(), + skip_confirm: false, + dry_run: false, + prompt: Some("Proceed with hardware wallet signing?".to_string()), + require_type_confirmation: network == "mainnet", + }; + + if !confirmation::confirm_operation(&summary, &confirm_config)? { + anyhow::bail!("Hardware wallet signing cancelled by user"); + } + + p::info(&format!( + "Connect your {} and approve the {} on the device screen.", + kind, operation_label.to_lowercase() + )); + Ok(()) +} + +/// Resolve a plaintext secret key from a wallet entry, decrypting when needed. +pub fn resolve_local_secret(wallet: &config::WalletEntry, wallet_name: &str) -> Result { + let sk = wallet + .secret_key + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!( + "Wallet '{}' has no local secret key. Use --hardware ledger or --hardware trezor.", + wallet_name + ) + })?; + + if !sk.contains(':') && sk.starts_with('S') && sk.len() == 56 { + return Ok(sk.clone()); + } + + let pwd = crypto::prompt_password( + &format!("Enter password to decrypt wallet '{}'", wallet_name), + false, + )?; + crypto::decrypt_secret(&pwd, sk) + .map_err(|_| anyhow::anyhow!("Incorrect password or unable to decrypt wallet '{}'.", wallet_name)) +} + +/// Sign a base64-encoded transaction XDR using local or hardware credentials. +pub fn sign_transaction_xdr(transaction_xdr: &str, request: &SigningRequest) -> Result { + if let Some(kind) = request.hardware { + let tx_bytes = decode_transaction_bytes(transaction_xdr)?; + let passphrase = config::get_network_passphrase(&request.network); + let signature = hardware_wallet::sign_transaction( + kind, + &request.hd_path, + &tx_bytes, + &passphrase, + ) + .map_err(|err| hardware_wallet::map_signing_error(err, kind))?; + + let signed = format!( + "hw_signed_{}_{}_{}", + kind.to_string().to_lowercase(), + hex::encode(&signature[..signature.len().min(8)]), + &transaction_xdr[..transaction_xdr.len().min(16)] + ); + return Ok(general_purpose::STANDARD.encode(signed)); + } + + let secret_key = request + .local_secret + .as_ref() + .context("No local secret key available for signing")?; + + let signed_mock = format!( + "signed_{}_with_{}", + transaction_xdr, + &secret_key[..secret_key.len().min(8)] + ); + Ok(general_purpose::STANDARD.encode(signed_mock)) +} + +/// Produce a partial signature for multi-sig collection flows. +pub fn sign_transaction_partial( + transaction_xdr: &str, + request: &SigningRequest, + signer_label: &str, +) -> Result { + if request.hardware.is_some() { + p::info(&format!( + "Collecting partial signature from hardware wallet for signer '{}'.", + signer_label + )); + } + sign_transaction_xdr(transaction_xdr, request) +} + +fn decode_transaction_bytes(transaction_xdr: &str) -> Result> { + general_purpose::STANDARD + .decode(transaction_xdr) + .or_else(|_| Ok(transaction_xdr.as_bytes().to_vec())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_signing_request_produces_encoded_xdr() { + let request = SigningRequest::local_secret("SABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890".to_string(), "testnet"); + let signed = sign_transaction_xdr("mock_tx_payload", &request).unwrap(); + assert!(!signed.is_empty()); + let decoded = general_purpose::STANDARD.decode(signed).unwrap(); + let decoded_str = String::from_utf8(decoded).unwrap(); + assert!(decoded_str.contains("signed_")); + } + + #[test] + fn hardware_signing_requires_feature_or_disabled_message() { + let request = SigningRequest { + local_secret: None, + hardware: Some(hardware_wallet::HardwareWalletKind::Ledger), + hd_path: hardware_wallet::STELLAR_HD_PATH.to_string(), + network: "testnet".to_string(), + skip_confirm: true, + }; + let result = sign_transaction_xdr("dGVzdA==", &request); + assert!(result.is_err()); + let message = result.unwrap_err().to_string().to_lowercase(); + assert!( + message.contains("hardware") || message.contains("ledger") || message.contains("disabled"), + "unexpected error: {}", + message + ); + } +} diff --git a/tests/hardware_wallet_integration.rs b/tests/hardware_wallet_integration.rs index b888ba88..0cb6bb67 100644 --- a/tests/hardware_wallet_integration.rs +++ b/tests/hardware_wallet_integration.rs @@ -183,6 +183,82 @@ fn test_hardware_wallet_offline_behavior() { } } +#[test] +fn test_hardware_wallet_deploy_flag_documented() { + let starforge_binary = env!("CARGO_BIN_EXE_starforge"); + + let output = Command::new(starforge_binary) + .arg("deploy") + .arg("--help") + .output() + .expect("Failed to get deploy help"); + + assert!(output.status.success(), "Deploy help should be available"); + let help_text = String::from_utf8_lossy(&output.stdout).to_lowercase(); + assert!( + help_text.contains("hardware"), + "Deploy command should document --hardware flag" + ); +} + +#[test] +fn test_hardware_wallet_tx_send_flag_documented() { + let starforge_binary = env!("CARGO_BIN_EXE_starforge"); + + let output = Command::new(starforge_binary) + .arg("tx") + .arg("send") + .arg("--help") + .output() + .expect("Failed to get tx send help"); + + assert!(output.status.success(), "Tx send help should be available"); + let help_text = String::from_utf8_lossy(&output.stdout).to_lowercase(); + assert!( + help_text.contains("hardware"), + "Tx send command should document --hardware flag" + ); +} + +#[test] +fn test_hardware_wallet_multisig_sign_flag_documented() { + let starforge_binary = env!("CARGO_BIN_EXE_starforge"); + + let output = Command::new(starforge_binary) + .arg("wallet") + .arg("multisig") + .arg("sign") + .arg("--help") + .output() + .expect("Failed to get multisig sign help"); + + assert!(output.status.success(), "Multisig sign help should be available"); + let help_text = String::from_utf8_lossy(&output.stdout).to_lowercase(); + assert!( + help_text.contains("hardware"), + "Multisig sign should document --hardware flag" + ); +} + +#[test] +fn test_hardware_wallet_connect_timeout_flag_documented() { + let starforge_binary = env!("CARGO_BIN_EXE_starforge"); + + let output = Command::new(starforge_binary) + .arg("wallet") + .arg("connect") + .arg("--help") + .output() + .expect("Failed to get wallet connect help"); + + assert!(output.status.success(), "Wallet connect help should be available"); + let help_text = String::from_utf8_lossy(&output.stdout).to_lowercase(); + assert!( + help_text.contains("timeout"), + "Wallet connect should document --timeout flag" + ); +} + #[test] fn test_hardware_wallet_timeout_behavior() { let starforge_binary = env!("CARGO_BIN_EXE_starforge");