use crate::utils::hwi::{HwiWallet, enumerate_hwi_devices, first_hwi_device};
use bdk_wallet::bitcoin::{
- Network, Psbt,
+ Network, NetworkKind, Psbt,
bip32::{ChildNumber, DerivationPath},
hex::DisplayHex,
};
.map_err(|e| Error::Generic(format!("Invalid derivation path: {e}")))?;
let device = first_hwi_device(network, &hwi_wallet, target_fp).await?;
let xpub = device.get_extended_pubkey(&derivation).await?;
+
+ if xpub.network != network.into() {
+ let device_network = if xpub.network == NetworkKind::Main {
+ "mainnet"
+ } else {
+ "testnet"
+ };
+
+ return Err(Error::Generic(format!(
+ "Device returned a {device_network} but wallet network is {network}.\
+ Set the device to the matching network."
+ )));
+ }
+
let master_fingerprint = device.get_master_fingerprint().await?.to_string();
let origin = path.trim_start_matches('m').trim_start_matches('/');
let key_expression = format!("[{master_fingerprint}/{origin}]{xpub}");
let ext = ext_desc.to_string();
let int = int_desc.to_string();
let int = if int == ext { None } else { Some(int) };
- let policy = build_device_policy(&ext, int.as_deref(), network)?;
+ let policy = matches!(kind, WalletKind::Policy)
+ .then(|| build_device_policy(&ext, int.as_deref(), network))
+ .transpose()?;
let hwi_wallet = HwiWallet {
name: Some(wallet_name),
- policy: matches!(kind, WalletKind::Policy).then_some(policy.as_str()),
+ policy: policy.as_deref(),
hmac: self.hmac.as_deref(),
};
match &self.subcommand {
WalletHwiSubCommand::Register => {
+ let policy = build_device_policy(&ext, int.as_deref(), network)?;
log::debug!("Registering wallet '{wallet_name}' with policy '{policy}'");
let device = first_hwi_device(network, &hwi_wallet, target_fp).await?;
match device.register_wallet(wallet_name, &policy).await {
}
/// How a wallet's descriptor maps onto a hardware device's capabilities.
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, PartialEq, Debug, Eq)]
enum WalletKind {
/// BIP-86 taproot single-sig — shown on device via the simple P2TR path.
TaprootSingleSig,
const EXT: &str = "wpkh([f5acc2fd/84'/1'/0']tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/0/*)";
const INT: &str = "wpkh([f5acc2fd/84'/1'/0']tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/1/*)";
+ const TPUB: &str = "tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP";
+
+ fn desc(s: &str) -> Descriptor<DescriptorPublicKey> {
+ Descriptor::<DescriptorPublicKey>::from_str(s).unwrap()
+ }
#[test]
fn cli_definition_is_valid() {
CliOpts::command().debug_assert();
}
+ #[test]
+ fn classifies_wallet_kinds() {
+ assert_eq!(WalletKind::classify(&desc(EXT)), WalletKind::SingleSig); // wpkh
+ assert_eq!(
+ WalletKind::classify(&desc(&format!("pkh([f5acc2fd/44'/1'/0']{TPUB}/0/*)"))),
+ WalletKind::SingleSig
+ );
+ assert_eq!(
+ WalletKind::classify(&desc(&format!("sh(wpkh([f5acc2fd/49'/1'/0']{TPUB}/0/*))"))),
+ WalletKind::SingleSig
+ );
+ assert_eq!(
+ WalletKind::classify(&desc(&format!("tr([f5acc2fd/86'/1'/0']{TPUB}/0/*)"))),
+ WalletKind::TaprootSingleSig
+ );
+ // wsh(...) is neither single-sig nor taproot single-sig.
+ assert_eq!(
+ WalletKind::classify(&desc(&format!(
+ "wsh(multi(1,[f5acc2fd/84'/1'/0']{TPUB}/0/*))"
+ ))),
+ WalletKind::Policy
+ );
+ }
+
+ #[test]
+ fn taproot_address_path_builds_full_path() {
+ let tr = desc(&format!("tr([f5acc2fd/86'/1'/0']{TPUB}/0/*)"));
+ assert_eq!(
+ taproot_address_path(&tr, 5).unwrap(),
+ DerivationPath::from_str("m/86'/1'/0'/0/5").unwrap()
+ );
+ // A non-taproot descriptor has no on-device taproot path.
+ assert!(taproot_address_path(&desc(EXT), 0).is_none());
+ }
+
#[test]
fn policy_combines_and_canonicalizes() {
let policy = build_device_policy(EXT, Some(INT), Network::Testnet).unwrap();
assert!(policy.contains("<0;1>"));
assert!(!policy.contains("/0/*"));
assert!(!policy.contains("/1/*"));
- // ...and re-serialized with a checksum.
assert!(policy.contains('#'));
}
}
match device.get_info().await {
Ok(info) => {
- if info.jade_state == jade::api::JadeState::Locked
- && let Err(e) = device.auth().await
- {
- warn!("Jade authentication failed: {e:?}");
- continue;
+ if info.jade_state == jade::api::JadeState::Locked {
+ eprintln!("Jade is locked; enter its PIN on the device to continue.");
+ if let Err(e) = device.auth().await {
+ warn!("Jade authentication failed: {e:?}");
+ continue;
+ }
}
devices.push(device.into());
}
/// Select a single connected device, by `target_fingerprint` when given.
///
/// 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.
+/// rank before simulators) is used and a notice lists the alternatives. The
+/// chosen device's fingerprint and model are always printed to stderr, so the
+/// user can confirm which device an operation runs against regardless of the
+/// log level.
pub async fn first_hwi_device(
network: Network,
wallet: &HwiWallet<'_>,
None => {
if labeled.len() > 1 {
let fingerprints: Vec<&str> = labeled.iter().map(|(fp, _)| fp.as_str()).collect();
- warn!(
+ eprintln!(
"{} hardware wallets connected ({}); using the first. Select one with --fingerprint.",
labeled.len(),
fingerprints.join(", ")
}
};
- warn!(
+ eprintln!(
"Using hardware wallet {fingerprint} ({})",
device.device_kind()
);
h.copy_from_slice(&bytes);
Ok(Some(h))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn hmac_none_is_none() {
+ assert_eq!(parse_registration_hmac(None).unwrap(), None);
+ }
+
+ #[test]
+ fn hmac_parses_32_bytes() {
+ let hex = "ab".repeat(32);
+ assert_eq!(
+ parse_registration_hmac(Some(&hex)).unwrap(),
+ Some([0xab; 32])
+ );
+ }
+
+ #[test]
+ fn hmac_rejects_wrong_length() {
+ // 31 bytes is one short.
+ assert!(parse_registration_hmac(Some(&"ab".repeat(31))).is_err());
+ }
+
+ #[test]
+ fn hmac_rejects_non_hex() {
+ assert!(parse_registration_hmac(Some("zz")).is_err());
+ }
+}