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