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