md_codec/canonicalize.rs
1//! BIP 388 placeholder-ordering canonicalization per spec v0.13 §6.1, plus
2//! per-`@N` canonical-fill expansion per §5.3 / §6.3.
3//!
4//! BIP 388 wallet policies require placeholder indices `@0..@{N-1}` to be
5//! introduced in the descriptor template in canonical first-occurrence order:
6//! the first new placeholder encountered in document-order pre-order
7//! traversal must be `@0`, the next new one `@1`, etc.
8//!
9//! [`canonicalize_placeholder_indices`] reshapes a [`Descriptor`] in place
10//! so this invariant holds, atomically permuting:
11//!
12//! - the tree's `KeyArg.index` and `Tr.key_index` fields;
13//! - the [`PathDecl`](crate::origin_path::PathDecl)'s `Divergent` paths vector (one path per `@N`);
14//! - per-`@N` TLV maps: `use_site_path_overrides`, `fingerprints`,
15//! `pubkeys`, `origin_path_overrides`.
16//!
17//! After canonicalization, the post-conditions are:
18//!
19//! 1. Each TLV map's `(idx, _)` keys are strictly ascending and `< n`.
20//! 2. The tree's first-occurrence sequence is exactly `[0, 1, ..., n-1]`
21//! (verified via [`crate::validate::validate_placeholder_usage`]).
22//!
23//! Idempotent: calling on an already-canonical descriptor is a no-op
24//! (identity-permutation fast path).
25//!
26//! The decoder side does not call this routine: v0.11's
27//! [`crate::validate::validate_placeholder_usage`] rejects non-canonical
28//! wires up-front via
29//! [`Error::PlaceholderFirstOccurrenceOutOfOrder`].
30//!
31//! [`expand_per_at_n`] resolves each `@N` to a fully-populated
32//! [`ExpandedKey`] (origin, use-site, optional fp/xpub) by composing the
33//! per-`@N` TLV overrides with the descriptor-level baselines. Used by
34//! Phase 4's `WalletPolicyId` construction and Phase 5's decoder validation.
35
36use crate::encode::Descriptor;
37use crate::error::Error;
38use crate::origin_path::{OriginPath, PathDeclPaths};
39use crate::tree::{Body, Node};
40use crate::use_site_path::UseSitePath;
41
42/// Walk `node` in pre-order, recording the first occurrence of each
43/// placeholder index in `first_occurrences`. `seen[i]` toggles to `true`
44/// the first time `@i` is encountered.
45fn walk_collect_first(node: &Node, seen: &mut [bool], first_occurrences: &mut Vec<u8>) {
46 match &node.body {
47 Body::KeyArg { index } => {
48 if let Some(slot) = seen.get_mut(*index as usize) {
49 if !*slot {
50 *slot = true;
51 first_occurrences.push(*index);
52 }
53 }
54 }
55 Body::Tr {
56 is_nums,
57 key_index,
58 tree,
59 } => {
60 // SPEC v0.30 §7: when `is_nums = true` the internal key is the
61 // BIP-341 NUMS H-point (not a placeholder reference); skip
62 // registration.
63 if !*is_nums {
64 if let Some(slot) = seen.get_mut(*key_index as usize) {
65 if !*slot {
66 *slot = true;
67 first_occurrences.push(*key_index);
68 }
69 }
70 }
71 if let Some(t) = tree {
72 walk_collect_first(t, seen, first_occurrences);
73 }
74 }
75 Body::Children(children) => {
76 for c in children {
77 walk_collect_first(c, seen, first_occurrences);
78 }
79 }
80 Body::Variable { children, .. } => {
81 for c in children {
82 walk_collect_first(c, seen, first_occurrences);
83 }
84 }
85 Body::MultiKeys { indices, .. } => {
86 // v0.30 Phase C: multi-family bodies carry raw key indices.
87 for idx in indices {
88 if let Some(slot) = seen.get_mut(*idx as usize) {
89 if !*slot {
90 *slot = true;
91 first_occurrences.push(*idx);
92 }
93 }
94 }
95 }
96 Body::Hash256Body(_) | Body::Hash160Body(_) | Body::Timelock(_) | Body::Empty => {}
97 }
98}
99
100/// Apply `perm[old_idx] -> new_idx` to every `KeyArg.index` and
101/// `Tr.key_index` in `node` (recursive).
102fn remap_indices(node: &mut Node, perm: &[u8]) {
103 match &mut node.body {
104 Body::KeyArg { index } => {
105 *index = perm[*index as usize];
106 }
107 Body::Tr {
108 is_nums,
109 key_index,
110 tree,
111 } => {
112 // SPEC v0.30 §7: when `is_nums = true` the internal key is the
113 // BIP-341 NUMS H-point (not a placeholder); skip remapping.
114 if !*is_nums {
115 *key_index = perm[*key_index as usize];
116 }
117 if let Some(t) = tree {
118 remap_indices(t, perm);
119 }
120 }
121 Body::Children(children) => {
122 for c in children {
123 remap_indices(c, perm);
124 }
125 }
126 Body::Variable { children, .. } => {
127 for c in children {
128 remap_indices(c, perm);
129 }
130 }
131 Body::MultiKeys { indices, .. } => {
132 // v0.30 Phase C: rewrite raw indices through the perm map.
133 for idx in indices.iter_mut() {
134 *idx = perm[*idx as usize];
135 }
136 }
137 Body::Hash256Body(_) | Body::Hash160Body(_) | Body::Timelock(_) | Body::Empty => {}
138 }
139}
140
141/// Remap idx values in a sparse TLV vector and re-sort ascending. After
142/// `perm` is applied the (possibly scrambled) idx column is restored to
143/// strictly ascending order, preserving the per-entry payload.
144fn remap_tlv_vec<T>(entries: &mut [(u8, T)], perm: &[u8]) {
145 for (idx, _) in entries.iter_mut() {
146 *idx = perm[*idx as usize];
147 }
148 entries.sort_by_key(|(idx, _)| *idx);
149}
150
151/// Canonicalize placeholder indices in `d` so the first-occurrence
152/// sequence in `d.tree` is exactly `[0, 1, ..., d.n - 1]`.
153///
154/// Walks the tree in document order to build a first-occurrence map,
155/// then atomically permutes indices in the tree, the path declaration
156/// (divergent variant), and every per-`@N` TLV map. Identity-permutation
157/// fast path: returns `Ok(())` without mutating `d` if the tree is
158/// already canonical.
159///
160/// # Errors
161///
162/// Returns [`Error::PlaceholderNotReferenced`] if any `@i` for
163/// `0 ≤ i < d.n` does not appear in the tree (a structural error that
164/// would otherwise leave the permutation under-specified).
165///
166/// Returns [`Error::PlaceholderIndexOutOfRange`] if the tree references
167/// a placeholder `@i` with `i >= d.n`.
168pub fn canonicalize_placeholder_indices(d: &mut Descriptor) -> Result<(), Error> {
169 let n = d.n as usize;
170
171 // Defensive bounds check before walking — surface out-of-range
172 // placeholder references as a typed error rather than silently
173 // ignoring them in walk_collect_first.
174 check_placeholder_bounds(&d.tree, d.n)?;
175
176 let mut seen = vec![false; n];
177 let mut first_occurrences: Vec<u8> = Vec::with_capacity(n);
178 walk_collect_first(&d.tree, &mut seen, &mut first_occurrences);
179
180 // Every `@i` must be referenced; otherwise the permutation is
181 // under-specified.
182 for (i, was_seen) in seen.iter().enumerate() {
183 if !was_seen {
184 return Err(Error::PlaceholderNotReferenced {
185 idx: i as u8,
186 n: d.n,
187 });
188 }
189 }
190
191 // perm[old_idx] = new_idx, where new_idx is the position at which
192 // old_idx was first observed in document order.
193 let mut perm = vec![0u8; n];
194 for (new_idx, &old_idx) in first_occurrences.iter().enumerate() {
195 perm[old_idx as usize] = new_idx as u8;
196 }
197
198 // Identity fast path: no work needed when perm is the identity.
199 if perm.iter().enumerate().all(|(i, p)| i as u8 == *p) {
200 return Ok(());
201 }
202
203 // Atomically apply the permutation to every index-bearing field.
204 remap_indices(&mut d.tree, &perm);
205
206 // Bind `d.n` before the mutable borrow of `d.path_decl.paths` below so
207 // the error payload (which wants the `u8`) does not re-borrow `d` while
208 // `paths` is live.
209 let n_keys = d.n;
210 if let PathDeclPaths::Divergent(paths) = &mut d.path_decl.paths {
211 // L6: a hand-built Descriptor can carry a short Divergent vector;
212 // guard before indexing old_paths[inverse[new_idx]] to surface a
213 // typed error instead of an out-of-bounds panic (mirror
214 // expand_per_at_n's length check).
215 if paths.len() != n {
216 return Err(Error::DivergentPathCountMismatch {
217 n: n_keys,
218 got: paths.len(),
219 });
220 }
221 // paths[old_idx] becomes paths[perm[old_idx]] — i.e. new_paths[new_idx] = old_paths[old_idx]
222 // where perm[old_idx] = new_idx. We need new_paths[new_idx] = old_paths[inverse_perm[new_idx]].
223 let mut inverse = vec![0u8; n];
224 for (old, &new) in perm.iter().enumerate() {
225 inverse[new as usize] = old as u8;
226 }
227 let old_paths = std::mem::take(paths);
228 let mut new_paths = Vec::with_capacity(n);
229 for new_idx in 0..n {
230 new_paths.push(old_paths[inverse[new_idx] as usize].clone());
231 }
232 *paths = new_paths;
233 }
234
235 if let Some(v) = d.tlv.use_site_path_overrides.as_mut() {
236 remap_tlv_vec(v, &perm);
237 }
238 if let Some(v) = d.tlv.fingerprints.as_mut() {
239 remap_tlv_vec(v, &perm);
240 }
241 if let Some(v) = d.tlv.pubkeys.as_mut() {
242 remap_tlv_vec(v, &perm);
243 }
244 if let Some(v) = d.tlv.origin_path_overrides.as_mut() {
245 remap_tlv_vec(v, &perm);
246 }
247
248 // Post-condition assertions (debug-only). Constructing the iterator-
249 // based check is cheap; gating to debug mode keeps release builds
250 // free of redundant work since the permutation is correct by
251 // construction.
252 debug_assert!(
253 crate::validate::validate_placeholder_usage(&d.tree, d.n).is_ok(),
254 "post-condition: tree first-occurrence must be canonical after canonicalize_placeholder_indices",
255 );
256 debug_assert!(
257 tlv_indices_strictly_ascending_and_in_range(d),
258 "post-condition: every TLV's idx column must be strictly ascending and < n",
259 );
260
261 Ok(())
262}
263
264/// Verify every `@N` reference in `node` falls within `0..n`. Returns
265/// [`Error::PlaceholderIndexOutOfRange`] on the first violation.
266fn check_placeholder_bounds(node: &Node, n: u8) -> Result<(), Error> {
267 match &node.body {
268 Body::KeyArg { index } => {
269 if *index >= n {
270 return Err(Error::PlaceholderIndexOutOfRange { idx: *index, n });
271 }
272 }
273 Body::Tr {
274 is_nums,
275 key_index,
276 tree,
277 } => {
278 // SPEC v0.30 §7 + §11: when `is_nums = true` the internal key is
279 // the BIP-341 NUMS H-point (not a placeholder); skip the bounds
280 // check. Otherwise `key_index` must be in `0..n`; out-of-range
281 // raises `NUMSSentinelConflict` per SPEC §11 (Phase G finalizes
282 // the variant's full doc-comment).
283 if !*is_nums && *key_index >= n {
284 return Err(Error::NUMSSentinelConflict);
285 }
286 if let Some(t) = tree {
287 check_placeholder_bounds(t, n)?;
288 }
289 }
290 Body::Children(children) => {
291 for c in children {
292 check_placeholder_bounds(c, n)?;
293 }
294 }
295 Body::Variable { children, .. } => {
296 for c in children {
297 check_placeholder_bounds(c, n)?;
298 }
299 }
300 Body::MultiKeys { indices, .. } => {
301 for idx in indices {
302 if *idx >= n {
303 return Err(Error::PlaceholderIndexOutOfRange { idx: *idx, n });
304 }
305 }
306 }
307 Body::Hash256Body(_) | Body::Hash160Body(_) | Body::Timelock(_) | Body::Empty => {}
308 }
309 Ok(())
310}
311
312/// Returns `true` if every TLV map's idx column is strictly ascending
313/// and within `0..d.n`. Used by debug-only post-condition assertions and
314/// by tests that want to exercise this invariant directly.
315fn tlv_indices_strictly_ascending_and_in_range(d: &Descriptor) -> bool {
316 fn check<T>(v: &Option<Vec<(u8, T)>>, n: u8) -> bool {
317 let Some(v) = v else {
318 return true;
319 };
320 let mut prev: Option<u8> = None;
321 for (idx, _) in v {
322 if *idx >= n {
323 return false;
324 }
325 if let Some(p) = prev {
326 if *idx <= p {
327 return false;
328 }
329 }
330 prev = Some(*idx);
331 }
332 true
333 }
334 check(&d.tlv.use_site_path_overrides, d.n)
335 && check(&d.tlv.fingerprints, d.n)
336 && check(&d.tlv.pubkeys, d.n)
337 && check(&d.tlv.origin_path_overrides, d.n)
338}
339
340/// Fully-resolved per-`@N` key record produced by [`expand_per_at_n`].
341///
342/// Each field is populated via the canonical-fill / per-`@N` override
343/// composition rules (spec v0.13 §5.3 + §6.3, "Option A"):
344///
345/// - `origin_path`: `OriginPathOverrides[idx]` if present, else
346/// `path_decl` resolved per the `Shared` / `Divergent` variant.
347/// - `use_site_path`: `UseSitePathOverrides[idx]` if present, else
348/// `Descriptor::use_site_path` (the descriptor-level baseline).
349/// - `fingerprint`: `Fingerprints[idx]` if present, else `None`.
350/// - `xpub`: `Pubkeys[idx]` if present, else `None`.
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct ExpandedKey {
353 /// Placeholder index `@N`; equals position in the returned `Vec`.
354 pub idx: u8,
355 /// Resolved origin path (per-`@N` override or `path_decl` baseline).
356 pub origin_path: OriginPath,
357 /// Resolved use-site path (per-`@N` override or descriptor baseline).
358 pub use_site_path: UseSitePath,
359 /// 4-byte master fingerprint, if a `Fingerprints` entry is present.
360 pub fingerprint: Option<[u8; 4]>,
361 /// 65-byte xpub (32 chain-code || 33 compressed pubkey), if a
362 /// `Pubkeys` entry is present.
363 pub xpub: Option<[u8; 65]>,
364}
365
366/// Linearly look up an `idx` key in a sparse, ascending `(idx, value)`
367/// vector. Returns the matching value by reference, or `None` if absent.
368///
369/// Sparse TLV maps are kept small (at most `d.n` entries, n ≤ 32), so a
370/// linear scan is the obvious choice over a binary search or BTreeMap.
371fn sparse_lookup<T>(v: &Option<Vec<(u8, T)>>, idx: u8) -> Option<&T> {
372 v.as_ref()
373 .and_then(|entries| entries.iter().find(|(i, _)| *i == idx).map(|(_, t)| t))
374}
375
376/// Expand a [`Descriptor`] into a vector of [`ExpandedKey`] records — one
377/// per `@N` in `0..d.n` — by composing per-`@N` TLV overrides with the
378/// descriptor-level baselines (spec v0.13 §5.3 / §6.3, "Option A").
379///
380/// Used by Phase 4's `WalletPolicyId` construction (the canonical-fill
381/// hash input is built from this vector) and Phase 5's decoder validation
382/// (the `MissingExplicitOrigin` check).
383///
384/// # Precondition
385///
386/// The caller MUST have already invoked
387/// [`canonicalize_placeholder_indices`] on `d`, or `d` must have been
388/// produced by the decoder (which rejects non-canonical wires up-front).
389/// Expansion does not re-canonicalize; passing a non-canonical descriptor
390/// produces semantically meaningful but `@N`-mis-aligned output.
391///
392/// # Resolution rules
393///
394/// Per `idx` in `0..d.n`:
395/// - **origin**: `OriginPathOverrides[idx]` if present; else
396/// `path_decl.paths` resolved by variant — `Shared(p)` returns `p` for
397/// every idx, `Divergent(v)` returns `v[idx]`.
398/// - **use-site**: `UseSitePathOverrides[idx]` if present; else
399/// `d.use_site_path` (the descriptor-level baseline that already
400/// carries the standard-multipath default when the wire elided it).
401/// - **fp**: `Fingerprints[idx]` if present, else `None`.
402/// - **xpub**: `Pubkeys[idx]` if present, else `None`.
403///
404/// # Errors
405///
406/// Returns [`Error::MissingExplicitOrigin`] when the resolved origin path
407/// for `idx` is empty (depth-0) AND `OriginPathOverrides[idx]` is absent
408/// AND [`crate::canonical_origin::canonical_origin`] returns `None` for
409/// `d.tree`. This is the structurally-rare "non-canonical wrapper without
410/// any user-supplied path" case; v0.11 wires already require `path_decl`
411/// to be populated, so this surfaces only when the shared-form path is
412/// itself empty.
413///
414/// Returns [`Error::DivergentPathCountMismatch`] if `path_decl.paths` is
415/// `Divergent(v)` and `v.len() != d.n` — a malformed descriptor that the
416/// v0.11 decoder would already reject; surfaced here defensively.
417///
418/// # INVARIANT (Option A, spec v0.13 §3 + §6.3)
419///
420/// `path_decl.paths` is always populated post-decode (v0.11 wire
421/// invariant). Canonical-fill into `path_decl` happens at *encode time*
422/// only (per spec §6.3) — by the time this function runs on a decoded
423/// `Descriptor`, the wire has already supplied either an explicit
424/// shared/divergent path or the encoder's canonical substitution.
425/// Consequently this function does NOT consult
426/// [`crate::canonical_origin::canonical_origin`] for the per-`@N` path
427/// (it only consults `canonical_origin` to decide whether the
428/// non-canonical-wrapper error gate applies).
429///
430/// Any future change that elides `path_decl` on the wire would require
431/// re-introducing `canonical_origin` lookups *here* and in
432/// [`crate::identity::compute_wallet_policy_id`] — both call sites
433/// share this invariant.
434pub fn expand_per_at_n(d: &Descriptor) -> Result<Vec<ExpandedKey>, Error> {
435 // Defensive: malformed descriptors with mismatched divergent path
436 // counts cannot be structurally exercised post-decode (v0.11 enforces
437 // n == divergent.len() during PathDecl::read), but check anyway so a
438 // hand-built Descriptor can't slip past.
439 if let PathDeclPaths::Divergent(paths) = &d.path_decl.paths {
440 if paths.len() != d.n as usize {
441 return Err(Error::DivergentPathCountMismatch {
442 n: d.n,
443 got: paths.len(),
444 });
445 }
446 }
447
448 let mut out = Vec::with_capacity(d.n as usize);
449 for idx in 0..d.n {
450 // Origin resolution: per-@N override beats path_decl baseline.
451 let origin_path = if let Some(p) = sparse_lookup(&d.tlv.origin_path_overrides, idx) {
452 p.clone()
453 } else {
454 match &d.path_decl.paths {
455 PathDeclPaths::Shared(p) => p.clone(),
456 PathDeclPaths::Divergent(v) => v[idx as usize].clone(),
457 }
458 };
459
460 // Structurally-rare: shared (or divergent) baseline path is empty
461 // AND no override present AND wrapper has no canonical default.
462 // This is the only path in v0.11+v0.13 where MissingExplicitOrigin
463 // can be raised.
464 if origin_path.components.is_empty()
465 && sparse_lookup(&d.tlv.origin_path_overrides, idx).is_none()
466 && crate::canonical_origin::canonical_origin(&d.tree).is_none()
467 {
468 return Err(Error::MissingExplicitOrigin { idx });
469 }
470
471 // Use-site resolution: per-@N override beats descriptor baseline.
472 let use_site_path = sparse_lookup(&d.tlv.use_site_path_overrides, idx)
473 .cloned()
474 .unwrap_or_else(|| d.use_site_path.clone());
475
476 let fingerprint = sparse_lookup(&d.tlv.fingerprints, idx).copied();
477 let xpub = sparse_lookup(&d.tlv.pubkeys, idx).copied();
478
479 out.push(ExpandedKey {
480 idx,
481 origin_path,
482 use_site_path,
483 fingerprint,
484 xpub,
485 });
486 }
487 Ok(out)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
494 use crate::tag::Tag;
495 use crate::tlv::TlvSection;
496 use crate::tree::{Body, Node};
497 use crate::use_site_path::UseSitePath;
498
499 fn shared_bip84() -> PathDecl {
500 PathDecl {
501 n: 1,
502 paths: PathDeclPaths::Shared(OriginPath {
503 components: vec![
504 PathComponent {
505 hardened: true,
506 value: 84,
507 },
508 PathComponent {
509 hardened: true,
510 value: 0,
511 },
512 PathComponent {
513 hardened: true,
514 value: 0,
515 },
516 ],
517 }),
518 }
519 }
520
521 fn shared_path_decl(n: u8) -> PathDecl {
522 PathDecl {
523 n,
524 paths: PathDeclPaths::Shared(OriginPath {
525 components: vec![PathComponent {
526 hardened: true,
527 value: 48,
528 }],
529 }),
530 }
531 }
532
533 fn no_multipath() -> UseSitePath {
534 UseSitePath {
535 multipath: None,
536 wildcard_hardened: false,
537 }
538 }
539
540 /// Pre-condition: `tr(@0)` already canonical → after canonicalize,
541 /// descriptor unchanged.
542 #[test]
543 fn identity_permutation_no_op() {
544 let d = Descriptor {
545 n: 1,
546 path_decl: shared_bip84(),
547 use_site_path: no_multipath(),
548 tree: Node {
549 tag: Tag::Tr,
550 body: Body::Tr {
551 is_nums: false,
552 key_index: 0,
553 tree: None,
554 },
555 },
556 tlv: TlvSection::new_empty(),
557 };
558 let mut d2 = d.clone();
559 canonicalize_placeholder_indices(&mut d2).unwrap();
560 assert_eq!(d, d2);
561 }
562
563 /// Encoder canonicalizes `tr(multi(2, @1, @0))` →
564 /// `tr(multi(2, @0, @1))` with swapped indices.
565 #[test]
566 fn swap_two_placeholders_in_multi() {
567 let mut d = Descriptor {
568 n: 2,
569 path_decl: shared_path_decl(2),
570 use_site_path: no_multipath(),
571 // tr keypath @0 already references @0 first, so embed the
572 // swap inside the tap-script-tree where the document-order
573 // walk will hit @1 first.
574 tree: Node {
575 tag: Tag::Multi,
576 body: Body::MultiKeys {
577 k: 2,
578 indices: vec![1, 0],
579 },
580 },
581 tlv: TlvSection::new_empty(),
582 };
583 canonicalize_placeholder_indices(&mut d).unwrap();
584 let expected_tree = Node {
585 tag: Tag::Multi,
586 body: Body::MultiKeys {
587 k: 2,
588 indices: vec![0, 1],
589 },
590 };
591 assert_eq!(d.tree, expected_tree);
592 }
593
594 /// `wsh(sortedmulti(2, @2, @0, @1))` → tree becomes canonical and
595 /// TLV `pubkeys` is renumbered consistently.
596 ///
597 /// Originally: pubkey-A is wired to @0, pubkey-B to @1, pubkey-C to @2.
598 /// After tree first-occurrence is `[2, 0, 1]`:
599 /// perm[0] = 1, perm[1] = 2, perm[2] = 0.
600 /// So the on-disk pubkeys vec `[(0, A), (1, B), (2, C)]` becomes
601 /// `[(perm[0], A), (perm[1], B), (perm[2], C)]`
602 /// = `[(1, A), (2, B), (0, C)]`, then re-sorted to
603 /// `[(0, C), (1, A), (2, B)]`.
604 #[test]
605 fn permute_three_placeholders_in_sortedmulti() {
606 let xpub_a = [0xaa; 65];
607 let xpub_b = [0xbb; 65];
608 let xpub_c = [0xcc; 65];
609 let mut d = Descriptor {
610 n: 3,
611 path_decl: shared_path_decl(3),
612 use_site_path: no_multipath(),
613 tree: Node {
614 tag: Tag::Wsh,
615 body: Body::Children(vec![Node {
616 tag: Tag::SortedMulti,
617 body: Body::MultiKeys {
618 k: 2,
619 indices: vec![2, 0, 1],
620 },
621 }]),
622 },
623 tlv: {
624 let mut t = TlvSection::new_empty();
625 t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b), (2, xpub_c)]);
626 t
627 },
628 };
629 canonicalize_placeholder_indices(&mut d).unwrap();
630 let expected_tree = Node {
631 tag: Tag::Wsh,
632 body: Body::Children(vec![Node {
633 tag: Tag::SortedMulti,
634 body: Body::MultiKeys {
635 k: 2,
636 indices: vec![0, 1, 2],
637 },
638 }]),
639 };
640 assert_eq!(d.tree, expected_tree);
641 assert_eq!(
642 d.tlv.pubkeys.unwrap(),
643 vec![(0, xpub_c), (1, xpub_a), (2, xpub_b)],
644 );
645 }
646
647 /// Divergent path declaration is reordered in lockstep with the
648 /// placeholder indices: paths[new] holds the path that was wired to
649 /// the @N now mapped to that new index.
650 #[test]
651 fn permute_with_divergent_path_decl() {
652 let path_for_at_0 = OriginPath {
653 components: vec![PathComponent {
654 hardened: true,
655 value: 84,
656 }],
657 };
658 let path_for_at_1 = OriginPath {
659 components: vec![PathComponent {
660 hardened: true,
661 value: 86,
662 }],
663 };
664 let mut d = Descriptor {
665 n: 2,
666 path_decl: PathDecl {
667 n: 2,
668 paths: PathDeclPaths::Divergent(vec![path_for_at_0.clone(), path_for_at_1.clone()]),
669 },
670 use_site_path: no_multipath(),
671 tree: Node {
672 tag: Tag::Wsh,
673 body: Body::Children(vec![Node {
674 tag: Tag::Multi,
675 body: Body::MultiKeys {
676 k: 2,
677 // First-occurrence: @1, then @0 → perm[0] = 1, perm[1] = 0.
678 indices: vec![1, 0],
679 },
680 }]),
681 },
682 tlv: TlvSection::new_empty(),
683 };
684 canonicalize_placeholder_indices(&mut d).unwrap();
685 // After: tree has @0 first, then @1. The @ that was originally
686 // @1 (and thus paired with `path_for_at_1`) is now @0, so
687 // paths[0] must be the path originally at index 1.
688 match &d.path_decl.paths {
689 PathDeclPaths::Divergent(paths) => {
690 assert_eq!(paths[0], path_for_at_1);
691 assert_eq!(paths[1], path_for_at_0);
692 }
693 _ => panic!("expected divergent paths"),
694 }
695 }
696
697 /// L6: a hand-built Descriptor with a SHORT Divergent vector
698 /// (length 1, but `n = 2`) and a NON-canonical tree ordering ([1, 0],
699 /// so the identity fast-path is bypassed) panics today at the
700 /// `old_paths[inverse[new_idx]]` index (OOB). After the length guard
701 /// it must surface a typed `DivergentPathCountMismatch` instead.
702 #[test]
703 fn canonicalize_short_divergent_returns_typed_error() {
704 let one_path = OriginPath {
705 components: vec![PathComponent {
706 hardened: true,
707 value: 84,
708 }],
709 };
710 let mut d = Descriptor {
711 n: 2,
712 path_decl: PathDecl {
713 n: 2,
714 // Length 1 ≠ n=2 — malformed/short Divergent vector.
715 paths: PathDeclPaths::Divergent(vec![one_path]),
716 },
717 use_site_path: no_multipath(),
718 tree: Node {
719 tag: Tag::Wsh,
720 body: Body::Children(vec![Node {
721 tag: Tag::Multi,
722 body: Body::MultiKeys {
723 k: 2,
724 // Non-canonical: @1 first → non-identity perm,
725 // so the Divergent reorder branch IS reached.
726 indices: vec![1, 0],
727 },
728 }]),
729 },
730 tlv: TlvSection::new_empty(),
731 };
732 let err = canonicalize_placeholder_indices(&mut d).unwrap_err();
733 assert!(
734 matches!(err, Error::DivergentPathCountMismatch { n: 2, got: 1 }),
735 "expected DivergentPathCountMismatch{{n:2,got:1}}, got {err:?}"
736 );
737 }
738
739 /// L6 scope-bound regression: a CANONICAL-ordering descriptor with a
740 /// short Divergent vector returns `Ok(())` via the identity fast-path
741 /// ([`canonicalize_placeholder_indices`] line ~200) WITHOUT reaching
742 /// the Divergent reorder branch — so the new guard does not over-reject
743 /// the fast-path. (The guard fires only on a NON-identity permutation.)
744 #[test]
745 fn canonicalize_identity_short_divergent_not_reached() {
746 let one_path = OriginPath {
747 components: vec![PathComponent {
748 hardened: true,
749 value: 84,
750 }],
751 };
752 let mut d = Descriptor {
753 n: 2,
754 path_decl: PathDecl {
755 n: 2,
756 paths: PathDeclPaths::Divergent(vec![one_path]),
757 },
758 use_site_path: no_multipath(),
759 tree: Node {
760 tag: Tag::Wsh,
761 body: Body::Children(vec![Node {
762 tag: Tag::Multi,
763 body: Body::MultiKeys {
764 k: 2,
765 // Canonical ordering: @0 first → identity perm →
766 // early return before the Divergent branch/guard.
767 indices: vec![0, 1],
768 },
769 }]),
770 },
771 tlv: TlvSection::new_empty(),
772 };
773 assert!(canonicalize_placeholder_indices(&mut d).is_ok());
774 }
775
776 /// `use_site_path_overrides` keys are remapped consistently with
777 /// the tree permutation.
778 #[test]
779 fn permute_with_use_site_path_overrides() {
780 let custom = UseSitePath::standard_multipath();
781 let mut d = Descriptor {
782 n: 2,
783 path_decl: shared_path_decl(2),
784 use_site_path: no_multipath(),
785 tree: Node {
786 tag: Tag::Multi,
787 body: Body::MultiKeys {
788 k: 2,
789 indices: vec![1, 0],
790 },
791 },
792 tlv: {
793 let mut t = TlvSection::new_empty();
794 // Override applies to the @ that was originally @1.
795 t.use_site_path_overrides = Some(vec![(1, custom.clone())]);
796 t
797 },
798 };
799 canonicalize_placeholder_indices(&mut d).unwrap();
800 // After: original @1 → new @0; override should now key on @0.
801 assert_eq!(d.tlv.use_site_path_overrides.unwrap(), vec![(0, custom)],);
802 }
803
804 /// Both `fingerprints` and `pubkeys` carry @N idx; both must be
805 /// remapped identically.
806 #[test]
807 fn permute_with_fingerprints_and_pubkeys() {
808 let fp_a = [0x11, 0x11, 0x11, 0x11];
809 let fp_b = [0x22, 0x22, 0x22, 0x22];
810 let fp_c = [0x33, 0x33, 0x33, 0x33];
811 let xpub_a = [0xaa; 65];
812 let xpub_b = [0xbb; 65];
813 let xpub_c = [0xcc; 65];
814 let mut d = Descriptor {
815 n: 3,
816 path_decl: shared_path_decl(3),
817 use_site_path: no_multipath(),
818 tree: Node {
819 tag: Tag::SortedMulti,
820 body: Body::MultiKeys {
821 k: 2,
822 // First-occurrence: @2, @0, @1
823 // perm[0]=1, perm[1]=2, perm[2]=0.
824 indices: vec![2, 0, 1],
825 },
826 },
827 tlv: {
828 let mut t = TlvSection::new_empty();
829 t.fingerprints = Some(vec![(0, fp_a), (1, fp_b), (2, fp_c)]);
830 t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b), (2, xpub_c)]);
831 t
832 },
833 };
834 canonicalize_placeholder_indices(&mut d).unwrap();
835 // Original (0,A)/(1,B)/(2,C) → perm gives (1,A)/(2,B)/(0,C) →
836 // sorted: (0,C), (1,A), (2,B).
837 assert_eq!(
838 d.tlv.fingerprints.unwrap(),
839 vec![(0, fp_c), (1, fp_a), (2, fp_b)],
840 );
841 assert_eq!(
842 d.tlv.pubkeys.unwrap(),
843 vec![(0, xpub_c), (1, xpub_a), (2, xpub_b)],
844 );
845 }
846
847 /// `origin_path_overrides` is also remapped correctly.
848 #[test]
849 fn permute_with_origin_path_overrides() {
850 let path_for_at_2 = OriginPath {
851 components: vec![PathComponent {
852 hardened: true,
853 value: 99,
854 }],
855 };
856 let mut d = Descriptor {
857 n: 3,
858 path_decl: shared_path_decl(3),
859 use_site_path: no_multipath(),
860 tree: Node {
861 tag: Tag::SortedMulti,
862 body: Body::MultiKeys {
863 k: 2,
864 // first-occurrence: @2, @0, @1 → perm[2]=0
865 indices: vec![2, 0, 1],
866 },
867 },
868 tlv: {
869 let mut t = TlvSection::new_empty();
870 t.origin_path_overrides = Some(vec![(2, path_for_at_2.clone())]);
871 t
872 },
873 };
874 canonicalize_placeholder_indices(&mut d).unwrap();
875 // perm[2] = 0; override at idx 2 maps to idx 0.
876 assert_eq!(
877 d.tlv.origin_path_overrides.unwrap(),
878 vec![(0, path_for_at_2)],
879 );
880 }
881
882 /// `tr(@0)` with `n=3` (i.e. @1 and @2 declared but never used) →
883 /// canonicalize errors with PlaceholderNotReferenced.
884 #[test]
885 fn unreferenced_placeholder_returns_error() {
886 let mut d = Descriptor {
887 n: 3,
888 path_decl: shared_path_decl(3),
889 use_site_path: no_multipath(),
890 tree: Node {
891 tag: Tag::Tr,
892 body: Body::Tr {
893 is_nums: false,
894 key_index: 0,
895 tree: None,
896 },
897 },
898 tlv: TlvSection::new_empty(),
899 };
900 let err = canonicalize_placeholder_indices(&mut d).unwrap_err();
901 assert!(matches!(
902 err,
903 Error::PlaceholderNotReferenced { idx: 1, n: 3 }
904 ));
905 }
906
907 /// Out-of-range `@N` reference is caught up-front with a typed error
908 /// rather than panicking inside the walker.
909 #[test]
910 fn out_of_range_placeholder_returns_error() {
911 let mut d = Descriptor {
912 n: 2,
913 path_decl: shared_path_decl(2),
914 use_site_path: no_multipath(),
915 tree: Node {
916 tag: Tag::Wpkh,
917 body: Body::KeyArg { index: 5 },
918 },
919 tlv: TlvSection::new_empty(),
920 };
921 let err = canonicalize_placeholder_indices(&mut d).unwrap_err();
922 assert!(matches!(
923 err,
924 Error::PlaceholderIndexOutOfRange { idx: 5, n: 2 }
925 ));
926 }
927
928 /// Idempotence: canonicalizing twice is a no-op after the first call.
929 #[test]
930 fn idempotence() {
931 let mut d = Descriptor {
932 n: 3,
933 path_decl: shared_path_decl(3),
934 use_site_path: no_multipath(),
935 tree: Node {
936 tag: Tag::SortedMulti,
937 body: Body::MultiKeys {
938 k: 2,
939 indices: vec![2, 0, 1],
940 },
941 },
942 tlv: {
943 let mut t = TlvSection::new_empty();
944 t.fingerprints = Some(vec![(0, [1; 4]), (1, [2; 4]), (2, [3; 4])]);
945 t
946 },
947 };
948 canonicalize_placeholder_indices(&mut d).unwrap();
949 let after_first = d.clone();
950 canonicalize_placeholder_indices(&mut d).unwrap();
951 assert_eq!(d, after_first);
952 }
953
954 /// Post-condition (1): every TLV map's idx column is strictly
955 /// ascending and `< d.n` after canonicalization.
956 #[test]
957 fn tlv_idx_post_condition() {
958 let mut d = Descriptor {
959 n: 3,
960 path_decl: shared_path_decl(3),
961 use_site_path: no_multipath(),
962 tree: Node {
963 tag: Tag::SortedMulti,
964 body: Body::MultiKeys {
965 k: 2,
966 indices: vec![2, 0, 1],
967 },
968 },
969 tlv: {
970 let mut t = TlvSection::new_empty();
971 t.fingerprints = Some(vec![(0, [1; 4]), (1, [2; 4]), (2, [3; 4])]);
972 t.pubkeys = Some(vec![(0, [0xaa; 65]), (1, [0xbb; 65]), (2, [0xcc; 65])]);
973 t
974 },
975 };
976 canonicalize_placeholder_indices(&mut d).unwrap();
977 assert!(tlv_indices_strictly_ascending_and_in_range(&d));
978 }
979
980 /// Post-condition (2): the tree's first-occurrence sequence is
981 /// exactly `[0, 1, ..., n-1]` after canonicalization.
982 #[test]
983 fn tree_first_occurrence_post_condition() {
984 let mut d = Descriptor {
985 n: 3,
986 path_decl: shared_path_decl(3),
987 use_site_path: no_multipath(),
988 tree: Node {
989 tag: Tag::SortedMulti,
990 body: Body::MultiKeys {
991 k: 2,
992 indices: vec![2, 0, 1],
993 },
994 },
995 tlv: TlvSection::new_empty(),
996 };
997 canonicalize_placeholder_indices(&mut d).unwrap();
998 // The validator returning Ok(()) is the canonical post-condition.
999 crate::validate::validate_placeholder_usage(&d.tree, d.n).unwrap();
1000 // Also walk explicitly to assert the literal sequence.
1001 let mut seen = vec![false; d.n as usize];
1002 let mut first = Vec::new();
1003 walk_collect_first(&d.tree, &mut seen, &mut first);
1004 assert_eq!(first, vec![0, 1, 2]);
1005 }
1006
1007 /// The encoder calls `canonicalize_placeholder_indices` internally,
1008 /// so a non-canonical input round-trips through encode/decode cleanly:
1009 /// the wire bytes are the canonical encoding, and the decoder accepts
1010 /// them without `PlaceholderFirstOccurrenceOutOfOrder`.
1011 #[test]
1012 fn encoder_canonicalizes_non_canonical_input() {
1013 let d = Descriptor {
1014 n: 2,
1015 path_decl: shared_path_decl(2),
1016 use_site_path: UseSitePath::standard_multipath(),
1017 tree: Node {
1018 tag: Tag::Wsh,
1019 body: Body::Children(vec![Node {
1020 tag: Tag::Multi,
1021 body: Body::MultiKeys {
1022 k: 2,
1023 // first-occurrence: @1 then @0 (non-canonical).
1024 indices: vec![1, 0],
1025 },
1026 }]),
1027 },
1028 tlv: TlvSection::new_empty(),
1029 };
1030 let (bytes, total_bits) =
1031 crate::encode::encode_payload(&d).expect("encoder must canonicalize and succeed");
1032 // Decoder rejects non-canonical first-occurrence ordering with
1033 // PlaceholderFirstOccurrenceOutOfOrder; if encoder didn't
1034 // canonicalize, this would fail.
1035 let decoded = crate::decode::decode_payload(&bytes, total_bits).expect("decode");
1036 // Decoded tree's first occurrence is canonical [0, 1].
1037 let mut seen = vec![false; decoded.n as usize];
1038 let mut first = Vec::new();
1039 walk_collect_first(&decoded.tree, &mut seen, &mut first);
1040 assert_eq!(first, vec![0, 1]);
1041 }
1042
1043 /// Post-condition (3): round-trip property — for hand-crafted
1044 /// permutations, `canonicalize → encode → decode → canonicalize`
1045 /// equals the canonicalize-only result. (Encode requires a fully
1046 /// well-formed descriptor, so this exercises the encoder path.)
1047 #[test]
1048 fn round_trip_canonicalize_encode_decode_canonicalize() {
1049 // 8 permutations of @0,@1,@2 inside sortedmulti(2, ...) plus
1050 // base canonical and one swap-pair → 10 total cases.
1051 let permutations: Vec<Vec<u8>> = vec![
1052 vec![0, 1, 2],
1053 vec![0, 2, 1],
1054 vec![1, 0, 2],
1055 vec![1, 2, 0],
1056 vec![2, 0, 1],
1057 vec![2, 1, 0],
1058 vec![1, 0, 1], // duplicate refs (re-uses @1 and @0; only first introduces)
1059 vec![2, 1, 0], // duplicate of above to give 8
1060 ];
1061 for perm in permutations {
1062 // n is the count of distinct placeholders in `perm`.
1063 let mut distinct: Vec<u8> = perm.clone();
1064 distinct.sort_unstable();
1065 distinct.dedup();
1066 let n = distinct.len() as u8;
1067 assert!(n >= 2, "test fixture expects ≥2 distinct placeholders");
1068 // Children are pk_k(@perm[i]) — but to match `n` we must use
1069 // exactly the `n` placeholders {0, 1, ..., n-1}; the
1070 // permutation `perm` already does that as long as `distinct`
1071 // == 0..n. Re-index if the permutation skipped any.
1072 let mut renumbered = perm.clone();
1073 // Build mapping: each distinct value gets the position of
1074 // its sorted occurrence as its label, ensuring the resulting
1075 // descriptor has placeholders 0..n exactly.
1076 let mut mapping = std::collections::HashMap::new();
1077 for (i, v) in distinct.iter().enumerate() {
1078 mapping.insert(*v, i as u8);
1079 }
1080 for v in renumbered.iter_mut() {
1081 *v = mapping[v];
1082 }
1083
1084 let indices: Vec<u8> = renumbered.clone();
1085 let n_children = indices.len();
1086 let k_value = std::cmp::min(2u8, n_children as u8);
1087 let mut d = Descriptor {
1088 n,
1089 path_decl: shared_path_decl(n),
1090 use_site_path: UseSitePath::standard_multipath(),
1091 tree: Node {
1092 tag: Tag::Wsh,
1093 body: Body::Children(vec![Node {
1094 tag: Tag::SortedMulti,
1095 body: Body::MultiKeys {
1096 k: k_value,
1097 indices,
1098 },
1099 }]),
1100 },
1101 tlv: TlvSection::new_empty(),
1102 };
1103 canonicalize_placeholder_indices(&mut d).unwrap();
1104 let canonical = d.clone();
1105
1106 // Encode → decode and confirm the result is already
1107 // canonical (decoder accepts it cleanly).
1108 let (bytes, total_bits) = crate::encode::encode_payload(&d).expect("encode");
1109 let decoded = crate::decode::decode_payload(&bytes, total_bits).expect("decode");
1110 let mut decoded_mut = decoded;
1111 canonicalize_placeholder_indices(&mut decoded_mut).unwrap();
1112 assert_eq!(canonical, decoded_mut);
1113 }
1114 }
1115}
1116
1117#[cfg(test)]
1118mod expand_tests {
1119 use super::*;
1120 use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
1121 use crate::tag::Tag;
1122 use crate::tlv::TlvSection;
1123 use crate::tree::{Body, Node};
1124 use crate::use_site_path::UseSitePath;
1125
1126 fn bip84() -> OriginPath {
1127 OriginPath {
1128 components: vec![
1129 PathComponent {
1130 hardened: true,
1131 value: 84,
1132 },
1133 PathComponent {
1134 hardened: true,
1135 value: 0,
1136 },
1137 PathComponent {
1138 hardened: true,
1139 value: 0,
1140 },
1141 ],
1142 }
1143 }
1144
1145 fn bip48_type_2() -> OriginPath {
1146 OriginPath {
1147 components: vec![
1148 PathComponent {
1149 hardened: true,
1150 value: 48,
1151 },
1152 PathComponent {
1153 hardened: true,
1154 value: 0,
1155 },
1156 PathComponent {
1157 hardened: true,
1158 value: 0,
1159 },
1160 PathComponent {
1161 hardened: true,
1162 value: 2,
1163 },
1164 ],
1165 }
1166 }
1167
1168 /// 1-of-1 wpkh, `path_decl: Shared(BIP84)`, no overrides → expanded
1169 /// `@0` has BIP-84 origin, descriptor-level use-site, no fp/xpub.
1170 #[test]
1171 fn expand_full_elision_canonical_wpkh() {
1172 let d = Descriptor {
1173 n: 1,
1174 path_decl: PathDecl {
1175 n: 1,
1176 paths: PathDeclPaths::Shared(bip84()),
1177 },
1178 use_site_path: UseSitePath::standard_multipath(),
1179 tree: Node {
1180 tag: Tag::Wpkh,
1181 body: Body::KeyArg { index: 0 },
1182 },
1183 tlv: TlvSection::new_empty(),
1184 };
1185 let expanded = expand_per_at_n(&d).unwrap();
1186 assert_eq!(expanded.len(), 1);
1187 assert_eq!(expanded[0].idx, 0);
1188 assert_eq!(expanded[0].origin_path, bip84());
1189 assert_eq!(expanded[0].use_site_path, UseSitePath::standard_multipath());
1190 assert!(expanded[0].fingerprint.is_none());
1191 assert!(expanded[0].xpub.is_none());
1192 }
1193
1194 /// 2-of-3 wsh-sortedmulti with `Shared(BIP48 type 2)` → all 3
1195 /// expanded keys have the same shared origin path.
1196 #[test]
1197 fn expand_full_elision_canonical_wsh_multi() {
1198 let d = Descriptor {
1199 n: 3,
1200 path_decl: PathDecl {
1201 n: 3,
1202 paths: PathDeclPaths::Shared(bip48_type_2()),
1203 },
1204 use_site_path: UseSitePath::standard_multipath(),
1205 tree: Node {
1206 tag: Tag::Wsh,
1207 body: Body::Children(vec![Node {
1208 tag: Tag::SortedMulti,
1209 body: Body::MultiKeys {
1210 k: 2,
1211 indices: vec![0, 1, 2],
1212 },
1213 }]),
1214 },
1215 tlv: TlvSection::new_empty(),
1216 };
1217 let expanded = expand_per_at_n(&d).unwrap();
1218 assert_eq!(expanded.len(), 3);
1219 for ek in &expanded {
1220 assert_eq!(ek.origin_path, bip48_type_2());
1221 assert_eq!(ek.use_site_path, UseSitePath::standard_multipath());
1222 assert!(ek.fingerprint.is_none());
1223 assert!(ek.xpub.is_none());
1224 }
1225 }
1226
1227 /// 2-of-3 with `OriginPathOverrides[1] = m/84'/0'/5'` (account 5):
1228 /// expanded `@1` gets the override; `@0` and `@2` use the shared
1229 /// `path_decl` baseline.
1230 #[test]
1231 fn expand_per_idx_override_mix() {
1232 let custom_path = OriginPath {
1233 components: vec![
1234 PathComponent {
1235 hardened: true,
1236 value: 84,
1237 },
1238 PathComponent {
1239 hardened: true,
1240 value: 0,
1241 },
1242 PathComponent {
1243 hardened: true,
1244 value: 5,
1245 },
1246 ],
1247 };
1248 let d = Descriptor {
1249 n: 3,
1250 path_decl: PathDecl {
1251 n: 3,
1252 paths: PathDeclPaths::Shared(bip48_type_2()),
1253 },
1254 use_site_path: UseSitePath::standard_multipath(),
1255 tree: Node {
1256 tag: Tag::Wsh,
1257 body: Body::Children(vec![Node {
1258 tag: Tag::SortedMulti,
1259 body: Body::MultiKeys {
1260 k: 2,
1261 indices: vec![0, 1, 2],
1262 },
1263 }]),
1264 },
1265 tlv: {
1266 let mut t = TlvSection::new_empty();
1267 t.origin_path_overrides = Some(vec![(1, custom_path.clone())]);
1268 t
1269 },
1270 };
1271 let expanded = expand_per_at_n(&d).unwrap();
1272 assert_eq!(expanded[0].origin_path, bip48_type_2());
1273 assert_eq!(expanded[1].origin_path, custom_path);
1274 assert_eq!(expanded[2].origin_path, bip48_type_2());
1275 }
1276
1277 /// 2-of-2 with `Divergent([path_a, path_b])` → expanded keys carry
1278 /// the per-`@N` divergent paths.
1279 #[test]
1280 fn expand_divergent_paths() {
1281 let path_a = OriginPath {
1282 components: vec![PathComponent {
1283 hardened: true,
1284 value: 84,
1285 }],
1286 };
1287 let path_b = OriginPath {
1288 components: vec![PathComponent {
1289 hardened: true,
1290 value: 86,
1291 }],
1292 };
1293 let d = Descriptor {
1294 n: 2,
1295 path_decl: PathDecl {
1296 n: 2,
1297 paths: PathDeclPaths::Divergent(vec![path_a.clone(), path_b.clone()]),
1298 },
1299 use_site_path: UseSitePath::standard_multipath(),
1300 tree: Node {
1301 tag: Tag::Wsh,
1302 body: Body::Children(vec![Node {
1303 tag: Tag::Multi,
1304 body: Body::MultiKeys {
1305 k: 2,
1306 indices: vec![0, 1],
1307 },
1308 }]),
1309 },
1310 tlv: TlvSection::new_empty(),
1311 };
1312 let expanded = expand_per_at_n(&d).unwrap();
1313 assert_eq!(expanded[0].origin_path, path_a);
1314 assert_eq!(expanded[1].origin_path, path_b);
1315 }
1316
1317 /// Descriptor with `UseSitePathOverrides[0] = custom` → `@0` has
1318 /// the override, `@1` uses `d.use_site_path`.
1319 #[test]
1320 fn expand_use_site_path_overrides() {
1321 let baseline = UseSitePath::standard_multipath();
1322 let custom = UseSitePath {
1323 multipath: None,
1324 wildcard_hardened: true,
1325 };
1326 let d = Descriptor {
1327 n: 2,
1328 path_decl: PathDecl {
1329 n: 2,
1330 paths: PathDeclPaths::Shared(bip48_type_2()),
1331 },
1332 use_site_path: baseline.clone(),
1333 tree: Node {
1334 tag: Tag::Wsh,
1335 body: Body::Children(vec![Node {
1336 tag: Tag::Multi,
1337 body: Body::MultiKeys {
1338 k: 2,
1339 indices: vec![0, 1],
1340 },
1341 }]),
1342 },
1343 tlv: {
1344 let mut t = TlvSection::new_empty();
1345 t.use_site_path_overrides = Some(vec![(0, custom.clone())]);
1346 t
1347 },
1348 };
1349 let expanded = expand_per_at_n(&d).unwrap();
1350 assert_eq!(expanded[0].use_site_path, custom);
1351 assert_eq!(expanded[1].use_site_path, baseline);
1352 }
1353
1354 /// 2-of-3 with sparse `Fingerprints[0]` and `Pubkeys[2]` → only
1355 /// those slots have `Some(...)`; others are `None`.
1356 #[test]
1357 fn expand_fingerprints_and_pubkeys() {
1358 let fp = [0xaa, 0xbb, 0xcc, 0xdd];
1359 let mut xpub = [0u8; 65];
1360 for (i, b) in xpub.iter_mut().enumerate() {
1361 *b = i as u8;
1362 }
1363 let d = Descriptor {
1364 n: 3,
1365 path_decl: PathDecl {
1366 n: 3,
1367 paths: PathDeclPaths::Shared(bip48_type_2()),
1368 },
1369 use_site_path: UseSitePath::standard_multipath(),
1370 tree: Node {
1371 tag: Tag::Wsh,
1372 body: Body::Children(vec![Node {
1373 tag: Tag::SortedMulti,
1374 body: Body::MultiKeys {
1375 k: 2,
1376 indices: vec![0, 1, 2],
1377 },
1378 }]),
1379 },
1380 tlv: {
1381 let mut t = TlvSection::new_empty();
1382 t.fingerprints = Some(vec![(0, fp)]);
1383 t.pubkeys = Some(vec![(2, xpub)]);
1384 t
1385 },
1386 };
1387 let expanded = expand_per_at_n(&d).unwrap();
1388 assert_eq!(expanded[0].fingerprint, Some(fp));
1389 assert!(expanded[1].fingerprint.is_none());
1390 assert!(expanded[2].fingerprint.is_none());
1391 assert!(expanded[0].xpub.is_none());
1392 assert!(expanded[1].xpub.is_none());
1393 assert_eq!(expanded[2].xpub, Some(xpub));
1394 }
1395
1396 /// `sh(sortedmulti(...))` with shared empty path AND no
1397 /// `OriginPathOverrides` → `MissingExplicitOrigin { idx: 0 }`.
1398 /// Construct an empty `OriginPath` (depth-0) to hit the structural
1399 /// edge case.
1400 #[test]
1401 fn expand_non_canonical_wrapper_without_overrides_errors() {
1402 let empty_path = OriginPath { components: vec![] };
1403 let d = Descriptor {
1404 n: 2,
1405 path_decl: PathDecl {
1406 n: 2,
1407 paths: PathDeclPaths::Shared(empty_path),
1408 },
1409 use_site_path: UseSitePath::standard_multipath(),
1410 tree: Node {
1411 tag: Tag::Sh,
1412 body: Body::Children(vec![Node {
1413 tag: Tag::SortedMulti,
1414 body: Body::MultiKeys {
1415 k: 2,
1416 indices: vec![0, 1],
1417 },
1418 }]),
1419 },
1420 tlv: TlvSection::new_empty(),
1421 };
1422 let err = expand_per_at_n(&d).unwrap_err();
1423 assert!(matches!(err, Error::MissingExplicitOrigin { idx: 0 }));
1424 }
1425
1426 /// Determinism: encode `wpkh(@0)` two ways — once with `Shared(BIP84)`
1427 /// in `path_decl` and no overrides; once with the same explicit path
1428 /// supplied as an override on top of an unrelated baseline — and the
1429 /// expansion is equal up to the origin_path. (The classic "elided vs
1430 /// explicit" determinism gate from the plan.)
1431 #[test]
1432 fn expand_determinism_across_elision() {
1433 // Wallet A: elided form. path_decl carries the canonical BIP-84.
1434 let d_elided = Descriptor {
1435 n: 1,
1436 path_decl: PathDecl {
1437 n: 1,
1438 paths: PathDeclPaths::Shared(bip84()),
1439 },
1440 use_site_path: UseSitePath::standard_multipath(),
1441 tree: Node {
1442 tag: Tag::Wpkh,
1443 body: Body::KeyArg { index: 0 },
1444 },
1445 tlv: TlvSection::new_empty(),
1446 };
1447 // Wallet B: explicit form. Same canonical BIP-84 path placed into
1448 // path_decl (Option A semantics — the encoder writes
1449 // canonical_origin into path_decl when no overrides supplied).
1450 let d_explicit = Descriptor {
1451 n: 1,
1452 path_decl: PathDecl {
1453 n: 1,
1454 paths: PathDeclPaths::Shared(bip84()),
1455 },
1456 use_site_path: UseSitePath::standard_multipath(),
1457 tree: Node {
1458 tag: Tag::Wpkh,
1459 body: Body::KeyArg { index: 0 },
1460 },
1461 tlv: TlvSection::new_empty(),
1462 };
1463 assert_eq!(
1464 expand_per_at_n(&d_elided).unwrap(),
1465 expand_per_at_n(&d_explicit).unwrap()
1466 );
1467 }
1468
1469 /// `tr(multi(2, @1, @0))` (non-canonical first-occurrence) →
1470 /// canonicalize permutes to `[0, 1]` and shifts the per-`@N` pubkeys
1471 /// in lockstep. After canonicalize+expand, expanded[0].xpub equals
1472 /// the xpub originally wired to `@1` (the now-canonical first slot).
1473 #[test]
1474 fn expand_after_canonicalize_uses_canonical_indices() {
1475 let xpub_a = [0xaa; 65];
1476 let xpub_b = [0xbb; 65];
1477 let mut d = Descriptor {
1478 n: 2,
1479 path_decl: PathDecl {
1480 n: 2,
1481 paths: PathDeclPaths::Shared(bip48_type_2()),
1482 },
1483 use_site_path: UseSitePath::standard_multipath(),
1484 tree: Node {
1485 tag: Tag::Multi,
1486 body: Body::MultiKeys {
1487 k: 2,
1488 // first-occurrence: @1 then @0 → perm[0]=1, perm[1]=0.
1489 indices: vec![1, 0],
1490 },
1491 },
1492 tlv: {
1493 let mut t = TlvSection::new_empty();
1494 // Wired-in: @0 → A, @1 → B.
1495 t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b)]);
1496 t
1497 },
1498 };
1499 canonicalize_placeholder_indices(&mut d).unwrap();
1500 let expanded = expand_per_at_n(&d).unwrap();
1501 // After permutation, original-@1 becomes new-@0, so expanded[0]
1502 // carries the xpub originally wired to @1 (= xpub_b).
1503 assert_eq!(expanded[0].xpub, Some(xpub_b));
1504 assert_eq!(expanded[1].xpub, Some(xpub_a));
1505 }
1506}