Skip to main content

md_codec/
derive.rs

1//! Address derivation (v0.32).
2//!
3//! v0.32 replaces the v0.14-era hand-rolled 5-shape allow-list with an
4//! AST → [`miniscript::Descriptor`] converter
5//! ([`crate::to_miniscript::to_miniscript_descriptor`]) and delegates
6//! address rendering to rust-miniscript. Any BIP-388-parseable shape
7//! derives — multi-leaf tap-trees, `tr(NUMS, ...)`, `sh(multi)`, arbitrary
8//! `wsh(<miniscript>)`, and any tap-leaf miniscript fragment included.
9//!
10//! Feature-gated: requires `derive` (default-on). Pure-codec consumers can
11//! opt out via `default-features = false`.
12//!
13//! ### What this module does NOT do
14//!
15//! - Origin path is not consulted. Origin is the path *to* the xpub from
16//!   the master seed; address derivation starts at the xpub. The recorded
17//!   origin matters for signing flows (PSBT key-source metadata), not for
18//!   getting an address.
19//! - Master fingerprint (`Fingerprints` TLV) is unused for the same
20//!   reason — it identifies the master, not the derivation root.
21//! - Hardened use-site components are rejected. Hardened public derivation
22//!   is forbidden by BIP 32; an xpub-only restore cannot produce addresses
23//!   for a wallet whose use-site path has a hardened alternative or
24//!   hardened wildcard.
25
26#[cfg(feature = "derive")]
27use crate::encode::Descriptor;
28#[cfg(feature = "derive")]
29use crate::error::Error;
30#[cfg(feature = "derive")]
31use bitcoin::NetworkKind;
32#[cfg(feature = "derive")]
33use bitcoin::address::NetworkUnchecked;
34#[cfg(feature = "derive")]
35use bitcoin::bip32::{ChainCode, ChildNumber, Fingerprint, Xpub};
36#[cfg(feature = "derive")]
37use bitcoin::secp256k1::PublicKey;
38#[cfg(feature = "derive")]
39use bitcoin::{Address, Network};
40
41/// Reconstruct an [`Xpub`] from a 65-byte `Pubkeys` TLV payload.
42///
43/// Layout: `bytes[0..32]` = chain code; `bytes[32..65]` = compressed
44/// public key. The four BIP 32 metadata fields (`network`, `depth`,
45/// `parent_fingerprint`, `child_number`) are not used by
46/// [`Xpub::derive_pub`] (only `chain_code` and `public_key` participate
47/// in `CKDpub`); they are filled with safe placeholders.
48#[cfg(feature = "derive")]
49pub(crate) fn xpub_from_tlv_bytes(idx: u8, bytes: &[u8; 65]) -> Result<Xpub, Error> {
50    let chain_code_bytes: [u8; 32] = bytes[0..32]
51        .try_into()
52        .expect("32-byte slice is statically sized");
53    let chain_code = ChainCode::from(chain_code_bytes);
54    let public_key =
55        PublicKey::from_slice(&bytes[32..65]).map_err(|_| Error::InvalidXpubBytes { idx })?;
56    Ok(Xpub {
57        network: NetworkKind::Main,
58        depth: 0,
59        parent_fingerprint: Fingerprint::default(),
60        child_number: ChildNumber::Normal { index: 0 },
61        public_key,
62        chain_code,
63    })
64}
65
66#[cfg(feature = "derive")]
67impl Descriptor {
68    /// Derive the address at `(chain, index)` for this descriptor on
69    /// `network`.
70    ///
71    /// `chain` selects the use-site multipath alternative (e.g. `0` =
72    /// receive, `1` = change for the standard `<0;1>/*` form). `index` is
73    /// the trailing wildcard child number.
74    ///
75    /// Returns an [`Address<NetworkUnchecked>`]; callers can
76    /// `.assume_checked()` (when they trust the network parameter) or
77    /// `.require_network(network)` to lock it down.
78    ///
79    /// # Errors
80    ///
81    /// - [`Error::MissingPubkey`] when any `@N` lacks an xpub.
82    /// - [`Error::InvalidXpubBytes`] when an xpub's 33-byte pubkey field
83    ///   doesn't parse as a valid secp256k1 point.
84    /// - [`Error::ChainIndexOutOfRange`] when `chain` is out of range for
85    ///   the use-site multipath.
86    /// - [`Error::HardenedPublicDerivation`] when the use-site path
87    ///   requires a hardened derivation step.
88    /// - [`Error::MissingExplicitOrigin`] propagated from
89    ///   [`crate::canonicalize::expand_per_at_n`].
90    /// - [`Error::AddressDerivationFailed`] for any miniscript-layer
91    ///   failure (type check, context error, unsupported fragment).
92    pub fn derive_address(
93        &self,
94        chain: u32,
95        index: u32,
96        network: Network,
97    ) -> Result<Address<NetworkUnchecked>, Error> {
98        // Pre-flight: hardened public-key derivation rejection (BIP-32
99        // forbids). The shared `has_hardened_use_site` predicate (Point B)
100        // covers BOTH the baseline use-site path AND every per-`@N`
101        // override — a hardened wildcard OR any hardened multipath
102        // alternative, anywhere. The pre-fix baseline-only checks missed a
103        // hardened alternative inside an override, which then surfaced only
104        // as a generic `AddressDerivationFailed` deep in the converter.
105        if crate::to_miniscript::has_hardened_use_site(self) {
106            return Err(Error::HardenedPublicDerivation);
107        }
108        // Pre-flight: chain index in range. Bound by the MAX alt-count across
109        // the baseline use-site path AND every per-`@N` override (None →
110        // alt-count 1, i.e. only chain 0). The per-key path
111        // (use_site_to_derivation_path) is the real authority and STILL
112        // fail-closes per key; this coarse gate must not be narrower than the
113        // widest key, or a valid override change chain is rejected.
114        let baseline_alts = self
115            .use_site_path
116            .multipath
117            .as_ref()
118            .map(|a| a.len())
119            .unwrap_or(1);
120        let max_alts = self
121            .tlv
122            .use_site_path_overrides
123            .iter()
124            .flatten()
125            .map(|(_, p)| p.multipath.as_ref().map(|a| a.len()).unwrap_or(1))
126            .fold(baseline_alts, std::cmp::max);
127        if (chain as usize) >= max_alts {
128            return Err(Error::ChainIndexOutOfRange {
129                chain,
130                alt_count: max_alts,
131            });
132        }
133
134        let desc = crate::to_miniscript::to_miniscript_descriptor(self, chain)?;
135        let definite =
136            desc.at_derivation_index(index)
137                .map_err(|e| Error::AddressDerivationFailed {
138                    detail: e.to_string(),
139                })?;
140        let addr = definite
141            .address(network)
142            .map_err(|e| Error::AddressDerivationFailed {
143                detail: e.to_string(),
144            })?;
145        Ok(addr.into_unchecked())
146    }
147}
148
149#[cfg(all(test, feature = "derive"))]
150mod tests {
151    use super::*;
152    use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
153    use crate::tag::Tag;
154    use crate::tlv::TlvSection;
155    use crate::tree::{Body, Node};
156    use crate::use_site_path::{Alternative, UseSitePath};
157
158    // ─── xpub_from_tlv_bytes ─────────────────────────────────────────
159
160    #[test]
161    fn xpub_from_tlv_bytes_rejects_invalid_pubkey() {
162        // 33 zero bytes is not a valid compressed pubkey.
163        let bytes = [0u8; 65];
164        assert!(matches!(
165            xpub_from_tlv_bytes(7, &bytes),
166            Err(Error::InvalidXpubBytes { idx: 7 })
167        ));
168    }
169
170    fn bip84_origin() -> OriginPath {
171        OriginPath {
172            components: vec![
173                PathComponent {
174                    hardened: true,
175                    value: 84,
176                },
177                PathComponent {
178                    hardened: true,
179                    value: 0,
180                },
181                PathComponent {
182                    hardened: true,
183                    value: 0,
184                },
185            ],
186        }
187    }
188
189    fn one_test_xpub_bytes() -> [u8; 65] {
190        let mut bytes = [0u8; 65];
191        bytes[0..32].copy_from_slice(&[0x42; 32]);
192        bytes[32] = 0x02;
193        bytes[33..].copy_from_slice(&[
194            0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87,
195            0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B,
196            0x16, 0xF8, 0x17, 0x98,
197        ]);
198        bytes
199    }
200
201    #[test]
202    fn derive_address_missing_pubkey_for_partial_keys() {
203        // 2-of-2 wsh-sortedmulti with only @0 populated.
204        let d = Descriptor {
205            n: 2,
206            path_decl: PathDecl {
207                n: 2,
208                paths: PathDeclPaths::Shared(OriginPath {
209                    components: vec![
210                        PathComponent {
211                            hardened: true,
212                            value: 48,
213                        },
214                        PathComponent {
215                            hardened: true,
216                            value: 0,
217                        },
218                        PathComponent {
219                            hardened: true,
220                            value: 0,
221                        },
222                        PathComponent {
223                            hardened: true,
224                            value: 2,
225                        },
226                    ],
227                }),
228            },
229            use_site_path: UseSitePath::standard_multipath(),
230            tree: Node {
231                tag: Tag::Wsh,
232                body: Body::Children(vec![Node {
233                    tag: Tag::SortedMulti,
234                    body: Body::MultiKeys {
235                        k: 2,
236                        indices: vec![0, 1],
237                    },
238                }]),
239            },
240            tlv: {
241                let mut t = TlvSection::new_empty();
242                t.pubkeys = Some(vec![(0u8, one_test_xpub_bytes())]);
243                t
244            },
245        };
246        let err = d.derive_address(0, 0, Network::Bitcoin).unwrap_err();
247        assert!(matches!(err, Error::MissingPubkey { idx: 1 }));
248    }
249
250    #[test]
251    fn derive_address_chain_out_of_range() {
252        let d = Descriptor {
253            n: 1,
254            path_decl: PathDecl {
255                n: 1,
256                paths: PathDeclPaths::Shared(bip84_origin()),
257            },
258            use_site_path: UseSitePath::standard_multipath(), // alt-count=2
259            tree: Node {
260                tag: Tag::Wpkh,
261                body: Body::KeyArg { index: 0 },
262            },
263            tlv: {
264                let mut t = TlvSection::new_empty();
265                t.pubkeys = Some(vec![(0u8, one_test_xpub_bytes())]);
266                t
267            },
268        };
269        let err = d.derive_address(5, 0, Network::Bitcoin).unwrap_err();
270        assert!(matches!(
271            err,
272            Error::ChainIndexOutOfRange {
273                chain: 5,
274                alt_count: 2
275            }
276        ));
277    }
278
279    #[test]
280    fn derive_address_hardened_wildcard_rejected() {
281        let d = Descriptor {
282            n: 1,
283            path_decl: PathDecl {
284                n: 1,
285                paths: PathDeclPaths::Shared(bip84_origin()),
286            },
287            use_site_path: UseSitePath {
288                multipath: Some(vec![
289                    Alternative {
290                        hardened: false,
291                        value: 0,
292                    },
293                    Alternative {
294                        hardened: false,
295                        value: 1,
296                    },
297                ]),
298                wildcard_hardened: true,
299            },
300            tree: Node {
301                tag: Tag::Wpkh,
302                body: Body::KeyArg { index: 0 },
303            },
304            tlv: {
305                let mut t = TlvSection::new_empty();
306                t.pubkeys = Some(vec![(0u8, one_test_xpub_bytes())]);
307                t
308            },
309        };
310        let err = d.derive_address(0, 0, Network::Bitcoin).unwrap_err();
311        assert!(matches!(err, Error::HardenedPublicDerivation));
312    }
313
314    /// Build a legal D5(b)-shaped `wpkh(@0)` descriptor with a `None`
315    /// baseline use-site (alt-count modeled as 1) plus a per-`@0` use-site
316    /// override carrying a `<0;1>` multipath (alt-count 2). `@0` has an
317    /// xpub so addresses resolve.
318    fn none_baseline_override_change_descriptor() -> Descriptor {
319        Descriptor {
320            n: 1,
321            path_decl: PathDecl {
322                n: 1,
323                paths: PathDeclPaths::Shared(bip84_origin()),
324            },
325            use_site_path: UseSitePath {
326                multipath: None,
327                wildcard_hardened: false,
328            },
329            tree: Node {
330                tag: Tag::Wpkh,
331                body: Body::KeyArg { index: 0 },
332            },
333            tlv: {
334                let mut t = TlvSection::new_empty();
335                t.use_site_path_overrides = Some(vec![(
336                    0u8,
337                    UseSitePath {
338                        multipath: Some(vec![
339                            Alternative {
340                                hardened: false,
341                                value: 0,
342                            },
343                            Alternative {
344                                hardened: false,
345                                value: 1,
346                            },
347                        ]),
348                        wildcard_hardened: false,
349                    },
350                )]);
351                t.pubkeys = Some(vec![(0u8, one_test_xpub_bytes())]);
352                t
353            },
354        }
355    }
356
357    #[test]
358    fn derive_address_override_change_chain_derivable() {
359        // Funds-availability (M3): a `None`-baseline + `Some(<0;1>)`-override
360        // wallet must derive its change (chain-1) address. The pre-fix gate
361        // bounds `chain` ONLY by the baseline (`None` → only chain 0) and
362        // rejects chain=1 with `alt_count: 0`. The per-key path resolves
363        // `@0`'s own override multipath and derives correctly post-fix.
364        let d = none_baseline_override_change_descriptor();
365        // Receive (chain 0) control: derivable in both pre- and post-fix.
366        assert!(d.derive_address(0, 0, Network::Bitcoin).is_ok());
367        // Change (chain 1): RED today → GREEN after the gate widening.
368        let change = d.derive_address(1, 0, Network::Bitcoin);
369        assert!(
370            change.is_ok(),
371            "override change chain must derive, got {change:?}"
372        );
373    }
374
375    #[test]
376    fn derive_address_override_chain_over_max_still_rejects() {
377        // Positive control / no over-widening: chain=2 is beyond the
378        // override's 2-alt max (max_alts == 2) → still rejected.
379        let d = none_baseline_override_change_descriptor();
380        let err = d.derive_address(2, 0, Network::Bitcoin).unwrap_err();
381        assert!(
382            matches!(
383                err,
384                Error::ChainIndexOutOfRange {
385                    chain: 2,
386                    alt_count: 2
387                }
388            ),
389            "over-max chain must still reject with alt_count=2, got {err:?}"
390        );
391    }
392}