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}