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