]> Untitled Git - bdk-cli/commitdiff
feat(hwi): Reuse saved wallet config for hwi
authorVihiga Tyonum <withtvpeter@gmail.com>
Tue, 4 Aug 2026 10:08:07 +0000 (11:08 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Mon, 19 Oct 2026 09:25:01 +0000 (10:25 +0100)
src/handlers/hwi.rs
src/handlers/offline.rs

index 5b4d0caac9ee553599006173caa085fda44fc9e1..ea6b2564ee6b4a892b7edc412cde675414b076ba 100644 (file)
@@ -4,16 +4,20 @@
 //! devices, register a wallet policy, display a receive address on the device
 //! and sign a PSBT. Device discovery lives in [`crate::utils::hwi`].
 
+use crate::commands::WalletOpts;
+use crate::error::BDKCliError as Error;
+use crate::handlers::{AppContext, AsyncAppCommand, Init};
+use crate::persister::new_wallet;
+use crate::utils::hwi::{HwiWallet, enumerate_hwi_devices, first_hwi_device};
+use crate::utils::load_wallet_config;
 use async_hwi::AddressScript;
-use bdk_wallet::bitcoin::{Psbt, hex::DisplayHex};
+use bdk_wallet::bitcoin::{Psbt, bip32::DerivationPath, hex::DisplayHex};
+use bdk_wallet::{KeychainKind, Wallet};
 use clap::{Args, Parser, Subcommand};
 use serde_json::json;
+use std::path::Path;
 use std::str::FromStr;
 
-use crate::error::BDKCliError as Error;
-use crate::handlers::{AppContext, AsyncAppCommand, Init};
-use crate::utils::hwi::{HwiWallet, enumerate_hwi_devices, first_hwi_device};
-
 /// Options shared by all HWI subcommands.
 #[derive(Clone, Debug, PartialEq, Eq, Args)]
 pub struct HwiOpts {
@@ -31,20 +35,19 @@ pub struct HwiOpts {
     pub hmac: Option<String>,
 }
 
-impl HwiOpts {
-    fn as_wallet(&self) -> HwiWallet<'_> {
-        HwiWallet {
-            name: self.wallet.as_deref(),
-            policy: self.ext_descriptor.as_deref(),
-            hmac: self.hmac.as_deref(),
-        }
-    }
-}
-
 /// HWI subcommands.
 #[derive(Debug, Subcommand, Clone, PartialEq, Eq)]
 #[command(rename_all = "snake")]
 pub enum HwiSubCommand {
+    /// Read an extended public key from the device at a derivation path.
+    ///
+    /// Use this first to obtain the key material 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,
+    },
     /// List all connected hardware wallet devices.
     Devices,
     /// Register a wallet policy on the device, returning its HMAC.
@@ -77,15 +80,88 @@ pub struct HwiCommand {
     pub subcommand: HwiSubCommand,
 }
 
+impl HwiCommand {
+    /// Load the saved wallet config when a descriptor is needed. Errors if `--wallet`
+    ///  is set but its config cannot be read.
+    fn load_wallet_opts(&self, datadir: &Path) -> Result<Option<WalletOpts>, Error> {
+        if self.opts.ext_descriptor.is_some() {
+            return Ok(None);
+        }
+        match &self.opts.wallet {
+            Some(name) => load_wallet_config(datadir, name).map(|(opts, _network)| Some(opts)),
+            None => Ok(None),
+        }
+    }
+
+    /// Build the device wallet context, preferring an explicit `--ext-descriptor`
+    /// and falling back to the loaded config's external descriptor.
+    fn hwi_wallet<'a>(&'a self, opts: Option<&'a WalletOpts>) -> HwiWallet<'a> {
+        let policy = self
+            .opts
+            .ext_descriptor
+            .as_deref()
+            .or_else(|| opts.map(|o| o.ext_descriptor.as_str()));
+        HwiWallet {
+            name: self.opts.wallet.as_deref(),
+            policy,
+            hmac: self.opts.hmac.as_deref(),
+        }
+    }
+
+    /// Derive the address at `index` for the given keychain, from the saved
+    /// config when available or from an explicit external descriptor otherwise.
+    fn derive_address(
+        &self,
+        network: bdk_wallet::bitcoin::Network,
+        opts: Option<&WalletOpts>,
+        index: u32,
+        change: bool,
+    ) -> Result<Option<String>, Error> {
+        let keychain = if change {
+            KeychainKind::Internal
+        } else {
+            KeychainKind::External
+        };
+
+        let wallet = if let Some(opts) = opts {
+            Some(new_wallet(network, opts)?)
+        } else if let Some(desc) = self.opts.ext_descriptor.as_deref() {
+            Some(
+                Wallet::create_single(desc.to_string())
+                    .network(network)
+                    .create_wallet_no_persist()?,
+            )
+        } else {
+            None
+        };
+
+        Ok(wallet.map(|w| w.peek_address(keychain, index).address.to_string()))
+    }
+}
+
 impl AsyncAppCommand<AppContext<Init>> for HwiCommand {
     type Output = serde_json::Value;
 
     async fn execute(&self, ctx: &mut AppContext<Init>) -> Result<Self::Output, Error> {
         let network = ctx.network;
-        let wallet = self.opts.as_wallet();
 
         match &self.subcommand {
+            HwiSubCommand::Xpub { path } => {
+                let derivation = DerivationPath::from_str(path)
+                    .map_err(|e| Error::Generic(format!("Invalid derivation path: {e}")))?;
+                let wallet = self.hwi_wallet(None);
+                let device = first_hwi_device(network, &wallet).await?;
+                let xpub = device.get_extended_pubkey(&derivation).await?;
+                let fingerprint = device.get_master_fingerprint().await?.to_string();
+                Ok(json!({
+                    "path": path,
+                    "xpub": xpub.to_string(),
+                    "master_fingerprint": fingerprint,
+                }))
+            }
+
             HwiSubCommand::Devices => {
+                let wallet = self.hwi_wallet(None);
                 let devices = enumerate_hwi_devices(network, &wallet).await?;
                 let mut listed = Vec::with_capacity(devices.len());
                 for device in &devices {
@@ -99,40 +175,49 @@ impl AsyncAppCommand<AppContext<Init>> for HwiCommand {
             }
 
             HwiSubCommand::Register => {
-                let name = self.opts.wallet.as_deref().ok_or_else(|| {
-                    Error::Generic("Wallet name (--wallet) is required to register".to_string())
+                let opts = self.load_wallet_opts(&ctx.datadir)?;
+                let wallet = self.hwi_wallet(opts.as_ref());
+                let name = wallet.name.ok_or_else(|| {
+                    Error::Generic("Wallet name is required to register".to_string())
                 })?;
-                let policy = self.opts.ext_descriptor.as_deref().ok_or_else(|| {
+                let policy = wallet.policy.ok_or_else(|| {
                     Error::Generic(
-                        "External descriptor (--ext-descriptor) is required to register"
+                        "No descriptor found: pass --ext-descriptor or save a wallet config for --wallet"
                             .to_string(),
                     )
                 })?;
-                let device = first_hwi_device(network, &wallet).await?;
                 tracing::debug!("Registering wallet '{name}' with policy '{policy}'");
-                let hmac = device.register_wallet(name, policy).await?;
-                let hmac_hex = hmac.map(|h| h.to_lower_hex_string());
-                Ok(json!({ "success": true, "hmac": hmac_hex }))
+                // let device = first_hwi_device(network, &wallet).await?;
+                let hmac = device_register(network, &wallet, name, policy).await?;
+
+                // let hmac_hex = hmac.map(|h| h.to_lower_hex_string());
+                Ok(json!({ "success": true, "hmac": hmac }))
             }
 
             HwiSubCommand::Address { index, change } => {
+                let opts = self.load_wallet_opts(&ctx.datadir)?;
+                let wallet = self.hwi_wallet(opts.as_ref());
                 let device = first_hwi_device(network, &wallet).await?;
                 let script = AddressScript::Miniscript {
                     index: *index,
                     change: *change,
                 };
                 device.display_address(&script).await?;
+                let address = self.derive_address(network, opts.as_ref(), *index, *change)?;
                 Ok(json!({
                     "success": true,
                     "index": index,
                     "change": change,
-                    "message": "Address displayed on device for verification",
+                    "address": address,
+                    "message": "Verify this address matches the one shown on your device",
                 }))
             }
 
             HwiSubCommand::Sign { psbt } => {
                 let mut psbt = Psbt::from_str(psbt)
                     .map_err(|e| Error::Generic(format!("Failed to parse PSBT: {e}")))?;
+                let opts = self.load_wallet_opts(&ctx.datadir)?;
+                let wallet = self.hwi_wallet(opts.as_ref());
                 let device = first_hwi_device(network, &wallet).await?;
                 device.sign_tx(&mut psbt).await?;
                 Ok(json!({ "psbt": psbt.to_string() }))
@@ -140,3 +225,15 @@ impl AsyncAppCommand<AppContext<Init>> for HwiCommand {
         }
     }
 }
+
+/// Register a policy on the first connected device and return its HMAC (hex).
+async fn device_register(
+    network: bdk_wallet::bitcoin::Network,
+    wallet: &HwiWallet<'_>,
+    name: &str,
+    policy: &str,
+) -> Result<Option<String>, Error> {
+    let device = first_hwi_device(network, wallet).await?;
+    let hmac = device.register_wallet(name, policy).await?;
+    Ok(hmac.map(|h| h.to_lower_hex_string()))
+}
index 971d68e7e92dabe1b97c1375945643bf791411ef..6aebdddf1f62c8ef129a2976d756b630aa3a7a40 100644 (file)
@@ -212,7 +212,7 @@ pub struct CreateTxCommand {
     pub enable_rbf: bool,
 
     /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `PSBT_GLOBAL_XPUB` and more details to let the signer identify the change output.
-    #[arg(long = "offline_signer")]
+    #[arg(long = "offline_signer", default_value_t = DEFAULT_OFFLINE_SIGNER)]
     pub offline_signer: bool,
 
     /// Selects which utxos *must* be spent.
@@ -550,7 +550,7 @@ pub struct BumpFeeCommand {
     pub shrink_address: Option<Address>,
 
     /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output.
-    #[arg(long = "offline_signer", default_value_t = DEFAULT_OFFLINE_SIGNER)]
+    #[arg(long = "offline_signer")]
     pub offline_signer: bool,
 
     /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used.