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