Skip to main content

bhwi_async/
descriptors.rs

1use bhwi::{
2    bitcoin::{
3        Network,
4        bip32::{self, ChildNumber, DerivationPath, Fingerprint},
5    },
6    miniscript::{
7        self, Descriptor, DescriptorPublicKey,
8        descriptor::{DescriptorType, DescriptorXKey, Wildcard, Wpkh},
9    },
10};
11
12use crate::{HWIDevice, HWIDeviceError};
13
14/// A device-query, derivation, or descriptor-construction failure.
15#[derive(Debug, thiserror::Error)]
16pub enum DescriptorError {
17    /// A wallet query failed.
18    #[error("{0}")]
19    Device(
20        /// The wallet-operation error.
21        #[from]
22        HWIDeviceError,
23    ),
24
25    /// A BIP32 derivation index is invalid.
26    #[error("{0}")]
27    Bip32(
28        /// The derivation error.
29        #[from]
30        bip32::Error,
31    ),
32
33    /// A descriptor could not be constructed.
34    #[error("{0}")]
35    Miniscript(
36        /// The descriptor-construction error.
37        #[from]
38        miniscript::Error,
39    ),
40
41    /// The requested descriptor type is unsupported.
42    #[error("Unsupported descriptor type {0:?}")]
43    UnsupportedDescriptorType(
44        /// The unsupported script type.
45        DescriptorType,
46    ),
47
48    /// A bare public-key descriptor has no supported account purpose.
49    #[error("Bare PK descriptors aren't supported")]
50    BarePk,
51
52    /// The keypool's first index exceeds its last index.
53    #[error("keypool start index must be less than or equal to end index")]
54    KeypoolRange,
55
56    /// A hardened child follows an unhardened child in a keypool path.
57    #[error("keypool path cannot contain hardened children after an unhardened child")]
58    HardenedAfterUnhardened,
59}
60
61/// Parameters for deriving a single-key wildcard descriptor.
62#[derive(Debug, Clone)]
63pub struct GetDescriptorOptions {
64    /// The device's master fingerprint to include in the key origin.
65    pub master_fingerprint: Fingerprint,
66    /// The path or account used to derive the descriptor key.
67    pub target: DescriptorTarget,
68    /// Whether account-based derivation selects the change branch.
69    pub is_change: bool,
70    /// The requested script type.
71    pub descriptor_type: DescriptorType,
72    /// The network used for account derivation: Bitcoin uses coin type 0, others 1.
73    pub network: Network,
74}
75
76/// The path or account used to derive a descriptor key.
77#[derive(Debug, Clone)]
78pub enum DescriptorTarget {
79    /// An explicit derivation path preceding the wildcard.
80    Path(
81        /// The requested derivation path.
82        DerivationPath,
83    ),
84    /// An account index for script-specific BIP44-style derivation.
85    Account(
86        /// The unhardened account index, hardened during derivation.
87        u32,
88    ),
89}
90
91/// Parameters for a wildcard descriptor and an inclusive keypool range.
92#[derive(Debug, Clone)]
93pub struct GetKeypoolOptions {
94    /// The account or parent path to which the receive/change branch is appended.
95    pub path: DerivationPath,
96    /// The first child index in the inclusive range.
97    pub start: u32,
98    /// The last child index in the inclusive range.
99    pub end: u32,
100    /// Whether to append the change branch (1) rather than the receive branch (0).
101    pub internal: bool,
102    /// The requested script type.
103    pub descriptor_type: DescriptorType,
104    /// The network used for descriptor construction.
105    pub network: Network,
106}
107
108impl GetDescriptorOptions {
109    /// Creates options using an explicit path preceding the descriptor wildcard.
110    pub fn with_path(
111        master_fingerprint: Fingerprint,
112        path: DerivationPath,
113        is_change: bool,
114        descriptor_type: DescriptorType,
115        network: Network,
116    ) -> Self {
117        Self {
118            master_fingerprint,
119            target: DescriptorTarget::Path(path),
120            is_change,
121            descriptor_type,
122            network,
123        }
124    }
125
126    /// Creates options using a script-specific account and receive/change branch.
127    pub fn with_account(
128        master_fingerprint: Fingerprint,
129        account: u32,
130        is_change: bool,
131        descriptor_type: DescriptorType,
132        network: Network,
133    ) -> Self {
134        Self {
135            master_fingerprint,
136            target: DescriptorTarget::Account(account),
137            is_change,
138            descriptor_type,
139            network,
140        }
141    }
142}
143
144impl GetKeypoolOptions {
145    fn descriptor_options(
146        &self,
147        master_fingerprint: Fingerprint,
148    ) -> Result<GetDescriptorOptions, DescriptorError> {
149        validate_keypool_range(self.start, self.end)?;
150        let path = keypool_descriptor_path(&self.path, self.internal)?;
151        Ok(GetDescriptorOptions::with_path(
152            master_fingerprint,
153            path,
154            self.internal,
155            self.descriptor_type,
156            self.network,
157        ))
158    }
159}
160
161/// Queries a device key and constructs a single-key wildcard descriptor.
162///
163/// Supports P2PKH, P2WPKH, P2SH-P2WPKH, and key-path Taproot. Account targets
164/// choose the corresponding purpose and use coin type 0 for Bitcoin, 1 otherwise.
165/// This does not check whether the device can sign the requested script type.
166// reference: https://github.com/bitcoin-core/HWI/blob/master/hwilib/commands.py#L274
167pub async fn get_descriptor(
168    device: &mut dyn HWIDevice,
169    options: GetDescriptorOptions,
170) -> Result<Descriptor<DescriptorPublicKey>, DescriptorError> {
171    let GetDescriptorOptions {
172        master_fingerprint,
173        target,
174        is_change,
175        descriptor_type,
176        network,
177    } = options;
178    let path = match target {
179        DescriptorTarget::Path(path) => path,
180        DescriptorTarget::Account(account) => {
181            let purpose = ChildNumber::from_hardened_idx(bip44_purpose(descriptor_type)?)?;
182            let chain = ChildNumber::from_hardened_idx(bip44_chain(network))?;
183            let account = ChildNumber::from_hardened_idx(account)?;
184            let change = ChildNumber::from_normal_idx(is_change.into())?;
185
186            [purpose, chain, account, change].as_ref().into()
187        }
188    };
189
190    let split = path
191        .into_iter()
192        .rposition(ChildNumber::is_hardened)
193        .map(|i| i + 1)
194        .unwrap_or(0);
195    let (origin, suffix) = (&path[..split], &path[split..]);
196
197    let xpub = device.get_extended_pubkey(origin.into(), false).await?;
198    let pk = DescriptorPublicKey::XPub(DescriptorXKey {
199        origin: Some((master_fingerprint, origin.into())),
200        xkey: xpub,
201        derivation_path: suffix.into(),
202        wildcard: Wildcard::Unhardened,
203    });
204    Ok(match descriptor_type {
205        DescriptorType::Pkh => Descriptor::new_pkh(pk)?,
206        DescriptorType::Wpkh => Descriptor::new_wpkh(pk)?,
207        DescriptorType::ShWpkh => {
208            Descriptor::new_sh_with_wpkh(Wpkh::new(pk).map_err(miniscript::Error::from)?)
209        }
210        // TODO: check if device supports Taproot
211        DescriptorType::Tr => Descriptor::new_tr(pk, None)?,
212        _ => return Err(DescriptorError::UnsupportedDescriptorType(descriptor_type)),
213    })
214}
215
216/// The single-key script types included in the standard receive/change descriptor set.
217pub const PUBKEY_DESCRIPTOR_TYPES: [DescriptorType; 4] = [
218    DescriptorType::Pkh,
219    DescriptorType::Wpkh,
220    DescriptorType::ShWpkh,
221    DescriptorType::Tr,
222];
223
224/// Receive and internal descriptors for every type in [`PUBKEY_DESCRIPTOR_TYPES`].
225#[derive(Debug, Clone)]
226pub struct PubkeyDescriptors {
227    /// Wildcard receive descriptors in [`PUBKEY_DESCRIPTOR_TYPES`] order.
228    pub receive: Vec<Descriptor<DescriptorPublicKey>>,
229    /// Wildcard change descriptors in [`PUBKEY_DESCRIPTOR_TYPES`] order.
230    pub internal: Vec<Descriptor<DescriptorPublicKey>>,
231}
232
233/// Derives the descriptor set a standard wallet needs for one account.
234pub async fn get_pubkey_descriptors(
235    device: &mut dyn HWIDevice,
236    master_fingerprint: Fingerprint,
237    account: u32,
238    network: Network,
239) -> Result<PubkeyDescriptors, DescriptorError> {
240    let mut descriptors = PubkeyDescriptors {
241        receive: Vec::with_capacity(PUBKEY_DESCRIPTOR_TYPES.len()),
242        internal: Vec::with_capacity(PUBKEY_DESCRIPTOR_TYPES.len()),
243    };
244    for descriptor_type in PUBKEY_DESCRIPTOR_TYPES {
245        for (is_change, out) in [
246            (false, &mut descriptors.receive),
247            (true, &mut descriptors.internal),
248        ] {
249            let options = GetDescriptorOptions::with_account(
250                master_fingerprint,
251                account,
252                is_change,
253                descriptor_type,
254                network,
255            );
256            out.push(get_descriptor(device, options).await?);
257        }
258    }
259    Ok(descriptors)
260}
261
262/// Queries a wildcard keypool descriptor from an account or parent path.
263///
264/// `start` and `end` are inclusive bounds validated for ordering only. They do
265/// not replace the returned descriptor's wildcard or constrain its derivation range.
266pub async fn get_keypool_descriptor(
267    device: &mut dyn HWIDevice,
268    master_fingerprint: Fingerprint,
269    options: &GetKeypoolOptions,
270) -> Result<Descriptor<DescriptorPublicKey>, DescriptorError> {
271    let descriptor_options = options.descriptor_options(master_fingerprint)?;
272    get_descriptor(device, descriptor_options).await
273}
274
275fn validate_keypool_range(start: u32, end: u32) -> Result<(), DescriptorError> {
276    if start > end {
277        return Err(DescriptorError::KeypoolRange);
278    }
279    Ok(())
280}
281
282fn keypool_descriptor_path(
283    path: &DerivationPath,
284    internal: bool,
285) -> Result<DerivationPath, DescriptorError> {
286    if has_hardened_child_after_unhardened(path) {
287        return Err(DescriptorError::HardenedAfterUnhardened);
288    }
289
290    let branch = ChildNumber::from_normal_idx(internal.into())?;
291    let mut children = path.as_ref().to_vec();
292    children.push(branch);
293    Ok(children.into())
294}
295
296fn has_hardened_child_after_unhardened(path: &DerivationPath) -> bool {
297    let mut seen_unhardened = false;
298    path.as_ref().iter().any(|child| {
299        seen_unhardened |= !child.is_hardened();
300        seen_unhardened && child.is_hardened()
301    })
302}
303
304fn bip44_purpose(desc_type: DescriptorType) -> Result<u32, DescriptorError> {
305    Ok(match desc_type {
306        DescriptorType::Sh | DescriptorType::Pkh | DescriptorType::ShSortedMulti => 44,
307        DescriptorType::Wpkh | DescriptorType::Wsh | DescriptorType::WshSortedMulti => 84,
308        DescriptorType::ShWsh | DescriptorType::ShWpkh | DescriptorType::ShWshSortedMulti => 49,
309        DescriptorType::Tr => 86,
310        DescriptorType::Bare => return Err(DescriptorError::BarePk),
311    })
312}
313
314fn bip44_chain(network: Network) -> u32 {
315    if let Network::Bitcoin = network { 0 } else { 1 }
316}
317
318#[cfg(test)]
319mod tests {
320    use std::str::FromStr;
321
322    use bhwi::bitcoin::bip32::DerivationPath;
323
324    use super::{keypool_descriptor_path, validate_keypool_range};
325
326    #[test]
327    fn sortedmulti_purposes_match_their_script_wrappers() {
328        use bhwi::miniscript::descriptor::DescriptorType;
329
330        for (descriptor_type, expected) in [
331            (DescriptorType::ShSortedMulti, 44),
332            (DescriptorType::WshSortedMulti, 84),
333            (DescriptorType::ShWshSortedMulti, 49),
334        ] {
335            assert_eq!(super::bip44_purpose(descriptor_type).unwrap(), expected);
336        }
337    }
338
339    #[test]
340    fn device_errors_are_not_double_prefixed() {
341        use crate::{HWIDeviceError, descriptors::DescriptorError};
342
343        let inner = HWIDeviceError::new(std::io::Error::other("boom"));
344
345        assert_eq!(DescriptorError::Device(inner).to_string(), "boom");
346    }
347
348    #[test]
349    fn derivation_errors_are_not_prefixed() {
350        use bhwi::bitcoin::bip32::ChildNumber;
351
352        use crate::descriptors::DescriptorError;
353
354        let inner = ChildNumber::from_hardened_idx(4_000_000_000).unwrap_err();
355        let expected = inner.to_string();
356
357        assert_eq!(DescriptorError::Bip32(inner).to_string(), expected);
358    }
359
360    #[test]
361    fn keypool_path_appends_receive_branch() {
362        let path = DerivationPath::from_str("m/84'/0'/0'").unwrap();
363
364        let path = keypool_descriptor_path(&path, false).unwrap();
365
366        assert_eq!(path.to_string(), "84'/0'/0'/0");
367    }
368
369    #[test]
370    fn keypool_path_appends_internal_branch() {
371        let path = DerivationPath::from_str("m/84'/0'/0'").unwrap();
372
373        let path = keypool_descriptor_path(&path, true).unwrap();
374
375        assert_eq!(path.to_string(), "84'/0'/0'/1");
376    }
377
378    #[test]
379    fn keypool_range_rejects_start_after_end() {
380        let err = validate_keypool_range(10, 9).unwrap_err();
381
382        assert!(err.to_string().contains("start index"));
383    }
384
385    #[test]
386    fn keypool_path_rejects_hardened_children_after_unhardened() {
387        let path = DerivationPath::from_str("m/84'/0'/0'/0/1'").unwrap();
388
389        let err = keypool_descriptor_path(&path, false).unwrap_err();
390
391        assert!(err.to_string().contains("hardened children"));
392    }
393}