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::EmptyOriginOverride`] (P0.3, I-1) when
415/// `OriginPathOverrides[idx]` is PRESENT but carries zero components —
416/// UNCONDITIONALLY, regardless of `canonical_origin` or `path_decl`. A
417/// present override is authoritative over `path_decl` (see the origin
418/// resolution rule above); an empty-but-present override would otherwise
419/// silently resolve to "no origin" without tripping the
420/// `MissingExplicitOrigin` gate (which only fires when NO override is
421/// present). Converges with
422/// [`crate::validate::validate_no_empty_origin_overrides`], the decode-side
423/// counterpart of this same check.
424///
425/// Returns [`Error::DivergentPathCountMismatch`] if `path_decl.paths` is
426/// `Divergent(v)` and `v.len() != d.n` — a malformed descriptor that the
427/// v0.11 decoder would already reject; surfaced here defensively.
428///
429/// # INVARIANT (Option A, spec v0.13 §3 + §6.3)
430///
431/// `path_decl.paths` is always populated post-decode (v0.11 wire
432/// invariant). Canonical-fill into `path_decl` happens at *encode time*
433/// only (per spec §6.3) — by the time this function runs on a decoded
434/// `Descriptor`, the wire has already supplied either an explicit
435/// shared/divergent path or the encoder's canonical substitution.
436/// Consequently this function does NOT consult
437/// [`crate::canonical_origin::canonical_origin`] for the per-`@N` path
438/// (it only consults `canonical_origin` to decide whether the
439/// non-canonical-wrapper error gate applies).
440///
441/// Any future change that elides `path_decl` on the wire would require
442/// re-introducing `canonical_origin` lookups *here* and in
443/// [`crate::identity::compute_wallet_policy_id`] — both call sites
444/// share this invariant.
445pub fn expand_per_at_n(d: &Descriptor) -> Result<Vec<ExpandedKey>, Error> {
446 // Defensive: malformed descriptors with mismatched divergent path
447 // counts cannot be structurally exercised post-decode (v0.11 enforces
448 // n == divergent.len() during PathDecl::read), but check anyway so a
449 // hand-built Descriptor can't slip past.
450 if let PathDeclPaths::Divergent(paths) = &d.path_decl.paths {
451 if paths.len() != d.n as usize {
452 return Err(Error::DivergentPathCountMismatch {
453 n: d.n,
454 got: paths.len(),
455 });
456 }
457 }
458
459 let mut out = Vec::with_capacity(d.n as usize);
460 for idx in 0..d.n {
461 let override_entry = sparse_lookup(&d.tlv.origin_path_overrides, idx);
462
463 // P0.3 (I-1): a PRESENT override with zero components is MALFORMED
464 // — reject UNCONDITIONALLY (even for a canonical-shape wrapper),
465 // before it can silently resolve to an empty origin below.
466 if let Some(p) = override_entry {
467 if p.components.is_empty() {
468 return Err(Error::EmptyOriginOverride { idx });
469 }
470 }
471
472 // Origin resolution: per-@N override beats path_decl baseline.
473 let origin_path = if let Some(p) = override_entry {
474 p.clone()
475 } else {
476 match &d.path_decl.paths {
477 PathDeclPaths::Shared(p) => p.clone(),
478 PathDeclPaths::Divergent(v) => v[idx as usize].clone(),
479 }
480 };
481
482 // Structurally-rare: shared (or divergent) baseline path is empty
483 // AND no override present AND wrapper has no canonical default.
484 // This is the only path in v0.11+v0.13 where MissingExplicitOrigin
485 // can be raised.
486 if origin_path.components.is_empty()
487 && override_entry.is_none()
488 && crate::canonical_origin::canonical_origin(&d.tree).is_none()
489 {
490 return Err(Error::MissingExplicitOrigin { idx });
491 }
492
493 // Use-site resolution: per-@N override beats descriptor baseline.
494 let use_site_path = sparse_lookup(&d.tlv.use_site_path_overrides, idx)
495 .cloned()
496 .unwrap_or_else(|| d.use_site_path.clone());
497
498 let fingerprint = sparse_lookup(&d.tlv.fingerprints, idx).copied();
499 let xpub = sparse_lookup(&d.tlv.pubkeys, idx).copied();
500
501 out.push(ExpandedKey {
502 idx,
503 origin_path,
504 use_site_path,
505 fingerprint,
506 xpub,
507 });
508 }
509 Ok(out)
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
516 use crate::tag::Tag;
517 use crate::tlv::TlvSection;
518 use crate::tree::{Body, Node};
519 use crate::use_site_path::UseSitePath;
520
521 fn shared_bip84() -> PathDecl {
522 PathDecl {
523 n: 1,
524 paths: PathDeclPaths::Shared(OriginPath {
525 components: vec![
526 PathComponent {
527 hardened: true,
528 value: 84,
529 },
530 PathComponent {
531 hardened: true,
532 value: 0,
533 },
534 PathComponent {
535 hardened: true,
536 value: 0,
537 },
538 ],
539 }),
540 }
541 }
542
543 fn shared_path_decl(n: u8) -> PathDecl {
544 PathDecl {
545 n,
546 paths: PathDeclPaths::Shared(OriginPath {
547 components: vec![PathComponent {
548 hardened: true,
549 value: 48,
550 }],
551 }),
552 }
553 }
554
555 fn no_multipath() -> UseSitePath {
556 UseSitePath {
557 multipath: None,
558 wildcard_hardened: false,
559 }
560 }
561
562 /// Pre-condition: `tr(@0)` already canonical → after canonicalize,
563 /// descriptor unchanged.
564 #[test]
565 fn identity_permutation_no_op() {
566 let d = Descriptor {
567 n: 1,
568 path_decl: shared_bip84(),
569 use_site_path: no_multipath(),
570 tree: Node {
571 tag: Tag::Tr,
572 body: Body::Tr {
573 is_nums: false,
574 key_index: 0,
575 tree: None,
576 },
577 },
578 tlv: TlvSection::new_empty(),
579 };
580 let mut d2 = d.clone();
581 canonicalize_placeholder_indices(&mut d2).unwrap();
582 assert_eq!(d, d2);
583 }
584
585 /// Encoder canonicalizes `tr(multi(2, @1, @0))` →
586 /// `tr(multi(2, @0, @1))` with swapped indices.
587 #[test]
588 fn swap_two_placeholders_in_multi() {
589 let mut d = Descriptor {
590 n: 2,
591 path_decl: shared_path_decl(2),
592 use_site_path: no_multipath(),
593 // tr keypath @0 already references @0 first, so embed the
594 // swap inside the tap-script-tree where the document-order
595 // walk will hit @1 first.
596 tree: Node {
597 tag: Tag::Multi,
598 body: Body::MultiKeys {
599 k: 2,
600 indices: vec![1, 0],
601 },
602 },
603 tlv: TlvSection::new_empty(),
604 };
605 canonicalize_placeholder_indices(&mut d).unwrap();
606 let expected_tree = Node {
607 tag: Tag::Multi,
608 body: Body::MultiKeys {
609 k: 2,
610 indices: vec![0, 1],
611 },
612 };
613 assert_eq!(d.tree, expected_tree);
614 }
615
616 /// `wsh(sortedmulti(2, @2, @0, @1))` → tree becomes canonical and
617 /// TLV `pubkeys` is renumbered consistently.
618 ///
619 /// Originally: pubkey-A is wired to @0, pubkey-B to @1, pubkey-C to @2.
620 /// After tree first-occurrence is `[2, 0, 1]`:
621 /// perm[0] = 1, perm[1] = 2, perm[2] = 0.
622 /// So the on-disk pubkeys vec `[(0, A), (1, B), (2, C)]` becomes
623 /// `[(perm[0], A), (perm[1], B), (perm[2], C)]`
624 /// = `[(1, A), (2, B), (0, C)]`, then re-sorted to
625 /// `[(0, C), (1, A), (2, B)]`.
626 #[test]
627 fn permute_three_placeholders_in_sortedmulti() {
628 let xpub_a = [0xaa; 65];
629 let xpub_b = [0xbb; 65];
630 let xpub_c = [0xcc; 65];
631 let mut d = Descriptor {
632 n: 3,
633 path_decl: shared_path_decl(3),
634 use_site_path: no_multipath(),
635 tree: Node {
636 tag: Tag::Wsh,
637 body: Body::Children(vec![Node {
638 tag: Tag::SortedMulti,
639 body: Body::MultiKeys {
640 k: 2,
641 indices: vec![2, 0, 1],
642 },
643 }]),
644 },
645 tlv: {
646 let mut t = TlvSection::new_empty();
647 t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b), (2, xpub_c)]);
648 t
649 },
650 };
651 canonicalize_placeholder_indices(&mut d).unwrap();
652 let expected_tree = Node {
653 tag: Tag::Wsh,
654 body: Body::Children(vec![Node {
655 tag: Tag::SortedMulti,
656 body: Body::MultiKeys {
657 k: 2,
658 indices: vec![0, 1, 2],
659 },
660 }]),
661 };
662 assert_eq!(d.tree, expected_tree);
663 assert_eq!(
664 d.tlv.pubkeys.unwrap(),
665 vec![(0, xpub_c), (1, xpub_a), (2, xpub_b)],
666 );
667 }
668
669 /// Divergent path declaration is reordered in lockstep with the
670 /// placeholder indices: paths[new] holds the path that was wired to
671 /// the @N now mapped to that new index.
672 #[test]
673 fn permute_with_divergent_path_decl() {
674 let path_for_at_0 = OriginPath {
675 components: vec![PathComponent {
676 hardened: true,
677 value: 84,
678 }],
679 };
680 let path_for_at_1 = OriginPath {
681 components: vec![PathComponent {
682 hardened: true,
683 value: 86,
684 }],
685 };
686 let mut d = Descriptor {
687 n: 2,
688 path_decl: PathDecl {
689 n: 2,
690 paths: PathDeclPaths::Divergent(vec![path_for_at_0.clone(), path_for_at_1.clone()]),
691 },
692 use_site_path: no_multipath(),
693 tree: Node {
694 tag: Tag::Wsh,
695 body: Body::Children(vec![Node {
696 tag: Tag::Multi,
697 body: Body::MultiKeys {
698 k: 2,
699 // First-occurrence: @1, then @0 → perm[0] = 1, perm[1] = 0.
700 indices: vec![1, 0],
701 },
702 }]),
703 },
704 tlv: TlvSection::new_empty(),
705 };
706 canonicalize_placeholder_indices(&mut d).unwrap();
707 // After: tree has @0 first, then @1. The @ that was originally
708 // @1 (and thus paired with `path_for_at_1`) is now @0, so
709 // paths[0] must be the path originally at index 1.
710 match &d.path_decl.paths {
711 PathDeclPaths::Divergent(paths) => {
712 assert_eq!(paths[0], path_for_at_1);
713 assert_eq!(paths[1], path_for_at_0);
714 }
715 _ => panic!("expected divergent paths"),
716 }
717 }
718
719 /// L6: a hand-built Descriptor with a SHORT Divergent vector
720 /// (length 1, but `n = 2`) and a NON-canonical tree ordering ([1, 0],
721 /// so the identity fast-path is bypassed) panics today at the
722 /// `old_paths[inverse[new_idx]]` index (OOB). After the length guard
723 /// it must surface a typed `DivergentPathCountMismatch` instead.
724 #[test]
725 fn canonicalize_short_divergent_returns_typed_error() {
726 let one_path = OriginPath {
727 components: vec![PathComponent {
728 hardened: true,
729 value: 84,
730 }],
731 };
732 let mut d = Descriptor {
733 n: 2,
734 path_decl: PathDecl {
735 n: 2,
736 // Length 1 ≠ n=2 — malformed/short Divergent vector.
737 paths: PathDeclPaths::Divergent(vec![one_path]),
738 },
739 use_site_path: no_multipath(),
740 tree: Node {
741 tag: Tag::Wsh,
742 body: Body::Children(vec![Node {
743 tag: Tag::Multi,
744 body: Body::MultiKeys {
745 k: 2,
746 // Non-canonical: @1 first → non-identity perm,
747 // so the Divergent reorder branch IS reached.
748 indices: vec![1, 0],
749 },
750 }]),
751 },
752 tlv: TlvSection::new_empty(),
753 };
754 let err = canonicalize_placeholder_indices(&mut d).unwrap_err();
755 assert!(
756 matches!(err, Error::DivergentPathCountMismatch { n: 2, got: 1 }),
757 "expected DivergentPathCountMismatch{{n:2,got:1}}, got {err:?}"
758 );
759 }
760
761 /// L6 scope-bound regression: a CANONICAL-ordering descriptor with a
762 /// short Divergent vector returns `Ok(())` via the identity fast-path
763 /// ([`canonicalize_placeholder_indices`] line ~200) WITHOUT reaching
764 /// the Divergent reorder branch — so the new guard does not over-reject
765 /// the fast-path. (The guard fires only on a NON-identity permutation.)
766 #[test]
767 fn canonicalize_identity_short_divergent_not_reached() {
768 let one_path = OriginPath {
769 components: vec![PathComponent {
770 hardened: true,
771 value: 84,
772 }],
773 };
774 let mut d = Descriptor {
775 n: 2,
776 path_decl: PathDecl {
777 n: 2,
778 paths: PathDeclPaths::Divergent(vec![one_path]),
779 },
780 use_site_path: no_multipath(),
781 tree: Node {
782 tag: Tag::Wsh,
783 body: Body::Children(vec![Node {
784 tag: Tag::Multi,
785 body: Body::MultiKeys {
786 k: 2,
787 // Canonical ordering: @0 first → identity perm →
788 // early return before the Divergent branch/guard.
789 indices: vec![0, 1],
790 },
791 }]),
792 },
793 tlv: TlvSection::new_empty(),
794 };
795 assert!(canonicalize_placeholder_indices(&mut d).is_ok());
796 }
797
798 /// `use_site_path_overrides` keys are remapped consistently with
799 /// the tree permutation.
800 #[test]
801 fn permute_with_use_site_path_overrides() {
802 let custom = UseSitePath::standard_multipath();
803 let mut d = Descriptor {
804 n: 2,
805 path_decl: shared_path_decl(2),
806 use_site_path: no_multipath(),
807 tree: Node {
808 tag: Tag::Multi,
809 body: Body::MultiKeys {
810 k: 2,
811 indices: vec![1, 0],
812 },
813 },
814 tlv: {
815 let mut t = TlvSection::new_empty();
816 // Override applies to the @ that was originally @1.
817 t.use_site_path_overrides = Some(vec![(1, custom.clone())]);
818 t
819 },
820 };
821 canonicalize_placeholder_indices(&mut d).unwrap();
822 // After: original @1 → new @0; override should now key on @0.
823 assert_eq!(d.tlv.use_site_path_overrides.unwrap(), vec![(0, custom)],);
824 }
825
826 /// Both `fingerprints` and `pubkeys` carry @N idx; both must be
827 /// remapped identically.
828 #[test]
829 fn permute_with_fingerprints_and_pubkeys() {
830 let fp_a = [0x11, 0x11, 0x11, 0x11];
831 let fp_b = [0x22, 0x22, 0x22, 0x22];
832 let fp_c = [0x33, 0x33, 0x33, 0x33];
833 let xpub_a = [0xaa; 65];
834 let xpub_b = [0xbb; 65];
835 let xpub_c = [0xcc; 65];
836 let mut d = Descriptor {
837 n: 3,
838 path_decl: shared_path_decl(3),
839 use_site_path: no_multipath(),
840 tree: Node {
841 tag: Tag::SortedMulti,
842 body: Body::MultiKeys {
843 k: 2,
844 // First-occurrence: @2, @0, @1
845 // perm[0]=1, perm[1]=2, perm[2]=0.
846 indices: vec![2, 0, 1],
847 },
848 },
849 tlv: {
850 let mut t = TlvSection::new_empty();
851 t.fingerprints = Some(vec![(0, fp_a), (1, fp_b), (2, fp_c)]);
852 t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b), (2, xpub_c)]);
853 t
854 },
855 };
856 canonicalize_placeholder_indices(&mut d).unwrap();
857 // Original (0,A)/(1,B)/(2,C) → perm gives (1,A)/(2,B)/(0,C) →
858 // sorted: (0,C), (1,A), (2,B).
859 assert_eq!(
860 d.tlv.fingerprints.unwrap(),
861 vec![(0, fp_c), (1, fp_a), (2, fp_b)],
862 );
863 assert_eq!(
864 d.tlv.pubkeys.unwrap(),
865 vec![(0, xpub_c), (1, xpub_a), (2, xpub_b)],
866 );
867 }
868
869 /// `origin_path_overrides` is also remapped correctly.
870 #[test]
871 fn permute_with_origin_path_overrides() {
872 let path_for_at_2 = OriginPath {
873 components: vec![PathComponent {
874 hardened: true,
875 value: 99,
876 }],
877 };
878 let mut d = Descriptor {
879 n: 3,
880 path_decl: shared_path_decl(3),
881 use_site_path: no_multipath(),
882 tree: Node {
883 tag: Tag::SortedMulti,
884 body: Body::MultiKeys {
885 k: 2,
886 // first-occurrence: @2, @0, @1 → perm[2]=0
887 indices: vec![2, 0, 1],
888 },
889 },
890 tlv: {
891 let mut t = TlvSection::new_empty();
892 t.origin_path_overrides = Some(vec![(2, path_for_at_2.clone())]);
893 t
894 },
895 };
896 canonicalize_placeholder_indices(&mut d).unwrap();
897 // perm[2] = 0; override at idx 2 maps to idx 0.
898 assert_eq!(
899 d.tlv.origin_path_overrides.unwrap(),
900 vec![(0, path_for_at_2)],
901 );
902 }
903
904 /// `tr(@0)` with `n=3` (i.e. @1 and @2 declared but never used) →
905 /// canonicalize errors with PlaceholderNotReferenced.
906 #[test]
907 fn unreferenced_placeholder_returns_error() {
908 let mut d = Descriptor {
909 n: 3,
910 path_decl: shared_path_decl(3),
911 use_site_path: no_multipath(),
912 tree: Node {
913 tag: Tag::Tr,
914 body: Body::Tr {
915 is_nums: false,
916 key_index: 0,
917 tree: None,
918 },
919 },
920 tlv: TlvSection::new_empty(),
921 };
922 let err = canonicalize_placeholder_indices(&mut d).unwrap_err();
923 assert!(matches!(
924 err,
925 Error::PlaceholderNotReferenced { idx: 1, n: 3 }
926 ));
927 }
928
929 /// Out-of-range `@N` reference is caught up-front with a typed error
930 /// rather than panicking inside the walker.
931 #[test]
932 fn out_of_range_placeholder_returns_error() {
933 let mut d = Descriptor {
934 n: 2,
935 path_decl: shared_path_decl(2),
936 use_site_path: no_multipath(),
937 tree: Node {
938 tag: Tag::Wpkh,
939 body: Body::KeyArg { index: 5 },
940 },
941 tlv: TlvSection::new_empty(),
942 };
943 let err = canonicalize_placeholder_indices(&mut d).unwrap_err();
944 assert!(matches!(
945 err,
946 Error::PlaceholderIndexOutOfRange { idx: 5, n: 2 }
947 ));
948 }
949
950 /// Idempotence: canonicalizing twice is a no-op after the first call.
951 #[test]
952 fn idempotence() {
953 let mut d = Descriptor {
954 n: 3,
955 path_decl: shared_path_decl(3),
956 use_site_path: no_multipath(),
957 tree: Node {
958 tag: Tag::SortedMulti,
959 body: Body::MultiKeys {
960 k: 2,
961 indices: vec![2, 0, 1],
962 },
963 },
964 tlv: {
965 let mut t = TlvSection::new_empty();
966 t.fingerprints = Some(vec![(0, [1; 4]), (1, [2; 4]), (2, [3; 4])]);
967 t
968 },
969 };
970 canonicalize_placeholder_indices(&mut d).unwrap();
971 let after_first = d.clone();
972 canonicalize_placeholder_indices(&mut d).unwrap();
973 assert_eq!(d, after_first);
974 }
975
976 /// Post-condition (1): every TLV map's idx column is strictly
977 /// ascending and `< d.n` after canonicalization.
978 #[test]
979 fn tlv_idx_post_condition() {
980 let mut d = Descriptor {
981 n: 3,
982 path_decl: shared_path_decl(3),
983 use_site_path: no_multipath(),
984 tree: Node {
985 tag: Tag::SortedMulti,
986 body: Body::MultiKeys {
987 k: 2,
988 indices: vec![2, 0, 1],
989 },
990 },
991 tlv: {
992 let mut t = TlvSection::new_empty();
993 t.fingerprints = Some(vec![(0, [1; 4]), (1, [2; 4]), (2, [3; 4])]);
994 t.pubkeys = Some(vec![(0, [0xaa; 65]), (1, [0xbb; 65]), (2, [0xcc; 65])]);
995 t
996 },
997 };
998 canonicalize_placeholder_indices(&mut d).unwrap();
999 assert!(tlv_indices_strictly_ascending_and_in_range(&d));
1000 }
1001
1002 /// Post-condition (2): the tree's first-occurrence sequence is
1003 /// exactly `[0, 1, ..., n-1]` after canonicalization.
1004 #[test]
1005 fn tree_first_occurrence_post_condition() {
1006 let mut d = Descriptor {
1007 n: 3,
1008 path_decl: shared_path_decl(3),
1009 use_site_path: no_multipath(),
1010 tree: Node {
1011 tag: Tag::SortedMulti,
1012 body: Body::MultiKeys {
1013 k: 2,
1014 indices: vec![2, 0, 1],
1015 },
1016 },
1017 tlv: TlvSection::new_empty(),
1018 };
1019 canonicalize_placeholder_indices(&mut d).unwrap();
1020 // The validator returning Ok(()) is the canonical post-condition.
1021 crate::validate::validate_placeholder_usage(&d.tree, d.n).unwrap();
1022 // Also walk explicitly to assert the literal sequence.
1023 let mut seen = vec![false; d.n as usize];
1024 let mut first = Vec::new();
1025 walk_collect_first(&d.tree, &mut seen, &mut first);
1026 assert_eq!(first, vec![0, 1, 2]);
1027 }
1028
1029 /// The encoder calls `canonicalize_placeholder_indices` internally,
1030 /// so a non-canonical input round-trips through encode/decode cleanly:
1031 /// the wire bytes are the canonical encoding, and the decoder accepts
1032 /// them without `PlaceholderFirstOccurrenceOutOfOrder`.
1033 #[test]
1034 fn encoder_canonicalizes_non_canonical_input() {
1035 let d = Descriptor {
1036 n: 2,
1037 path_decl: shared_path_decl(2),
1038 use_site_path: UseSitePath::standard_multipath(),
1039 tree: Node {
1040 tag: Tag::Wsh,
1041 body: Body::Children(vec![Node {
1042 tag: Tag::Multi,
1043 body: Body::MultiKeys {
1044 k: 2,
1045 // first-occurrence: @1 then @0 (non-canonical).
1046 indices: vec![1, 0],
1047 },
1048 }]),
1049 },
1050 tlv: TlvSection::new_empty(),
1051 };
1052 let (bytes, total_bits) =
1053 crate::encode::encode_payload(&d).expect("encoder must canonicalize and succeed");
1054 // Decoder rejects non-canonical first-occurrence ordering with
1055 // PlaceholderFirstOccurrenceOutOfOrder; if encoder didn't
1056 // canonicalize, this would fail.
1057 let decoded = crate::decode::decode_payload(&bytes, total_bits).expect("decode");
1058 // Decoded tree's first occurrence is canonical [0, 1].
1059 let mut seen = vec![false; decoded.n as usize];
1060 let mut first = Vec::new();
1061 walk_collect_first(&decoded.tree, &mut seen, &mut first);
1062 assert_eq!(first, vec![0, 1]);
1063 }
1064
1065 /// Post-condition (3): round-trip property — for hand-crafted
1066 /// permutations, `canonicalize → encode → decode → canonicalize`
1067 /// equals the canonicalize-only result. (Encode requires a fully
1068 /// well-formed descriptor, so this exercises the encoder path.)
1069 #[test]
1070 fn round_trip_canonicalize_encode_decode_canonicalize() {
1071 // 8 permutations of @0,@1,@2 inside sortedmulti(2, ...) plus
1072 // base canonical and one swap-pair → 10 total cases.
1073 let permutations: Vec<Vec<u8>> = vec![
1074 vec![0, 1, 2],
1075 vec![0, 2, 1],
1076 vec![1, 0, 2],
1077 vec![1, 2, 0],
1078 vec![2, 0, 1],
1079 vec![2, 1, 0],
1080 vec![1, 0, 1], // duplicate refs (re-uses @1 and @0; only first introduces)
1081 vec![2, 1, 0], // duplicate of above to give 8
1082 ];
1083 for perm in permutations {
1084 // n is the count of distinct placeholders in `perm`.
1085 let mut distinct: Vec<u8> = perm.clone();
1086 distinct.sort_unstable();
1087 distinct.dedup();
1088 let n = distinct.len() as u8;
1089 assert!(n >= 2, "test fixture expects ≥2 distinct placeholders");
1090 // Children are pk_k(@perm[i]) — but to match `n` we must use
1091 // exactly the `n` placeholders {0, 1, ..., n-1}; the
1092 // permutation `perm` already does that as long as `distinct`
1093 // == 0..n. Re-index if the permutation skipped any.
1094 let mut renumbered = perm.clone();
1095 // Build mapping: each distinct value gets the position of
1096 // its sorted occurrence as its label, ensuring the resulting
1097 // descriptor has placeholders 0..n exactly.
1098 let mut mapping = std::collections::HashMap::new();
1099 for (i, v) in distinct.iter().enumerate() {
1100 mapping.insert(*v, i as u8);
1101 }
1102 for v in renumbered.iter_mut() {
1103 *v = mapping[v];
1104 }
1105
1106 let indices: Vec<u8> = renumbered.clone();
1107 let n_children = indices.len();
1108 let k_value = std::cmp::min(2u8, n_children as u8);
1109 let mut d = Descriptor {
1110 n,
1111 path_decl: shared_path_decl(n),
1112 use_site_path: UseSitePath::standard_multipath(),
1113 tree: Node {
1114 tag: Tag::Wsh,
1115 body: Body::Children(vec![Node {
1116 tag: Tag::SortedMulti,
1117 body: Body::MultiKeys {
1118 k: k_value,
1119 indices,
1120 },
1121 }]),
1122 },
1123 tlv: TlvSection::new_empty(),
1124 };
1125 canonicalize_placeholder_indices(&mut d).unwrap();
1126 let canonical = d.clone();
1127
1128 // Encode → decode and confirm the result is already
1129 // canonical (decoder accepts it cleanly).
1130 let (bytes, total_bits) = crate::encode::encode_payload(&d).expect("encode");
1131 let decoded = crate::decode::decode_payload(&bytes, total_bits).expect("decode");
1132 let mut decoded_mut = decoded;
1133 canonicalize_placeholder_indices(&mut decoded_mut).unwrap();
1134 assert_eq!(canonical, decoded_mut);
1135 }
1136 }
1137}
1138
1139#[cfg(test)]
1140mod expand_tests {
1141 use super::*;
1142 use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
1143 use crate::tag::Tag;
1144 use crate::tlv::TlvSection;
1145 use crate::tree::{Body, Node};
1146 use crate::use_site_path::UseSitePath;
1147
1148 fn bip84() -> OriginPath {
1149 OriginPath {
1150 components: vec![
1151 PathComponent {
1152 hardened: true,
1153 value: 84,
1154 },
1155 PathComponent {
1156 hardened: true,
1157 value: 0,
1158 },
1159 PathComponent {
1160 hardened: true,
1161 value: 0,
1162 },
1163 ],
1164 }
1165 }
1166
1167 fn bip48_type_2() -> OriginPath {
1168 OriginPath {
1169 components: vec![
1170 PathComponent {
1171 hardened: true,
1172 value: 48,
1173 },
1174 PathComponent {
1175 hardened: true,
1176 value: 0,
1177 },
1178 PathComponent {
1179 hardened: true,
1180 value: 0,
1181 },
1182 PathComponent {
1183 hardened: true,
1184 value: 2,
1185 },
1186 ],
1187 }
1188 }
1189
1190 /// 1-of-1 wpkh, `path_decl: Shared(BIP84)`, no overrides → expanded
1191 /// `@0` has BIP-84 origin, descriptor-level use-site, no fp/xpub.
1192 #[test]
1193 fn expand_full_elision_canonical_wpkh() {
1194 let d = Descriptor {
1195 n: 1,
1196 path_decl: PathDecl {
1197 n: 1,
1198 paths: PathDeclPaths::Shared(bip84()),
1199 },
1200 use_site_path: UseSitePath::standard_multipath(),
1201 tree: Node {
1202 tag: Tag::Wpkh,
1203 body: Body::KeyArg { index: 0 },
1204 },
1205 tlv: TlvSection::new_empty(),
1206 };
1207 let expanded = expand_per_at_n(&d).unwrap();
1208 assert_eq!(expanded.len(), 1);
1209 assert_eq!(expanded[0].idx, 0);
1210 assert_eq!(expanded[0].origin_path, bip84());
1211 assert_eq!(expanded[0].use_site_path, UseSitePath::standard_multipath());
1212 assert!(expanded[0].fingerprint.is_none());
1213 assert!(expanded[0].xpub.is_none());
1214 }
1215
1216 /// 2-of-3 wsh-sortedmulti with `Shared(BIP48 type 2)` → all 3
1217 /// expanded keys have the same shared origin path.
1218 #[test]
1219 fn expand_full_elision_canonical_wsh_multi() {
1220 let d = Descriptor {
1221 n: 3,
1222 path_decl: PathDecl {
1223 n: 3,
1224 paths: PathDeclPaths::Shared(bip48_type_2()),
1225 },
1226 use_site_path: UseSitePath::standard_multipath(),
1227 tree: Node {
1228 tag: Tag::Wsh,
1229 body: Body::Children(vec![Node {
1230 tag: Tag::SortedMulti,
1231 body: Body::MultiKeys {
1232 k: 2,
1233 indices: vec![0, 1, 2],
1234 },
1235 }]),
1236 },
1237 tlv: TlvSection::new_empty(),
1238 };
1239 let expanded = expand_per_at_n(&d).unwrap();
1240 assert_eq!(expanded.len(), 3);
1241 for ek in &expanded {
1242 assert_eq!(ek.origin_path, bip48_type_2());
1243 assert_eq!(ek.use_site_path, UseSitePath::standard_multipath());
1244 assert!(ek.fingerprint.is_none());
1245 assert!(ek.xpub.is_none());
1246 }
1247 }
1248
1249 /// 2-of-3 with `OriginPathOverrides[1] = m/84'/0'/5'` (account 5):
1250 /// expanded `@1` gets the override; `@0` and `@2` use the shared
1251 /// `path_decl` baseline.
1252 #[test]
1253 fn expand_per_idx_override_mix() {
1254 let custom_path = OriginPath {
1255 components: vec![
1256 PathComponent {
1257 hardened: true,
1258 value: 84,
1259 },
1260 PathComponent {
1261 hardened: true,
1262 value: 0,
1263 },
1264 PathComponent {
1265 hardened: true,
1266 value: 5,
1267 },
1268 ],
1269 };
1270 let d = Descriptor {
1271 n: 3,
1272 path_decl: PathDecl {
1273 n: 3,
1274 paths: PathDeclPaths::Shared(bip48_type_2()),
1275 },
1276 use_site_path: UseSitePath::standard_multipath(),
1277 tree: Node {
1278 tag: Tag::Wsh,
1279 body: Body::Children(vec![Node {
1280 tag: Tag::SortedMulti,
1281 body: Body::MultiKeys {
1282 k: 2,
1283 indices: vec![0, 1, 2],
1284 },
1285 }]),
1286 },
1287 tlv: {
1288 let mut t = TlvSection::new_empty();
1289 t.origin_path_overrides = Some(vec![(1, custom_path.clone())]);
1290 t
1291 },
1292 };
1293 let expanded = expand_per_at_n(&d).unwrap();
1294 assert_eq!(expanded[0].origin_path, bip48_type_2());
1295 assert_eq!(expanded[1].origin_path, custom_path);
1296 assert_eq!(expanded[2].origin_path, bip48_type_2());
1297 }
1298
1299 /// 2-of-2 with `Divergent([path_a, path_b])` → expanded keys carry
1300 /// the per-`@N` divergent paths.
1301 #[test]
1302 fn expand_divergent_paths() {
1303 let path_a = OriginPath {
1304 components: vec![PathComponent {
1305 hardened: true,
1306 value: 84,
1307 }],
1308 };
1309 let path_b = OriginPath {
1310 components: vec![PathComponent {
1311 hardened: true,
1312 value: 86,
1313 }],
1314 };
1315 let d = Descriptor {
1316 n: 2,
1317 path_decl: PathDecl {
1318 n: 2,
1319 paths: PathDeclPaths::Divergent(vec![path_a.clone(), path_b.clone()]),
1320 },
1321 use_site_path: UseSitePath::standard_multipath(),
1322 tree: Node {
1323 tag: Tag::Wsh,
1324 body: Body::Children(vec![Node {
1325 tag: Tag::Multi,
1326 body: Body::MultiKeys {
1327 k: 2,
1328 indices: vec![0, 1],
1329 },
1330 }]),
1331 },
1332 tlv: TlvSection::new_empty(),
1333 };
1334 let expanded = expand_per_at_n(&d).unwrap();
1335 assert_eq!(expanded[0].origin_path, path_a);
1336 assert_eq!(expanded[1].origin_path, path_b);
1337 }
1338
1339 /// Descriptor with `UseSitePathOverrides[0] = custom` → `@0` has
1340 /// the override, `@1` uses `d.use_site_path`.
1341 #[test]
1342 fn expand_use_site_path_overrides() {
1343 let baseline = UseSitePath::standard_multipath();
1344 let custom = UseSitePath {
1345 multipath: None,
1346 wildcard_hardened: true,
1347 };
1348 let d = Descriptor {
1349 n: 2,
1350 path_decl: PathDecl {
1351 n: 2,
1352 paths: PathDeclPaths::Shared(bip48_type_2()),
1353 },
1354 use_site_path: baseline.clone(),
1355 tree: Node {
1356 tag: Tag::Wsh,
1357 body: Body::Children(vec![Node {
1358 tag: Tag::Multi,
1359 body: Body::MultiKeys {
1360 k: 2,
1361 indices: vec![0, 1],
1362 },
1363 }]),
1364 },
1365 tlv: {
1366 let mut t = TlvSection::new_empty();
1367 t.use_site_path_overrides = Some(vec![(0, custom.clone())]);
1368 t
1369 },
1370 };
1371 let expanded = expand_per_at_n(&d).unwrap();
1372 assert_eq!(expanded[0].use_site_path, custom);
1373 assert_eq!(expanded[1].use_site_path, baseline);
1374 }
1375
1376 /// 2-of-3 with sparse `Fingerprints[0]` and `Pubkeys[2]` → only
1377 /// those slots have `Some(...)`; others are `None`.
1378 #[test]
1379 fn expand_fingerprints_and_pubkeys() {
1380 let fp = [0xaa, 0xbb, 0xcc, 0xdd];
1381 let mut xpub = [0u8; 65];
1382 for (i, b) in xpub.iter_mut().enumerate() {
1383 *b = i as u8;
1384 }
1385 let d = Descriptor {
1386 n: 3,
1387 path_decl: PathDecl {
1388 n: 3,
1389 paths: PathDeclPaths::Shared(bip48_type_2()),
1390 },
1391 use_site_path: UseSitePath::standard_multipath(),
1392 tree: Node {
1393 tag: Tag::Wsh,
1394 body: Body::Children(vec![Node {
1395 tag: Tag::SortedMulti,
1396 body: Body::MultiKeys {
1397 k: 2,
1398 indices: vec![0, 1, 2],
1399 },
1400 }]),
1401 },
1402 tlv: {
1403 let mut t = TlvSection::new_empty();
1404 t.fingerprints = Some(vec![(0, fp)]);
1405 t.pubkeys = Some(vec![(2, xpub)]);
1406 t
1407 },
1408 };
1409 let expanded = expand_per_at_n(&d).unwrap();
1410 assert_eq!(expanded[0].fingerprint, Some(fp));
1411 assert!(expanded[1].fingerprint.is_none());
1412 assert!(expanded[2].fingerprint.is_none());
1413 assert!(expanded[0].xpub.is_none());
1414 assert!(expanded[1].xpub.is_none());
1415 assert_eq!(expanded[2].xpub, Some(xpub));
1416 }
1417
1418 /// `sh(sortedmulti(...))` with shared empty path AND no
1419 /// `OriginPathOverrides` → `MissingExplicitOrigin { idx: 0 }`.
1420 /// Construct an empty `OriginPath` (depth-0) to hit the structural
1421 /// edge case.
1422 #[test]
1423 fn expand_non_canonical_wrapper_without_overrides_errors() {
1424 let empty_path = OriginPath { components: vec![] };
1425 let d = Descriptor {
1426 n: 2,
1427 path_decl: PathDecl {
1428 n: 2,
1429 paths: PathDeclPaths::Shared(empty_path),
1430 },
1431 use_site_path: UseSitePath::standard_multipath(),
1432 tree: Node {
1433 tag: Tag::Sh,
1434 body: Body::Children(vec![Node {
1435 tag: Tag::SortedMulti,
1436 body: Body::MultiKeys {
1437 k: 2,
1438 indices: vec![0, 1],
1439 },
1440 }]),
1441 },
1442 tlv: TlvSection::new_empty(),
1443 };
1444 let err = expand_per_at_n(&d).unwrap_err();
1445 assert!(matches!(err, Error::MissingExplicitOrigin { idx: 0 }));
1446 }
1447
1448 /// P0.3 (I-1): an `OriginPathOverrides[idx]` entry that is PRESENT but
1449 /// zero-component must be rejected — NOT silently treated as "no
1450 /// override present" (which is what the pre-P0.3 `sparse_lookup(...)
1451 /// .is_none()` conjunct did, letting the empty override silently
1452 /// resolve to an empty origin). The shared `path_decl` here is
1453 /// POPULATED (non-empty) — under the OLD code neither the
1454 /// `MissingExplicitOrigin` condition (guarded by
1455 /// `sparse_lookup(...).is_none()`) nor anything else would have
1456 /// caught this: `expand_per_at_n` would have silently returned an
1457 /// empty `origin_path` for @0.
1458 #[test]
1459 fn expand_rejects_present_but_empty_origin_override() {
1460 let mut d = Descriptor {
1461 n: 2,
1462 path_decl: PathDecl {
1463 n: 2,
1464 paths: PathDeclPaths::Shared(bip48_type_2()),
1465 },
1466 use_site_path: UseSitePath::standard_multipath(),
1467 tree: Node {
1468 tag: Tag::Sh,
1469 body: Body::Children(vec![Node {
1470 tag: Tag::SortedMulti,
1471 body: Body::MultiKeys {
1472 k: 2,
1473 indices: vec![0, 1],
1474 },
1475 }]),
1476 },
1477 tlv: TlvSection::new_empty(),
1478 };
1479 d.tlv.origin_path_overrides = Some(vec![(0u8, OriginPath { components: vec![] })]);
1480 let err = expand_per_at_n(&d).unwrap_err();
1481 assert!(matches!(err, Error::EmptyOriginOverride { idx: 0 }));
1482 }
1483
1484 /// P0.3 (I-1a): the empty-override reject applies even to a
1485 /// CANONICAL-shape wrapper (`wpkh(@0)`) — `expand_per_at_n` doesn't
1486 /// special-case canonical shapes for this check (unlike the OLD
1487 /// `MissingExplicitOrigin` condition, which required `canonical_origin
1488 /// (&d.tree).is_none()`).
1489 #[test]
1490 fn expand_rejects_present_but_empty_origin_override_canonical_shape() {
1491 let mut d = Descriptor {
1492 n: 1,
1493 path_decl: PathDecl {
1494 n: 1,
1495 paths: PathDeclPaths::Shared(OriginPath { components: vec![] }),
1496 },
1497 use_site_path: UseSitePath::standard_multipath(),
1498 tree: Node {
1499 tag: Tag::Wpkh,
1500 body: Body::KeyArg { index: 0 },
1501 },
1502 tlv: TlvSection::new_empty(),
1503 };
1504 d.tlv.origin_path_overrides = Some(vec![(0u8, OriginPath { components: vec![] })]);
1505 let err = expand_per_at_n(&d).unwrap_err();
1506 assert!(matches!(err, Error::EmptyOriginOverride { idx: 0 }));
1507 }
1508
1509 /// Determinism: encode `wpkh(@0)` two ways — once with `Shared(BIP84)`
1510 /// in `path_decl` and no overrides; once with the same explicit path
1511 /// supplied as an override on top of an unrelated baseline — and the
1512 /// expansion is equal up to the origin_path. (The classic "elided vs
1513 /// explicit" determinism gate from the plan.)
1514 #[test]
1515 fn expand_determinism_across_elision() {
1516 // Wallet A: elided form. path_decl carries the canonical BIP-84.
1517 let d_elided = Descriptor {
1518 n: 1,
1519 path_decl: PathDecl {
1520 n: 1,
1521 paths: PathDeclPaths::Shared(bip84()),
1522 },
1523 use_site_path: UseSitePath::standard_multipath(),
1524 tree: Node {
1525 tag: Tag::Wpkh,
1526 body: Body::KeyArg { index: 0 },
1527 },
1528 tlv: TlvSection::new_empty(),
1529 };
1530 // Wallet B: explicit form. Same canonical BIP-84 path placed into
1531 // path_decl (Option A semantics — the encoder writes
1532 // canonical_origin into path_decl when no overrides supplied).
1533 let d_explicit = Descriptor {
1534 n: 1,
1535 path_decl: PathDecl {
1536 n: 1,
1537 paths: PathDeclPaths::Shared(bip84()),
1538 },
1539 use_site_path: UseSitePath::standard_multipath(),
1540 tree: Node {
1541 tag: Tag::Wpkh,
1542 body: Body::KeyArg { index: 0 },
1543 },
1544 tlv: TlvSection::new_empty(),
1545 };
1546 assert_eq!(
1547 expand_per_at_n(&d_elided).unwrap(),
1548 expand_per_at_n(&d_explicit).unwrap()
1549 );
1550 }
1551
1552 /// `tr(multi(2, @1, @0))` (non-canonical first-occurrence) →
1553 /// canonicalize permutes to `[0, 1]` and shifts the per-`@N` pubkeys
1554 /// in lockstep. After canonicalize+expand, expanded[0].xpub equals
1555 /// the xpub originally wired to `@1` (the now-canonical first slot).
1556 #[test]
1557 fn expand_after_canonicalize_uses_canonical_indices() {
1558 let xpub_a = [0xaa; 65];
1559 let xpub_b = [0xbb; 65];
1560 let mut d = Descriptor {
1561 n: 2,
1562 path_decl: PathDecl {
1563 n: 2,
1564 paths: PathDeclPaths::Shared(bip48_type_2()),
1565 },
1566 use_site_path: UseSitePath::standard_multipath(),
1567 tree: Node {
1568 tag: Tag::Multi,
1569 body: Body::MultiKeys {
1570 k: 2,
1571 // first-occurrence: @1 then @0 → perm[0]=1, perm[1]=0.
1572 indices: vec![1, 0],
1573 },
1574 },
1575 tlv: {
1576 let mut t = TlvSection::new_empty();
1577 // Wired-in: @0 → A, @1 → B.
1578 t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b)]);
1579 t
1580 },
1581 };
1582 canonicalize_placeholder_indices(&mut d).unwrap();
1583 let expanded = expand_per_at_n(&d).unwrap();
1584 // After permutation, original-@1 becomes new-@0, so expanded[0]
1585 // carries the xpub originally wired to @1 (= xpub_b).
1586 assert_eq!(expanded[0].xpub, Some(xpub_b));
1587 assert_eq!(expanded[1].xpub, Some(xpub_a));
1588 }
1589}