Skip to main content

md_codec/
canonicalize.rs

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