Skip to main content

bhwi_async/
display_address.rs

1use std::str::FromStr;
2
3use bhwi::{
4    bitcoin::{
5        address::AddressType,
6        bip32::{self, DerivationPath, Fingerprint, Xpub},
7        hex::{self, DisplayHex},
8        secp256k1::{PublicKey as SecpPublicKey, XOnlyPublicKey},
9    },
10    common::{DisplayAddress, MultisigAddressType, MultisigDisplayAddress},
11    miniscript::{DescriptorPublicKey, descriptor::DescriptorKeyParseError},
12};
13
14use bhwi::common::DeviceContext;
15use bhwi::miniscript::descriptor::WalletPolicy;
16
17use crate::{
18    HWIDeviceError,
19    device::{Device, DeviceType, supports_multisig_display_address},
20};
21
22/// Missing policy information for a backend's address-display context.
23#[derive(Debug, thiserror::Error)]
24pub enum DisplayAddressContextError {
25    /// The backend requires a policy descriptor.
26    #[error("{0} needs the wallet policy descriptor of the registered wallet")]
27    MissingPolicy(
28        /// The device family requiring the policy.
29        DeviceType,
30    ),
31
32    /// The backend requires both a policy descriptor and a registration HMAC.
33    #[error("{0} needs both the wallet policy descriptor and its registration hmac")]
34    IncompletePolicy(
35        /// The device family requiring both values.
36        DeviceType,
37    ),
38}
39
40/// Prepares backend-specific context for displaying a registered wallet address.
41///
42/// Enabled BitBox02 and Specter-DIY backends require a policy. Ledger accepts
43/// both policy and HMAC or neither; supplying only one is an error. Other
44/// families return no context. This function does not display an address.
45pub fn display_address_context(
46    device_type: DeviceType,
47    name: &str,
48    policy: Option<WalletPolicy>,
49    hmac: Option<[u8; 32]>,
50) -> Result<Option<DeviceContext>, DisplayAddressContextError> {
51    let _ = (name, &policy, hmac);
52    match device_type {
53        #[cfg(feature = "bitbox")]
54        DeviceType::BitBox02 => Ok(Some(DeviceContext::BitBox {
55            policy: policy.ok_or(DisplayAddressContextError::MissingPolicy(device_type))?,
56        })),
57        #[cfg(feature = "ledger")]
58        DeviceType::Ledger => match (policy, hmac) {
59            (None, None) => Ok(None),
60            (Some(policy), Some(hmac)) => Ok(Some(crate::signing::ledger::registered_context(
61                name.to_owned(),
62                policy,
63                Some(hmac),
64            ))),
65            _ => Err(DisplayAddressContextError::IncompletePolicy(device_type)),
66        },
67        #[cfg(feature = "specter")]
68        DeviceType::Specter => Ok(Some(DeviceContext::Specter {
69            policy: policy.ok_or(DisplayAddressContextError::MissingPolicy(device_type))?,
70        })),
71        _ => Ok(None),
72    }
73}
74
75/// A descriptor could not be converted into an address-display request.
76#[derive(Debug, thiserror::Error)]
77pub enum DisplayAddressError {
78    /// The descriptor's script or key expression is unsupported.
79    #[error("Unsupported displayaddress descriptor: {0}")]
80    UnsupportedDescriptor(
81        /// The unsupported descriptor text.
82        String,
83    ),
84
85    /// A single-key descriptor lacks key-origin information.
86    #[error("Descriptor missing origin info: {0}")]
87    MissingOrigin(
88        /// The descriptor lacking an origin.
89        String,
90    ),
91
92    /// The descriptor's origin fingerprint differs from the selected wallet.
93    #[error("Descriptor fingerprint does not match device: {0}")]
94    FingerprintMismatch(
95        /// The mismatching descriptor.
96        String,
97    ),
98
99    /// The descriptor key differs from the wallet key at its origin path.
100    #[error("Key in descriptor does not match device: {0}")]
101    KeyMismatch(
102        /// The mismatching descriptor.
103        String,
104    ),
105
106    /// A key string contains a character outside the Base58 alphabet.
107    #[error("Character '{0}' is not a valid base58 character")]
108    InvalidBase58Character(
109        /// The invalid character.
110        char,
111    ),
112
113    /// The multisig threshold or key list is invalid.
114    #[error(
115        "Either the redeem script provided is invalid or the keypaths provided are insufficient"
116    )]
117    InvalidMultisig,
118
119    /// A derivation path or extended public key could not be parsed.
120    #[error(transparent)]
121    Bip32(
122        /// The BIP32 parsing error.
123        #[from]
124        bip32::Error,
125    ),
126
127    /// A key-origin fingerprint could not be parsed.
128    #[error(transparent)]
129    Fingerprint(
130        /// The hexadecimal parsing error.
131        #[from]
132        hex::HexToArrayError,
133    ),
134
135    /// A multisig descriptor key could not be parsed.
136    #[error(transparent)]
137    Key(
138        /// The descriptor-key parsing error.
139        #[from]
140        DescriptorKeyParseError,
141    ),
142
143    /// A multisig threshold could not be parsed as an integer.
144    #[error(transparent)]
145    Threshold(
146        /// The integer parsing error.
147        #[from]
148        std::num::ParseIntError,
149    ),
150
151    /// A wallet query failed during request preparation.
152    #[error(transparent)]
153    Device(
154        /// The wallet-operation error.
155        #[from]
156        HWIDeviceError,
157    ),
158}
159
160/// Prepares an address-display request, trying singlesig before supported multisig.
161///
162/// This may query the wallet's fingerprint and keys, but does not display the
163/// address. When neither conversion succeeds, the singlesig error is reported.
164/// A trailing checksum is stripped without being validated.
165pub async fn display_address_from_descriptor(
166    device: &mut Device,
167    descriptor: &str,
168) -> Result<DisplayAddress, DisplayAddressError> {
169    let device_type = device.device_type();
170    let singlesig_error = match singlesig_display_address_from_descriptor(device, descriptor).await
171    {
172        Ok(address) => return Ok(address),
173        Err(error) => error,
174    };
175    if !supports_multisig_display_address(device_type) {
176        return Err(singlesig_error);
177    }
178    match multisig_display_address_from_descriptor(descriptor) {
179        Ok(address) => Ok(DisplayAddress::ByMultisig(address)),
180        Err(_) => Err(singlesig_error),
181    }
182}
183
184/// Prepares a concrete single-key address-display request after checking wallet ownership.
185///
186/// Queries the fingerprint and origin key. Supports P2PKH, P2WPKH,
187/// P2SH-P2WPKH, and key-path Taproot; wildcard suffixes are not accepted.
188/// A trailing checksum is stripped without being validated.
189pub async fn singlesig_display_address_from_descriptor(
190    device: &mut Device,
191    descriptor: &str,
192) -> Result<DisplayAddress, DisplayAddressError> {
193    let descriptor = strip_descriptor_checksum(descriptor);
194    let parsed = parse_singlesig_display_descriptor(descriptor)?;
195    let fingerprint = device.fingerprint().await?;
196    if parsed.fingerprint != fingerprint {
197        return Err(DisplayAddressError::FingerprintMismatch(
198            descriptor.to_owned(),
199        ));
200    }
201
202    let xpub = device
203        .device()
204        .get_extended_pubkey(parsed.origin_path.clone(), false)
205        .await?;
206
207    if !descriptor_key_matches_xpub(&parsed.key, xpub) {
208        return Err(DisplayAddressError::KeyMismatch(descriptor.to_owned()));
209    }
210
211    Ok(DisplayAddress::ByPath {
212        path: parsed.full_path,
213        display: true,
214        address_format: Some(parsed.addr_type),
215    })
216}
217
218#[derive(Debug)]
219struct ParsedSingleSigDisplayDescriptor {
220    addr_type: AddressType,
221    fingerprint: Fingerprint,
222    origin_path: DerivationPath,
223    full_path: DerivationPath,
224    key: String,
225}
226
227fn parse_singlesig_display_descriptor(
228    descriptor: &str,
229) -> Result<ParsedSingleSigDisplayDescriptor, DisplayAddressError> {
230    let (addr_type, key_expr) = if let Some(inner) = descriptor
231        .strip_prefix("sh(wpkh(")
232        .and_then(|value| value.strip_suffix("))"))
233    {
234        (AddressType::P2sh, inner)
235    } else if let Some(inner) = descriptor
236        .strip_prefix("wpkh(")
237        .and_then(|value| value.strip_suffix(')'))
238    {
239        (AddressType::P2wpkh, inner)
240    } else if let Some(inner) = descriptor
241        .strip_prefix("pkh(")
242        .and_then(|value| value.strip_suffix(')'))
243    {
244        (AddressType::P2pkh, inner)
245    } else if let Some(inner) = descriptor
246        .strip_prefix("tr(")
247        .and_then(|value| value.strip_suffix(')'))
248    {
249        (AddressType::P2tr, inner)
250    } else {
251        return Err(DisplayAddressError::UnsupportedDescriptor(
252            descriptor.to_owned(),
253        ));
254    };
255
256    let Some(rest) = key_expr.strip_prefix('[') else {
257        return Err(DisplayAddressError::MissingOrigin(descriptor.to_owned()));
258    };
259    let Some((origin, key_and_suffix)) = rest.split_once(']') else {
260        return Err(DisplayAddressError::MissingOrigin(descriptor.to_owned()));
261    };
262    let (fingerprint, origin_path) = parse_key_origin(origin)?;
263    let (key, suffix_path) = split_key_suffix(key_and_suffix)?;
264    validate_singlesig_display_key(key)?;
265    let full_path = join_derivation_path(&origin_path, &suffix_path);
266
267    Ok(ParsedSingleSigDisplayDescriptor {
268        addr_type,
269        fingerprint,
270        origin_path,
271        full_path,
272        key: key.to_owned(),
273    })
274}
275
276fn validate_singlesig_display_key(key: &str) -> Result<(), DisplayAddressError> {
277    if SecpPublicKey::from_str(key).is_ok() || XOnlyPublicKey::from_str(key).is_ok() {
278        return Ok(());
279    }
280
281    Xpub::from_str(key).map(|_| ()).map_err(|err| {
282        invalid_base58_character(key)
283            .map_or_else(|| err.into(), DisplayAddressError::InvalidBase58Character)
284    })
285}
286
287fn invalid_base58_character(value: &str) -> Option<char> {
288    const BASE58_ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
289
290    value.chars().find(|ch| !BASE58_ALPHABET.contains(*ch))
291}
292
293/// Parses a multisig descriptor into a request without contacting a device.
294///
295/// Supports `multi` and `sortedmulti` inside P2SH, P2WSH, or P2SH-P2WSH.
296/// Checks the threshold against the key count, but does not check wallet
297/// ownership. A trailing checksum is stripped without being validated.
298pub fn multisig_display_address_from_descriptor(
299    descriptor: &str,
300) -> Result<MultisigDisplayAddress, DisplayAddressError> {
301    let descriptor = strip_descriptor_checksum(descriptor);
302    let (address_type, sorted, inner) = parse_multisig_descriptor_envelope(descriptor)
303        .ok_or_else(|| DisplayAddressError::UnsupportedDescriptor(descriptor.to_owned()))?;
304
305    let mut parts = inner.split(',');
306    // `split` always yields at least one item, so an empty threshold reaches the parser.
307    let threshold = parts.next().unwrap_or_default().parse::<u8>()?;
308    let keys = parts
309        .map(|key| DescriptorPublicKey::from_str(key).map_err(DisplayAddressError::from))
310        .collect::<Result<Vec<_>, _>>()?;
311    if keys.is_empty() || threshold == 0 || usize::from(threshold) > keys.len() {
312        return Err(DisplayAddressError::InvalidMultisig);
313    }
314    Ok(MultisigDisplayAddress {
315        threshold,
316        address_type,
317        sorted,
318        keys,
319    })
320}
321
322fn parse_multisig_descriptor_envelope(
323    descriptor: &str,
324) -> Option<(MultisigAddressType, bool, &str)> {
325    let wrappers = [
326        ("sh(wsh(", "))", MultisigAddressType::ShWit),
327        ("wsh(", ")", MultisigAddressType::Wit),
328        ("sh(", ")", MultisigAddressType::Legacy),
329    ];
330    for (prefix, suffix, address_type) in wrappers {
331        let Some(inner) = descriptor
332            .strip_prefix(prefix)
333            .and_then(|value| value.strip_suffix(suffix))
334        else {
335            continue;
336        };
337        if let Some(inner) = inner
338            .strip_prefix("sortedmulti(")
339            .and_then(|value| value.strip_suffix(')'))
340        {
341            return Some((address_type, true, inner));
342        }
343        if let Some(inner) = inner
344            .strip_prefix("multi(")
345            .and_then(|value| value.strip_suffix(')'))
346        {
347            return Some((address_type, false, inner));
348        }
349    }
350    None
351}
352
353fn strip_descriptor_checksum(descriptor: &str) -> &str {
354    descriptor
355        .split_once('#')
356        .map_or(descriptor, |(desc, _)| desc)
357}
358
359fn parse_key_origin(origin: &str) -> Result<(Fingerprint, DerivationPath), DisplayAddressError> {
360    let (fingerprint, path) = origin
361        .split_once('/')
362        .map_or((origin, ""), |(fp, path)| (fp, path));
363    let fingerprint = Fingerprint::from_str(fingerprint)?;
364    let path = if path.is_empty() {
365        DerivationPath::master()
366    } else {
367        DerivationPath::from_str(&format!("m/{path}"))?
368    };
369    Ok((fingerprint, path))
370}
371
372fn split_key_suffix(key_and_suffix: &str) -> Result<(&str, DerivationPath), DisplayAddressError> {
373    let Some((key, suffix)) = key_and_suffix.split_once('/') else {
374        return Ok((key_and_suffix, DerivationPath::master()));
375    };
376    let suffix = DerivationPath::from_str(&format!("m/{suffix}"))?;
377    Ok((key, suffix))
378}
379
380fn join_derivation_path(base: &DerivationPath, suffix: &DerivationPath) -> DerivationPath {
381    let mut children = base.as_ref().to_vec();
382    children.extend_from_slice(suffix.as_ref());
383    DerivationPath::from(children)
384}
385
386fn descriptor_key_matches_xpub(key: &str, xpub: Xpub) -> bool {
387    key == xpub.to_string()
388        || key.eq_ignore_ascii_case(&xpub.public_key.serialize().to_lower_hex_string())
389        || key.eq_ignore_ascii_case(
390            &xpub
391                .public_key
392                .x_only_public_key()
393                .0
394                .serialize()
395                .to_lower_hex_string(),
396        )
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[cfg(feature = "specter")]
404    #[test]
405    fn specter_display_carries_the_policy_it_is_given() {
406        let policy = WalletPolicy::from_str(
407            "wpkh([f5acc2fd/84'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT/<0;1>/*)",
408        )
409        .expect("valid wallet policy");
410        let context = display_address_context(DeviceType::Specter, "wallet", Some(policy), None)
411            .expect("a Specter context");
412        assert!(matches!(context, Some(DeviceContext::Specter { .. })));
413        assert!(matches!(
414            display_address_context(DeviceType::Specter, "wallet", None, None),
415            Err(DisplayAddressContextError::MissingPolicy(
416                DeviceType::Specter
417            ))
418        ));
419    }
420
421    #[test]
422    fn parses_singlesig_display_descriptor() {
423        let descriptor = "sh(wpkh([f5acc2fd/49h/1h/0h]tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT/0/7))#checksum";
424
425        let parsed = parse_singlesig_display_descriptor(strip_descriptor_checksum(descriptor))
426            .expect("display descriptor");
427
428        assert_eq!(parsed.addr_type, AddressType::P2sh);
429        assert_eq!(
430            parsed.fingerprint,
431            Fingerprint::from([0xf5, 0xac, 0xc2, 0xfd])
432        );
433        assert_eq!(
434            parsed.origin_path,
435            DerivationPath::from_str("m/49h/1h/0h").unwrap()
436        );
437        assert_eq!(
438            parsed.full_path,
439            DerivationPath::from_str("m/49h/1h/0h/0/7").unwrap()
440        );
441        assert_eq!(
442            parsed.key,
443            "tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
444        );
445    }
446
447    #[test]
448    fn display_descriptor_requires_origin() {
449        let err = parse_singlesig_display_descriptor("wpkh(tpubDD8d3xExampleKeyMaterial/0/7)")
450            .expect_err("missing origin");
451
452        assert!(err.to_string().contains("Descriptor missing origin info"));
453    }
454
455    #[test]
456    fn display_descriptor_rejects_invalid_base58_key_like_python_hwi() {
457        let err = parse_singlesig_display_descriptor("wpkh([0f056943/84h/1h/0h]not_an_xpub/0/0)")
458            .expect_err("invalid key");
459
460        assert_eq!(
461            err.to_string(),
462            "Character '_' is not a valid base58 character"
463        );
464    }
465
466    #[test]
467    fn parses_coldcard_sortedmulti_display_descriptor() {
468        let descriptor = "sh(wsh(sortedmulti(2,[f5acc2fd/48h/1h/0h/0h/0]0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,[aaaaaaaa/48h/1h/1h/0h/0]03f028892bad7ed57d2fb57bf33081d5cfcf6f9ed3d3d7f159c2e2fff579dc341a)))";
469
470        let parsed = multisig_display_address_from_descriptor(descriptor)
471            .expect("multisig display descriptor");
472
473        assert_eq!(parsed.threshold, 2);
474        assert!(matches!(parsed.address_type, MultisigAddressType::ShWit));
475        assert!(parsed.sorted);
476        assert_eq!(parsed.keys.len(), 2);
477        let origins = parsed
478            .keys
479            .iter()
480            .map(|key| match key {
481                DescriptorPublicKey::Single(key) => key.origin.clone().unwrap().1,
482                _ => panic!("expected concrete public key"),
483            })
484            .collect::<Vec<_>>();
485        assert_eq!(
486            origins,
487            vec![
488                DerivationPath::from_str("m/48h/1h/0h/0h/0").unwrap(),
489                DerivationPath::from_str("m/48h/1h/1h/0h/0").unwrap(),
490            ]
491        );
492    }
493}