]> Untitled Git - bdk-cli/commitdiff
feat(hwi): Add fingerprint to "hwi xpub"
authorVihiga Tyonum <withtvpeter@gmail.com>
Fri, 9 Oct 2026 13:15:17 +0000 (14:15 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Mon, 19 Oct 2026 09:25:02 +0000 (10:25 +0100)
- add --fingerprint to `hwi xpub` and `wallet <name> hwi`, select the
  matching device, and enumerate physical devices before simulators.

src/handlers/hwi.rs
src/utils/hwi.rs

index 657c32384468277e91ab8f2d1dc85411a3b81eff..1db00705987b047125aeae883e58e07750ca85a8 100644 (file)
@@ -22,7 +22,7 @@ use crate::handlers::{AppContext, AsyncAppCommand, Init, OfflineOperations};
 use crate::utils::hwi::{HwiWallet, enumerate_hwi_devices, first_hwi_device};
 
 use bdk_wallet::bitcoin::{
-    Psbt,
+    Network, Psbt,
     bip32::{ChildNumber, DerivationPath},
     hex::DisplayHex,
 };
@@ -44,12 +44,16 @@ pub enum HwiSubCommand {
     /// 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,
@@ -64,25 +68,37 @@ impl AsyncAppCommand<AppContext<Init>> for HwiCommand {
         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);
@@ -123,6 +139,9 @@ pub struct WalletHwiCommand {
     /// 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,
 }
@@ -133,7 +152,10 @@ pub struct WalletHwiCommand {
 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)]
@@ -163,6 +185,8 @@ impl WalletHwiCommand {
     ) -> 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
@@ -195,12 +219,17 @@ impl WalletHwiCommand {
         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,
@@ -236,8 +265,8 @@ impl WalletHwiCommand {
                     }),
                 };
 
-                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,
@@ -261,13 +290,14 @@ impl WalletHwiCommand {
                     "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() }))
             }
@@ -349,10 +379,11 @@ fn infer_script_type(path: &str) -> Option<&'static str> {
 /// 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}")))?;
@@ -367,44 +398,104 @@ fn build_descriptor(script_type: &str, key_expr: &str, branch: u32) -> Result<St
 /// - 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());
     }
 }
index 94b1568b7c597515ab7bea77706cd5b21ce60a5a..296eabd61421c564bbc3e85f5617c6d7cd158318 100644 (file)
@@ -43,10 +43,7 @@ pub async fn enumerate_hwi_devices(
 ) -> 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:?}"),
@@ -74,130 +71,208 @@ pub async fn enumerate_hwi_devices(
         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))
 }