use crate::utils::hwi::{HwiWallet, enumerate_hwi_devices, first_hwi_device};
use bdk_wallet::bitcoin::{
- Psbt,
+ Network, Psbt,
bip32::{ChildNumber, DerivationPath},
hex::DisplayHex,
};
/// Needed to build a wallet descriptor for the device.
Xpub {
/// Derivation path, e.g. `m/84'/1'/0'`.
- #[arg(default_value = "m/84'/1'/0'")]
- path: String,
+ // #[arg(default_value = "m/84'/1'/0'")]
+ path: Option<String>,
/// Output descriptor script type.
- #[arg(long = "type", value_parser = ["wpkh", "tr", "pkh"])]
+ #[arg(long = "type", value_parser = ["wpkh", "tr", "sh-wpkh", "pkh"])]
script_type: Option<String>,
+
+ /// Target a specific connected device by master fingerprint (hex).
+ #[arg(long)]
+ fingerprint: Option<String>,
},
/// List all connected hardware wallet devices.
Devices,
let hwi_wallet = HwiWallet::default();
match &self.subcommand {
- HwiSubCommand::Xpub { path, script_type } => {
- let derivation = DerivationPath::from_str(path)
+ HwiSubCommand::Xpub {
+ path,
+ script_type,
+ fingerprint,
+ } => {
+ let path = match path {
+ Some(path) => path.clone(),
+ None => {
+ let coin = if network == Network::Bitcoin { 0 } else { 1 };
+ format!("m/84'/{coin}'/0'")
+ }
+ };
+
+ let derivation = DerivationPath::from_str(&path)
.map_err(|e| Error::Generic(format!("Invalid derivation path: {e}")))?;
- let device = first_hwi_device(network, &hwi_wallet).await?;
+ let device = first_hwi_device(network, &hwi_wallet, fingerprint.as_deref()).await?;
let xpub = device.get_extended_pubkey(&derivation).await?;
- let fingerprint = device.get_master_fingerprint().await?.to_string();
+ let master_fingerprint = device.get_master_fingerprint().await?.to_string();
let origin = path.trim_start_matches('m').trim_start_matches('/');
- let key_expression = format!("[{fingerprint}/{origin}]{xpub}");
+ let key_expression = format!("[{master_fingerprint}/{origin}]{xpub}");
let mut out = json!({
"path": path,
"xpub": xpub.to_string(),
- "master_fingerprint": fingerprint,
+ "master_fingerprint": master_fingerprint,
"key_expression": key_expression,
});
match script_type
.clone()
- .or_else(|| infer_script_type(path).map(str::to_string))
+ .or_else(|| infer_script_type(&path).map(str::to_string))
{
Some(stype) => {
out["script_type"] = json!(stype);
/// policy on a Ledger for `address`/`sign`.
#[arg(env = "HWI_HMAC", long)]
pub hmac: Option<String>,
+ /// Target a specific connected device by master fingerprint (hex).
+ #[arg(long)]
+ pub fingerprint: Option<String>,
#[command(subcommand)]
pub subcommand: WalletHwiSubCommand,
}
pub enum WalletHwiSubCommand {
/// Register the wallet's policy on the device, returning its HMAC.
Register,
- /// Display a receive address on the device.
+ /// Display the receive address at an index on the device for verification.
+ ///
+ /// It derives and shows the address at `--index` but does NOT
+ /// advance the wallet's receive index.
Address {
/// Address index to derive.
#[arg(long, default_value_t = 0)]
) -> Result<serde_json::Value, Error> {
let network = ctx.network;
+ let target_fp = self.fingerprint.as_deref();
+
// Derive the multipath policy from the wallet's public descriptors.
let ext_desc = ctx
.state
match &self.subcommand {
WalletHwiSubCommand::Register => {
tracing::debug!("Registering wallet '{wallet_name}' with policy '{policy}'");
- let device = first_hwi_device(network, &hwi_wallet).await?;
+ let device = first_hwi_device(network, &hwi_wallet, target_fp).await?;
match device.register_wallet(wallet_name, &policy).await {
- Ok(hmac) => Ok(json!({
- "success": true,
- "hmac": hmac.map(|h| h.to_lower_hex_string()),
- })),
+ Ok(hmac) => {
+ let hmac = hmac.map(|h| h.to_lower_hex_string());
+ let next_step = hmac.as_ref().map(|h| {
+ format!(
+ "Save this HMAC and pass it to `address`/`sign` with --hmac {h} (or set HWI_HMAC)."
+ )
+ });
+ Ok(json!({ "success": true, "hmac": hmac, "next_step": next_step }))
+ }
// Some devices don't implement registration.
Err(async_hwi::Error::UnimplementedMethod) => Ok(json!({
"success": true,
}),
};
- let device = first_hwi_device(network, &hwi_wallet).await?;
- let displayed = match script {
+ let device = first_hwi_device(network, &hwi_wallet, target_fp).await?;
+ let (displayed, message) = match script {
Some(script) => match device.display_address(&script).await {
Ok(()) => (
true,
"change": change,
"address": address,
"displayed_on_device": displayed,
+ "message": message,
}))
}
WalletHwiSubCommand::Sign { psbt } => {
let mut psbt = Psbt::from_str(psbt)
.map_err(|e| Error::Generic(format!("Failed to parse PSBT: {e}")))?;
- let device = first_hwi_device(network, &hwi_wallet).await?;
+ let device = first_hwi_device(network, &hwi_wallet, target_fp).await?;
device.sign_tx(&mut psbt).await?;
Ok(json!({ "psbt": psbt.to_string() }))
}
/// keychain branch (0 = external, 1 = internal).
fn build_descriptor(script_type: &str, key_expr: &str, branch: u32) -> Result<String, Error> {
let raw = match script_type {
+ "wpkh" => format!("wpkh({key_expr}/{branch}/*)"),
"tr" => format!("tr({key_expr}/{branch}/*)"),
"pkh" => format!("pkh({key_expr}/{branch}/*)"),
"sh-wpkh" => format!("sh(wpkh({key_expr}/{branch}/*))"),
- _ => format!("wpkh({key_expr}/{branch}/*)"),
+ other => return Err(Error::Generic(format!("Unsupported script type: {other}"))),
};
let descriptor = Descriptor::<DescriptorPublicKey>::from_str(&raw)
.map_err(|e| Error::Generic(format!("Failed to build descriptor: {e}")))?;
/// - Combines a standard `/0/*` external and `/1/*` internal pair into a single
/// `/<0;1>/*` multipath descriptor.
fn build_device_policy(ext: &str, int: Option<&str>) -> Result<String, Error> {
- let ext = strip_checksum(ext).trim().to_string();
-
- if ext.contains("xprv") || ext.contains("tprv") {
+ let ext = strip_checksum(ext).trim();
+ if contains_private_key(ext) {
return Err(Error::Generic(
"Hardware wallet policy must use public keys (xpub), not private keys".to_string(),
));
}
- // Already a multipath descriptor: use as-is.
- if ext.contains("/**") || ext.contains('<') {
- return Ok(ext);
- }
-
- match int {
- Some(int) => {
- let int = strip_checksum(int).trim();
- if int.contains("xprv") || int.contains("tprv") {
- return Err(Error::Generic(
- "Hardware wallet policy must use public keys (xpub), not private keys"
- .to_string(),
- ));
+ let raw = if ext.contains("/**") || ext.contains('<') {
+ // Already a multipath descriptor.
+ ext.to_string()
+ } else {
+ match int {
+ Some(int) => {
+ let int = strip_checksum(int).trim();
+ if contains_private_key(int) {
+ return Err(Error::Generic(
+ "Hardware wallet policy must use public keys (xpub), not private keys"
+ .to_string(),
+ ));
+ }
+ // `/0/*` and `/1/*` only ever appear as a key's wildcard branch.
+ if ext.replace("/0/*", "/1/*") != int {
+ return Err(Error::Generic(
+ "Cannot derive a multipath policy: the wallet's external and internal \
+ descriptors are not a standard `/0/*` and `/1/*` pair."
+ .to_string(),
+ ));
+ }
+ ext.replace("/0/*", "/<0;1>/*")
}
- // `/0/*` and `/1/*` only ever appear as a key's wildcard branch.
- if ext.replace("/0/*", "/1/*") != int {
- return Err(Error::Generic(
- "Cannot derive a multipath policy: the wallet's external and internal \
- descriptors are not a standard `/0/*` and `/1/*` pair."
- .to_string(),
- ));
+ None => {
+ tracing::warn!(
+ "Wallet has no separate internal (change) descriptor; the device policy will \
+ only cover receive addresses."
+ );
+ ext.to_string()
}
- Ok(ext.replace("/0/*", "/<0;1>/*"))
- }
- None => {
- tracing::warn!(
- "Wallet has no separate internal (change) descriptor; the device policy will \
- only cover receive addresses."
- );
- Ok(ext)
}
+ };
+
+ // Canonicalize so the device receives one well-formed, checksummed form.
+ let descriptor = Descriptor::<DescriptorPublicKey>::from_str(&raw)
+ .map_err(|e| Error::Generic(format!("Invalid wallet policy descriptor: {e}")))?;
+ Ok(descriptor.to_string())
+}
+
+fn contains_private_key(descriptor: &str) -> bool {
+ descriptor.contains("xprv") || descriptor.contains("tprv")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::commands::CliOpts;
+ use clap::CommandFactory;
+
+ const EXT: &str = "wpkh([f5acc2fd/84'/1'/0']tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/0/*)";
+ const INT: &str = "wpkh([f5acc2fd/84'/1'/0']tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/1/*)";
+
+ #[test]
+ fn cli_definition_is_valid() {
+ CliOpts::command().debug_assert();
+ }
+
+ #[test]
+ fn infers_script_type_from_purpose() {
+ assert_eq!(infer_script_type("m/44'/1'/0'"), Some("pkh"));
+ assert_eq!(infer_script_type("m/49'/1'/0'"), Some("sh-wpkh"));
+ assert_eq!(infer_script_type("m/84'/1'/0'"), Some("wpkh"));
+ assert_eq!(infer_script_type("m/86h/0h/0h"), Some("tr"));
+ assert_eq!(infer_script_type("m/48'/1'/0'/2'"), None);
+ }
+
+ #[test]
+ fn build_descriptor_rejects_unknown_type() {
+ assert!(build_descriptor("foo", "key", 0).is_err());
+ }
+
+ #[test]
+ fn policy_combines_and_canonicalizes() {
+ let policy = build_device_policy(EXT, Some(INT)).unwrap();
+ // Combined into a single multipath descriptor...
+ assert!(policy.contains("<0;1>"));
+ assert!(!policy.contains("/0/*"));
+ assert!(!policy.contains("/1/*"));
+ // ...and re-serialized with a checksum.
+ assert!(policy.contains('#'));
+ }
+
+ #[test]
+ fn policy_rejects_private_keys() {
+ let xprv = "wpkh([f5acc2fd/84'/1'/0']tprv8ZgxMBicQKsPd.../0/*)";
+ assert!(build_device_policy(xprv, None).is_err());
+ }
+
+ #[test]
+ fn policy_rejects_mismatched_pair() {
+ let other = "wpkh([f5acc2fd/84'/1'/0']tpubDDtb2WPYwEWw2WWDV7reLV348iJHw2HmhzvPysKKrJw3hYmvrd4jasyoioVPdKGQqjyaBMEvTn1HvHWDSVqQ6amyyxRZ5YjpPBBGjJ8yu8S/1/*)";
+ assert!(build_device_policy(EXT, Some(other)).is_err());
}
}
) -> Result<Vec<Box<dyn HWI + Send>>, Error> {
let mut devices: Vec<Box<dyn HWI + Send>> = Vec::new();
- // Specter simulator (TCP) then physical Specter devices (serial).
- if let Ok(device) = SpecterSimulator::try_connect().await {
- devices.push(device.into());
- }
+ // Specter (serial).
match Specter::enumerate().await {
Ok(specters) => devices.extend(specters.into_iter().map(Into::into)),
Err(e) => warn!("Specter enumeration failed: {e:?}"),
Err(e) => warn!("Jade enumeration failed: {e:?}"),
}
- // Ledger simulator (Speculos).
- if let Ok(device) = LedgerSimulator::try_connect().await {
- devices.push(device.into());
- }
+ // USB (HID): BitBox02, Coldcard and Ledger. A HID init failure is non-fatal
+ match HidApi::new() {
+ Ok(api) => {
+ let api = Box::new(api);
- // BitBox02 simulator (TCP, defaults to 127.0.0.1:15423). async-hwi only
- // connects a physical BitBox over USB, so the simulator is reached directly
- // through the re-exported bitbox-api. A connection error just means the
- // simulator isn't running, so we ignore it and keep probing other devices.
- {
- if let Ok(bitbox) =
- BitBoxApi::<TokioRuntime>::from_simulator(None, Box::new(NoiseConfigNoCache {})).await
- {
- match bitbox.unlock_and_pair().await {
- Ok(pairing) => match pairing.wait_confirm().await {
- Ok(paired) => {
- // A freshly started simulator is unseeded, so key operations fail.
- // Applicable to only simulators
- if let Ok(info) = paired.device_info().await
- && !info.initialized
- && let Err(e) = paired.restore_from_mnemonic().await
- {
- warn!("BitBox simulator seeding failed: {e:?}");
- }
- let mut bb02 = BitBox02::from(paired).with_network(network);
- if let Some(policy) = wallet.policy {
- bb02 = bb02.with_policy(policy).map_err(map_device_err)?;
+ for device_info in api.device_list() {
+ // BitBox02
+ if async_hwi::bitbox::is_bitbox02(device_info)
+ && let Ok(handle) = device_info.open_device(&api)
+ && let Ok(pairing) =
+ PairingBitbox02WithLocalCache::<runtime::TokioRuntime>::connect(
+ handle, None,
+ )
+ .await
+ && let Ok((device, _)) = pairing.wait_confirm().await
+ {
+ push_bitbox(
+ &mut devices,
+ BitBox02::from(device).with_network(network),
+ wallet.policy,
+ );
+ }
+
+ // Coldcard
+ if device_info.vendor_id() == coldcard::api::COINKITE_VID
+ && device_info.product_id() == coldcard::api::CKCC_PID
+ && let Some(sn) = device_info.serial_number()
+ {
+ match coldcard::api::Coldcard::open(&api, sn, None) {
+ Ok((cc, _)) => {
+ let mut hw = coldcard::Coldcard::from(cc);
+ if let Some(name) = wallet.name {
+ hw = hw.with_wallet_name(name.to_string());
+ }
+ devices.push(hw.into());
}
- devices.push(bb02.into());
+ Err(e) => warn!("Failed to open Coldcard (SN {sn}): {e:?}"),
}
- Err(e) => warn!("BitBox simulator pairing confirmation failed: {e:?}"),
- },
- Err(e) => warn!("BitBox simulator unlock/pair failed: {e:?}"),
- }
- }
- }
-
- // USB (HID) devices: BitBox02 and Coldcard.
- let api = Box::new(HidApi::new().map_err(|e| Error::Generic(e.to_string()))?);
-
- for device_info in api.device_list() {
- // BitBox02
- if async_hwi::bitbox::is_bitbox02(device_info)
- && let Ok(handle) = device_info.open_device(&api)
- && let Ok(pairing) =
- PairingBitbox02WithLocalCache::<runtime::TokioRuntime>::connect(handle, None).await
- && let Ok((device, _)) = pairing.wait_confirm().await
- {
- let mut bb02 = BitBox02::from(device).with_network(network);
- if let Some(policy) = wallet.policy {
- bb02 = bb02.with_policy(policy).map_err(map_device_err)?;
+ }
}
- devices.push(bb02.into());
- }
- // Coldcard
- if device_info.vendor_id() == coldcard::api::COINKITE_VID
- && device_info.product_id() == coldcard::api::CKCC_PID
- && let Some(sn) = device_info.serial_number()
- {
- match coldcard::api::Coldcard::open(&api, sn, None) {
- Ok((cc, _)) => {
- let mut hw = coldcard::Coldcard::from(cc);
- if let Some(name) = wallet.name {
- hw = hw.with_wallet_name(name.to_string());
- }
- devices.push(hw.into());
+ // Ledger (HID).
+ for detected in Ledger::<TransportHID>::enumerate(&api) {
+ if let Ok(device) = Ledger::<TransportHID>::connect(&api, detected) {
+ push_ledger(&mut devices, device, wallet)?;
}
- Err(e) => warn!("Failed to open Coldcard (SN {sn}): {e:?}"),
}
}
+ Err(e) => warn!("HID API init failed; skipping USB devices: {e}"),
}
- // Ledger (HID).
- for detected in Ledger::<TransportHID>::enumerate(&api) {
- if let Ok(mut device) = Ledger::<TransportHID>::connect(&api, detected) {
- // A registered wallet policy (and its HMAC) is required before a
- // Ledger will operate on anything other than default single-sig.
- if let (Some(name), Some(policy)) = (wallet.name, wallet.policy) {
- let hmac = match wallet.hmac {
- Some(s) => {
- let bytes = Vec::from_hex(s)
- .map_err(|e| Error::Generic(format!("Invalid HMAC hex: {e}")))?;
- let mut h = [0u8; 32];
- if bytes.len() != h.len() {
- return Err(Error::Generic(
- "HMAC must be 32 bytes (64 hex chars)".to_string(),
- ));
- }
- h.copy_from_slice(&bytes);
- Some(h)
+ // Simulators
+
+ if let Ok(device) = SpecterSimulator::try_connect().await {
+ devices.push(device.into());
+ }
+ if let Ok(device) = LedgerSimulator::try_connect().await {
+ devices.push(device.into());
+ }
+
+ // BitBox02 simulator
+ if let Ok(bitbox) =
+ BitBoxApi::<TokioRuntime>::from_simulator(None, Box::new(NoiseConfigNoCache {})).await
+ {
+ match bitbox.unlock_and_pair().await {
+ Ok(pairing) => match pairing.wait_confirm().await {
+ Ok(paired) => {
+ // A freshly started simulator is unseeded, so key operations
+ // fail. Create a seed to carry out operations
+ if let Ok(info) = paired.device_info().await
+ && !info.initialized
+ && let Err(e) = paired.restore_from_mnemonic().await
+ {
+ warn!("BitBox simulator seeding failed: {e:?}");
}
- None => None,
- };
- device = device
- .with_wallet(name, policy, hmac)
- .map_err(map_device_err)?;
- }
- devices.push(device.into());
+ push_bitbox(
+ &mut devices,
+ BitBox02::from(paired).with_network(network),
+ wallet.policy,
+ );
+ }
+ Err(e) => warn!("BitBox simulator pairing confirmation failed: {e:?}"),
+ },
+ Err(e) => warn!("BitBox simulator unlock/pair failed: {e:?}"),
}
}
Ok(devices)
}
-/// Enumerate devices and return the first one, erroring if none are found.
+/// Select a single connected device, by `target_fingerprint` when given.
///
-/// Used by the single-device subcommands (register/address/sign). If more than
-/// one device is connected a warning is logged and the first is used.
+/// With no target and several devices connected, the first (physical devices
+/// rank before simulators) is used and a warning lists the alternatives. The
+/// chosen device's fingerprint and model are always logged.
pub async fn first_hwi_device(
network: Network,
wallet: &HwiWallet<'_>,
+ target_fingerprint: Option<&str>,
) -> Result<Box<dyn HWI + Send>, Error> {
let devices = enumerate_hwi_devices(network, wallet).await?;
- let count = devices.len();
- if count > 1 {
- warn!("{count} hardware wallets detected; using the first one.");
+ if devices.is_empty() {
+ return Err(Error::Generic("No hardware wallet detected".to_string()));
+ }
+
+ // Label devices with their master fingerprint for selection and logging.
+ let mut labeled: Vec<(String, Box<dyn HWI + Send>)> = Vec::with_capacity(devices.len());
+ for device in devices {
+ match device.get_master_fingerprint().await {
+ Ok(fp) => labeled.push((fp.to_string(), device)),
+ Err(e) => warn!("Ignoring a device with an unreadable fingerprint: {e:?}"),
+ }
+ }
+ if labeled.is_empty() {
+ return Err(Error::Generic(
+ "No usable hardware wallet detected".to_string(),
+ ));
+ }
+
+ let (fingerprint, device) = match target_fingerprint {
+ Some(target) => labeled
+ .into_iter()
+ .find(|(fp, _)| fp.eq_ignore_ascii_case(target))
+ .ok_or_else(|| {
+ Error::Generic(format!("No connected device matches fingerprint {target}"))
+ })?,
+ None => {
+ if labeled.len() > 1 {
+ let fingerprints: Vec<&str> = labeled.iter().map(|(fp, _)| fp.as_str()).collect();
+ warn!(
+ "{} hardware wallets connected ({}); using the first. Select one with --fingerprint.",
+ labeled.len(),
+ fingerprints.join(", ")
+ );
+ }
+ labeled
+ .into_iter()
+ .next()
+ .expect("labeled is non-empty here")
+ }
+ };
+
+ warn!(
+ "Using hardware wallet {fingerprint} ({})",
+ device.device_kind()
+ );
+ Ok(device)
+}
+
+/// Attach an optional policy to a BitBox and push it. A policy failure skips
+/// the device with a warning instead of aborting the whole scan.
+fn push_bitbox(
+ devices: &mut Vec<Box<dyn HWI + Send>>,
+ bb02: BitBox02<TokioRuntime>,
+ policy: Option<&str>,
+) {
+ match policy {
+ Some(policy) => match bb02.with_policy(policy) {
+ Ok(device) => devices.push(device.into()),
+ Err(e) => warn!("BitBox with_policy failed; skipping device: {e:?}"),
+ },
+ None => devices.push(bb02.into()),
}
- devices
- .into_iter()
- .next()
- .ok_or_else(|| Error::Generic("No hardware wallet detected".to_string()))
}
-fn map_device_err<E: std::fmt::Display>(e: E) -> Error {
- Error::Generic(e.to_string())
+/// Attach the wallet (name/policy/hmac) to a Ledger and push it. A `with_wallet`
+/// failure skips the device with a warning; a malformed `--hmac` is a user error
+/// and is returned.
+fn push_ledger(
+ devices: &mut Vec<Box<dyn HWI + Send>>,
+ device: Ledger<TransportHID>,
+ wallet: &HwiWallet<'_>,
+) -> Result<(), Error> {
+ let device = match (wallet.name, wallet.policy) {
+ (Some(name), Some(policy)) => {
+ let hmac = parse_registration_hmac(wallet.hmac)?;
+ match device.with_wallet(name, policy, hmac) {
+ Ok(device) => device,
+ Err(e) => {
+ warn!("Ledger with_wallet failed; skipping device: {e:?}");
+ return Ok(());
+ }
+ }
+ }
+ _ => device,
+ };
+ devices.push(device.into());
+ Ok(())
+}
+
+/// Parse a 32-byte registration HMAC from its hex representation.
+fn parse_registration_hmac(hmac: Option<&str>) -> Result<Option<[u8; 32]>, Error> {
+ let Some(s) = hmac else {
+ return Ok(None);
+ };
+ let bytes = Vec::from_hex(s).map_err(|e| Error::Generic(format!("Invalid HMAC hex: {e}")))?;
+ let mut h = [0u8; 32];
+ if bytes.len() != h.len() {
+ return Err(Error::Generic(
+ "HMAC must be 32 bytes (64 hex chars)".to_string(),
+ ));
+ }
+ h.copy_from_slice(&bytes);
+ Ok(Some(h))
}