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 (resolved against the baseline
109        // use-site multipath alt-count, which bounds the supported chains).
110        if let Some(alts) = &self.use_site_path.multipath {
111            if (chain as usize) >= alts.len() {
112                return Err(Error::ChainIndexOutOfRange {
113                    chain,
114                    alt_count: alts.len(),
115                });
116            }
117        } else if chain != 0 {
118            return Err(Error::ChainIndexOutOfRange {
119                chain,
120                alt_count: 0,
121            });
122        }
123
124        let desc = crate::to_miniscript::to_miniscript_descriptor(self, chain)?;
125        let definite =
126            desc.at_derivation_index(index)
127                .map_err(|e| Error::AddressDerivationFailed {
128                    detail: e.to_string(),
129                })?;
130        let addr = definite
131            .address(network)
132            .map_err(|e| Error::AddressDerivationFailed {
133                detail: e.to_string(),
134            })?;
135        Ok(addr.into_unchecked())
136    }
137}
138
139#[cfg(all(test, feature = "derive"))]
140mod tests {
141    use super::*;
142    use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
143    use crate::tag::Tag;
144    use crate::tlv::TlvSection;
145    use crate::tree::{Body, Node};
146    use crate::use_site_path::{Alternative, UseSitePath};
147
148    // ─── xpub_from_tlv_bytes ─────────────────────────────────────────
149
150    #[test]
151    fn xpub_from_tlv_bytes_rejects_invalid_pubkey() {
152        // 33 zero bytes is not a valid compressed pubkey.
153        let bytes = [0u8; 65];
154        assert!(matches!(
155            xpub_from_tlv_bytes(7, &bytes),
156            Err(Error::InvalidXpubBytes { idx: 7 })
157        ));
158    }
159
160    fn bip84_origin() -> OriginPath {
161        OriginPath {
162            components: vec![
163                PathComponent {
164                    hardened: true,
165                    value: 84,
166                },
167                PathComponent {
168                    hardened: true,
169                    value: 0,
170                },
171                PathComponent {
172                    hardened: true,
173                    value: 0,
174                },
175            ],
176        }
177    }
178
179    fn one_test_xpub_bytes() -> [u8; 65] {
180        let mut bytes = [0u8; 65];
181        bytes[0..32].copy_from_slice(&[0x42; 32]);
182        bytes[32] = 0x02;
183        bytes[33..].copy_from_slice(&[
184            0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87,
185            0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B,
186            0x16, 0xF8, 0x17, 0x98,
187        ]);
188        bytes
189    }
190
191    #[test]
192    fn derive_address_missing_pubkey_for_partial_keys() {
193        // 2-of-2 wsh-sortedmulti with only @0 populated.
194        let d = Descriptor {
195            n: 2,
196            path_decl: PathDecl {
197                n: 2,
198                paths: PathDeclPaths::Shared(OriginPath {
199                    components: vec![
200                        PathComponent {
201                            hardened: true,
202                            value: 48,
203                        },
204                        PathComponent {
205                            hardened: true,
206                            value: 0,
207                        },
208                        PathComponent {
209                            hardened: true,
210                            value: 0,
211                        },
212                        PathComponent {
213                            hardened: true,
214                            value: 2,
215                        },
216                    ],
217                }),
218            },
219            use_site_path: UseSitePath::standard_multipath(),
220            tree: Node {
221                tag: Tag::Wsh,
222                body: Body::Children(vec![Node {
223                    tag: Tag::SortedMulti,
224                    body: Body::MultiKeys {
225                        k: 2,
226                        indices: vec![0, 1],
227                    },
228                }]),
229            },
230            tlv: {
231                let mut t = TlvSection::new_empty();
232                t.pubkeys = Some(vec![(0u8, one_test_xpub_bytes())]);
233                t
234            },
235        };
236        let err = d.derive_address(0, 0, Network::Bitcoin).unwrap_err();
237        assert!(matches!(err, Error::MissingPubkey { idx: 1 }));
238    }
239
240    #[test]
241    fn derive_address_chain_out_of_range() {
242        let d = Descriptor {
243            n: 1,
244            path_decl: PathDecl {
245                n: 1,
246                paths: PathDeclPaths::Shared(bip84_origin()),
247            },
248            use_site_path: UseSitePath::standard_multipath(), // alt-count=2
249            tree: Node {
250                tag: Tag::Wpkh,
251                body: Body::KeyArg { index: 0 },
252            },
253            tlv: {
254                let mut t = TlvSection::new_empty();
255                t.pubkeys = Some(vec![(0u8, one_test_xpub_bytes())]);
256                t
257            },
258        };
259        let err = d.derive_address(5, 0, Network::Bitcoin).unwrap_err();
260        assert!(matches!(
261            err,
262            Error::ChainIndexOutOfRange {
263                chain: 5,
264                alt_count: 2
265            }
266        ));
267    }
268
269    #[test]
270    fn derive_address_hardened_wildcard_rejected() {
271        let d = Descriptor {
272            n: 1,
273            path_decl: PathDecl {
274                n: 1,
275                paths: PathDeclPaths::Shared(bip84_origin()),
276            },
277            use_site_path: UseSitePath {
278                multipath: Some(vec![
279                    Alternative {
280                        hardened: false,
281                        value: 0,
282                    },
283                    Alternative {
284                        hardened: false,
285                        value: 1,
286                    },
287                ]),
288                wildcard_hardened: true,
289            },
290            tree: Node {
291                tag: Tag::Wpkh,
292                body: Body::KeyArg { index: 0 },
293            },
294            tlv: {
295                let mut t = TlvSection::new_empty();
296                t.pubkeys = Some(vec![(0u8, one_test_xpub_bytes())]);
297                t
298            },
299        };
300        let err = d.derive_address(0, 0, Network::Bitcoin).unwrap_err();
301        assert!(matches!(err, Error::HardenedPublicDerivation));
302    }
303}