Skip to main content

md_codec/
validate.rs

1//! Decoder-side validation per spec §7.
2
3use crate::canonical_origin::canonical_origin;
4use crate::encode::Descriptor;
5use crate::error::Error;
6use crate::origin_path::PathDeclPaths;
7use crate::tag::Tag;
8use crate::tree::{Body, Node};
9use crate::use_site_path::UseSitePath;
10
11/// Validate the BIP 388 well-formedness of placeholder usage in the tree.
12///
13/// Enforces two invariants:
14/// 1. Every `@i` for `0 ≤ i < n` appears at least once in the tree.
15/// 2. The first occurrences (in pre-order traversal) of distinct placeholder
16///    indices appear in canonical ascending order: `@0` before `@1` before `@2`, etc.
17pub fn validate_placeholder_usage(root: &Node, n: u8) -> Result<(), Error> {
18    let mut seen = vec![false; n as usize];
19    let mut first_occurrences: Vec<u8> = Vec::new();
20    walk_for_placeholders(root, &mut seen, &mut first_occurrences)?;
21    // Each @i for 0 ≤ i < n must appear at least once.
22    for (i, was_seen) in seen.iter().enumerate() {
23        if !was_seen {
24            return Err(Error::PlaceholderNotReferenced { idx: i as u8, n });
25        }
26    }
27    // First occurrences must be in canonical ascending order.
28    for (pos, idx) in first_occurrences.iter().enumerate() {
29        if *idx as usize != pos {
30            return Err(Error::PlaceholderFirstOccurrenceOutOfOrder {
31                expected_first: pos as u8,
32                got_first: *idx,
33            });
34        }
35    }
36    Ok(())
37}
38
39fn walk_for_placeholders(
40    node: &Node,
41    seen: &mut [bool],
42    first_occurrences: &mut Vec<u8>,
43) -> Result<(), Error> {
44    match &node.body {
45        Body::KeyArg { index } => {
46            if (*index as usize) >= seen.len() {
47                return Err(Error::PlaceholderIndexOutOfRange {
48                    idx: *index,
49                    n: seen.len() as u8,
50                });
51            }
52            if !seen[*index as usize] {
53                seen[*index as usize] = true;
54                first_occurrences.push(*index);
55            }
56        }
57        Body::Children(children) => {
58            for c in children {
59                walk_for_placeholders(c, seen, first_occurrences)?;
60            }
61        }
62        Body::Variable { children, .. } => {
63            for c in children {
64                walk_for_placeholders(c, seen, first_occurrences)?;
65            }
66        }
67        Body::MultiKeys { indices, .. } => {
68            // v0.30 Phase C: multi-family bodies carry raw key indices instead
69            // of child Nodes. Same placeholder-usage semantics as KeyArg, per
70            // index.
71            for index in indices {
72                if (*index as usize) >= seen.len() {
73                    return Err(Error::PlaceholderIndexOutOfRange {
74                        idx: *index,
75                        n: seen.len() as u8,
76                    });
77                }
78                if !seen[*index as usize] {
79                    seen[*index as usize] = true;
80                    first_occurrences.push(*index);
81                }
82            }
83        }
84        Body::Tr {
85            is_nums,
86            key_index,
87            tree,
88        } => {
89            // SPEC v0.30 §7 + §11: when `is_nums = true` the internal key is
90            // the BIP-341 NUMS H-point (not a placeholder reference); skip
91            // registration. Otherwise `key_index` must be in `0..n`; out-of-
92            // range raises `NUMSSentinelConflict` per SPEC §11 (Phase G
93            // finalizes the variant's full doc-comment).
94            if !*is_nums {
95                if (*key_index as usize) >= seen.len() {
96                    return Err(Error::NUMSSentinelConflict);
97                }
98                if !seen[*key_index as usize] {
99                    seen[*key_index as usize] = true;
100                    first_occurrences.push(*key_index);
101                }
102            }
103            if let Some(t) = tree {
104                walk_for_placeholders(t, seen, first_occurrences)?;
105            }
106        }
107        Body::Hash256Body(_) | Body::Hash160Body(_) | Body::Timelock(_) | Body::Empty => {}
108    }
109    Ok(())
110}
111
112/// Validate that all multipaths in shared default + overrides share the same alt-count.
113///
114/// Per spec §7, when multiple `UseSitePath` entries (the shared default plus any
115/// per-`@N` overrides) carry a multipath group, all groups MUST have the same
116/// number of alternatives.
117pub fn validate_multipath_consistency(
118    shared: &UseSitePath,
119    overrides: &[(u8, UseSitePath)],
120) -> Result<(), Error> {
121    let mut seen_alt_count: Option<usize> = None;
122    let candidates = std::iter::once(shared).chain(overrides.iter().map(|(_, p)| p));
123    for path in candidates {
124        if let Some(alts) = &path.multipath {
125            match seen_alt_count {
126                None => seen_alt_count = Some(alts.len()),
127                Some(prev) if prev == alts.len() => {}
128                Some(prev) => {
129                    return Err(Error::MultipathAltCountMismatch {
130                        expected: prev,
131                        got: alts.len(),
132                    });
133                }
134            }
135        }
136    }
137    Ok(())
138}
139
140/// Validate that all leaves in a tap-script-tree are permitted-leaf tags per §6.3.1.
141pub fn validate_tap_script_tree(node: &Node) -> Result<(), Error> {
142    walk_tap_tree_leaves(node)
143}
144
145fn walk_tap_tree_leaves(node: &Node) -> Result<(), Error> {
146    if matches!(node.tag, Tag::TapTree) {
147        if let Body::Children(children) = &node.body {
148            for c in children {
149                walk_tap_tree_leaves(c)?;
150            }
151        }
152        Ok(())
153    } else {
154        // This is a leaf — validate per §6.3.1.
155        if is_forbidden_leaf_tag(node.tag) {
156            return Err(Error::ForbiddenTapTreeLeaf {
157                tag: node.tag.codes().0,
158            });
159        }
160        Ok(())
161    }
162}
163
164fn is_forbidden_leaf_tag(tag: Tag) -> bool {
165    matches!(
166        tag,
167        Tag::Wpkh | Tag::Tr | Tag::Wsh | Tag::Sh | Tag::Pkh | Tag::Multi | Tag::SortedMulti
168    )
169}
170
171/// Validate that every `@N` in a non-canonical wrapper has an explicit
172/// origin path on the wire — either via `OriginPathOverrides[idx]` or
173/// via a non-empty entry in the `path_decl` (shared or divergent).
174///
175/// Per spec v0.13 §6.3: when `canonical_origin(&d.tree)` is `None`, the
176/// wrapper is "non-canonical" and the encoder must emit an explicit
177/// origin for every `@N`. The decoder enforces the same as defense in
178/// depth: failure → `Error::MissingExplicitOrigin { idx }`.
179///
180/// If `canonical_origin(&d.tree)` is `Some(_)`, this validator is a
181/// no-op — any origin spec (elided or explicit) is allowed.
182pub fn validate_explicit_origin_required(d: &Descriptor) -> Result<(), Error> {
183    if canonical_origin(&d.tree).is_some() {
184        return Ok(());
185    }
186    let overrides = d.tlv.origin_path_overrides.as_deref().unwrap_or(&[]);
187    for idx in 0..d.n {
188        // Override path takes precedence — if present and non-empty, OK.
189        if let Some((_, op)) = overrides.iter().find(|(i, _)| *i == idx) {
190            if !op.components.is_empty() {
191                continue;
192            }
193        }
194        // Otherwise consult the path_decl for this idx.
195        let decl_components_empty = match &d.path_decl.paths {
196            PathDeclPaths::Shared(p) => p.components.is_empty(),
197            PathDeclPaths::Divergent(v) => v
198                .get(idx as usize)
199                .map(|p| p.components.is_empty())
200                .unwrap_or(true),
201        };
202        if decl_components_empty {
203            return Err(Error::MissingExplicitOrigin { idx });
204        }
205    }
206    Ok(())
207}
208
209/// Validate that every `Pubkeys` TLV entry's 33-byte compressed pubkey
210/// field (bytes 32..65 of the 65-byte payload) parses as a valid
211/// secp256k1 point. The 32-byte chain code prefix is unvalidated (any
212/// 32 bytes are a structurally valid BIP 32 chain code).
213///
214/// Per spec v0.13 §6.4: failure → `Error::InvalidXpubBytes { idx }`.
215/// When `d.tlv.pubkeys` is `None` (template-only mode), this is a no-op.
216pub fn validate_xpub_bytes(d: &Descriptor) -> Result<(), Error> {
217    let Some(entries) = d.tlv.pubkeys.as_deref() else {
218        return Ok(());
219    };
220    for (idx, xpub) in entries {
221        if bitcoin::secp256k1::PublicKey::from_slice(&xpub[32..65]).is_err() {
222            return Err(Error::InvalidXpubBytes { idx: *idx });
223        }
224    }
225    Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::tag::Tag;
232    use crate::tree::{Body, Node};
233
234    #[test]
235    fn placeholder_usage_ok_for_2_of_3() {
236        let root = Node {
237            tag: Tag::SortedMulti,
238            body: Body::MultiKeys {
239                k: 2,
240                indices: vec![0, 1, 2],
241            },
242        };
243        validate_placeholder_usage(&root, 3).unwrap();
244    }
245
246    #[test]
247    fn placeholder_usage_rejects_unreferenced() {
248        let root = Node {
249            tag: Tag::SortedMulti,
250            body: Body::MultiKeys {
251                k: 1,
252                indices: vec![0, 1],
253            },
254        };
255        assert!(matches!(
256            validate_placeholder_usage(&root, 3),
257            Err(Error::PlaceholderNotReferenced { idx: 2, n: 3 })
258        ));
259    }
260
261    #[test]
262    fn placeholder_usage_rejects_out_of_order_first_occurrences() {
263        let root = Node {
264            tag: Tag::SortedMulti,
265            body: Body::MultiKeys {
266                k: 1,
267                indices: vec![1, 0],
268            },
269        };
270        assert!(matches!(
271            validate_placeholder_usage(&root, 2),
272            Err(Error::PlaceholderFirstOccurrenceOutOfOrder { .. })
273        ));
274    }
275
276    #[test]
277    fn multipath_consistency_ok_when_all_match() {
278        let shared = UseSitePath::standard_multipath();
279        let overrides = vec![(1u8, UseSitePath::standard_multipath())];
280        validate_multipath_consistency(&shared, &overrides).unwrap();
281    }
282
283    #[test]
284    fn multipath_consistency_rejects_mismatched_alt_counts() {
285        use crate::use_site_path::Alternative;
286        let shared = UseSitePath::standard_multipath();
287        let overrides = vec![(
288            1u8,
289            UseSitePath {
290                multipath: Some(vec![
291                    Alternative {
292                        hardened: false,
293                        value: 0,
294                    },
295                    Alternative {
296                        hardened: false,
297                        value: 1,
298                    },
299                    Alternative {
300                        hardened: false,
301                        value: 2,
302                    },
303                ]),
304                wildcard_hardened: false,
305            },
306        )];
307        assert!(matches!(
308            validate_multipath_consistency(&shared, &overrides),
309            Err(Error::MultipathAltCountMismatch {
310                expected: 2,
311                got: 3
312            })
313        ));
314    }
315
316    #[test]
317    fn tap_tree_leaf_rejects_wsh() {
318        let leaf = Node {
319            tag: Tag::Wsh,
320            body: Body::Children(vec![]),
321        };
322        assert!(matches!(
323            validate_tap_script_tree(&leaf),
324            Err(Error::ForbiddenTapTreeLeaf { .. })
325        ));
326    }
327
328    #[test]
329    fn tap_tree_leaf_accepts_pk_k() {
330        let leaf = Node {
331            tag: Tag::PkK,
332            body: Body::KeyArg { index: 0 },
333        };
334        validate_tap_script_tree(&leaf).unwrap();
335    }
336
337    #[test]
338    fn placeholder_usage_rejects_index_out_of_range_n3() {
339        // n=3 → key_index_width=2 admits 0..=3 structurally. @3 is out of range.
340        let root = Node {
341            tag: Tag::Wpkh,
342            body: Body::KeyArg { index: 3 },
343        };
344        let err = validate_placeholder_usage(&root, 3).unwrap_err();
345        assert!(matches!(
346            err,
347            Error::PlaceholderIndexOutOfRange { idx: 3, n: 3 }
348        ));
349    }
350
351    #[test]
352    fn placeholder_usage_rejects_index_out_of_range_n5() {
353        // n=5 → key_index_width=3 admits 0..=7. @5..=7 are out of range.
354        let root = Node {
355            tag: Tag::SortedMulti,
356            body: Body::MultiKeys {
357                k: 1,
358                indices: vec![5],
359            },
360        };
361        let err = validate_placeholder_usage(&root, 5).unwrap_err();
362        assert!(matches!(
363            err,
364            Error::PlaceholderIndexOutOfRange { idx: 5, n: 5 }
365        ));
366    }
367
368    #[test]
369    fn placeholder_usage_rejects_index_out_of_range_n15() {
370        // n=15 → key_index_width=4 admits 0..=15. @15 just out of range.
371        let root = Node {
372            tag: Tag::SortedMulti,
373            body: Body::MultiKeys {
374                k: 1,
375                indices: vec![15],
376            },
377        };
378        let err = validate_placeholder_usage(&root, 15).unwrap_err();
379        assert!(matches!(
380            err,
381            Error::PlaceholderIndexOutOfRange { idx: 15, n: 15 }
382        ));
383    }
384
385    #[test]
386    fn placeholder_usage_rejects_out_of_range_in_tr_key_index() {
387        // SPEC v0.30 §7 + §11: `is_nums = false` with `key_index >= n` is a
388        // `NUMSSentinelConflict` (distinct from KeyArg's
389        // `PlaceholderIndexOutOfRange`; NUMS is signalled by `is_nums = true`
390        // with `key_index` unused on wire).
391        let root = Node {
392            tag: Tag::Tr,
393            body: Body::Tr {
394                is_nums: false,
395                key_index: 3,
396                tree: None,
397            },
398        };
399        let err = validate_placeholder_usage(&root, 3).unwrap_err();
400        assert!(matches!(err, Error::NUMSSentinelConflict));
401    }
402
403    #[test]
404    fn placeholder_usage_accepts_nums_flag_in_tr() {
405        // SPEC v0.30 §7: `is_nums = true` is the NUMS-H-point signal and
406        // MUST pass validation. validate_placeholder_usage requires every
407        // @i in 0..n to be referenced; the @0 reference here satisfies that
408        // for n=1.
409        let root = Node {
410            tag: Tag::Tr,
411            body: Body::Tr {
412                is_nums: true,
413                key_index: 0,
414                tree: Some(Box::new(Node {
415                    tag: Tag::PkK,
416                    body: Body::KeyArg { index: 0 },
417                })),
418            },
419        };
420        validate_placeholder_usage(&root, 1)
421            .expect("is_nums flag + @0 reference must validate under v0.30");
422    }
423}
424
425#[cfg(test)]
426mod explicit_origin_required_tests {
427    use super::*;
428    use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
429    use crate::tag::Tag;
430    use crate::tlv::TlvSection;
431    use crate::tree::{Body, Node};
432    use crate::use_site_path::UseSitePath;
433
434    fn empty_path() -> OriginPath {
435        OriginPath { components: vec![] }
436    }
437
438    fn bip84_path() -> OriginPath {
439        OriginPath {
440            components: vec![
441                PathComponent {
442                    hardened: true,
443                    value: 84,
444                },
445                PathComponent {
446                    hardened: true,
447                    value: 0,
448                },
449                PathComponent {
450                    hardened: true,
451                    value: 0,
452                },
453            ],
454        }
455    }
456
457    /// Build a single-key descriptor with `n=1`, the given tree root, an
458    /// empty shared path_decl (origin elided on wire), and an empty TLV
459    /// section.
460    fn single_key_descriptor(tree: Node) -> Descriptor {
461        Descriptor {
462            n: 1,
463            path_decl: PathDecl {
464                n: 1,
465                paths: PathDeclPaths::Shared(empty_path()),
466            },
467            use_site_path: UseSitePath::standard_multipath(),
468            tree,
469            tlv: TlvSection::new_empty(),
470        }
471    }
472
473    #[test]
474    fn validate_explicit_origin_required_passes_canonical_wpkh() {
475        // wpkh(@0) has canonical BIP-84 origin → empty path_decl OK.
476        let d = single_key_descriptor(Node {
477            tag: Tag::Wpkh,
478            body: Body::KeyArg { index: 0 },
479        });
480        validate_explicit_origin_required(&d).unwrap();
481    }
482
483    #[test]
484    fn validate_explicit_origin_required_passes_with_overrides_for_non_canonical() {
485        // sh(sortedmulti(@0,@1,@2)) — non-canonical. Must have explicit
486        // origin per @N. Provide overrides for all three.
487        let mut d = Descriptor {
488            n: 3,
489            path_decl: PathDecl {
490                n: 3,
491                paths: PathDeclPaths::Shared(empty_path()),
492            },
493            use_site_path: UseSitePath::standard_multipath(),
494            tree: Node {
495                tag: Tag::Sh,
496                body: Body::Children(vec![Node {
497                    tag: Tag::SortedMulti,
498                    body: Body::MultiKeys {
499                        k: 2,
500                        indices: vec![0, 1, 2],
501                    },
502                }]),
503            },
504            tlv: TlvSection::new_empty(),
505        };
506        d.tlv.origin_path_overrides = Some(vec![
507            (0u8, bip84_path()),
508            (1u8, bip84_path()),
509            (2u8, bip84_path()),
510        ]);
511        validate_explicit_origin_required(&d).unwrap();
512    }
513
514    #[test]
515    fn validate_explicit_origin_required_fails_sh_sortedmulti_with_empty_path_decl() {
516        // sh(sortedmulti(@0,@1,@2)) — non-canonical. Empty path_decl, no
517        // overrides → fails on idx=0.
518        let d = Descriptor {
519            n: 3,
520            path_decl: PathDecl {
521                n: 3,
522                paths: PathDeclPaths::Shared(empty_path()),
523            },
524            use_site_path: UseSitePath::standard_multipath(),
525            tree: Node {
526                tag: Tag::Sh,
527                body: Body::Children(vec![Node {
528                    tag: Tag::SortedMulti,
529                    body: Body::MultiKeys {
530                        k: 2,
531                        indices: vec![0, 1, 2],
532                    },
533                }]),
534            },
535            tlv: TlvSection::new_empty(),
536        };
537        let err = validate_explicit_origin_required(&d).unwrap_err();
538        assert!(matches!(err, Error::MissingExplicitOrigin { idx: 0 }));
539    }
540
541    #[test]
542    fn validate_explicit_origin_required_fails_bare_wsh_with_empty_path_decl() {
543        // bare wsh(@0) — non-canonical (no `multi`/`sortedmulti` inner).
544        let d = single_key_descriptor(Node {
545            tag: Tag::Wsh,
546            body: Body::Children(vec![Node {
547                tag: Tag::PkK,
548                body: Body::KeyArg { index: 0 },
549            }]),
550        });
551        let err = validate_explicit_origin_required(&d).unwrap_err();
552        assert!(matches!(err, Error::MissingExplicitOrigin { idx: 0 }));
553    }
554
555    #[test]
556    fn validate_explicit_origin_required_passes_tr_keypath_only_with_empty_path_decl() {
557        // tr(@0) key-path only → BIP 86 canonical exists → empty path_decl OK.
558        let d = single_key_descriptor(Node {
559            tag: Tag::Tr,
560            body: Body::Tr {
561                is_nums: false,
562                key_index: 0,
563                tree: None,
564            },
565        });
566        validate_explicit_origin_required(&d).unwrap();
567    }
568
569    #[test]
570    fn validate_explicit_origin_required_fails_tr_with_taptree_with_empty_path_decl() {
571        // tr(@0, TapTree) → no canonical → must be explicit.
572        let d = single_key_descriptor(Node {
573            tag: Tag::Tr,
574            body: Body::Tr {
575                is_nums: false,
576                key_index: 0,
577                tree: Some(Box::new(Node {
578                    tag: Tag::PkK,
579                    body: Body::KeyArg { index: 0 },
580                })),
581            },
582        });
583        let err = validate_explicit_origin_required(&d).unwrap_err();
584        assert!(matches!(err, Error::MissingExplicitOrigin { idx: 0 }));
585    }
586
587    #[test]
588    fn validate_explicit_origin_required_passes_with_populated_shared_path_decl() {
589        // Bare wsh(@0) with a populated shared path_decl — explicit origin
590        // is on the wire via path_decl, so the validator is satisfied even
591        // without an OriginPathOverrides entry.
592        let mut d = single_key_descriptor(Node {
593            tag: Tag::Wsh,
594            body: Body::Children(vec![Node {
595                tag: Tag::PkK,
596                body: Body::KeyArg { index: 0 },
597            }]),
598        });
599        d.path_decl.paths = PathDeclPaths::Shared(bip84_path());
600        validate_explicit_origin_required(&d).unwrap();
601    }
602
603    #[test]
604    fn validate_explicit_origin_required_passes_divergent_when_all_populated() {
605        // sh(sortedmulti(...)) with divergent path_decl, all entries populated.
606        let d = Descriptor {
607            n: 2,
608            path_decl: PathDecl {
609                n: 2,
610                paths: PathDeclPaths::Divergent(vec![bip84_path(), bip84_path()]),
611            },
612            use_site_path: UseSitePath::standard_multipath(),
613            tree: Node {
614                tag: Tag::Sh,
615                body: Body::Children(vec![Node {
616                    tag: Tag::SortedMulti,
617                    body: Body::MultiKeys {
618                        k: 1,
619                        indices: vec![0, 1],
620                    },
621                }]),
622            },
623            tlv: TlvSection::new_empty(),
624        };
625        validate_explicit_origin_required(&d).unwrap();
626    }
627
628    #[test]
629    fn validate_explicit_origin_required_fails_divergent_when_one_idx_empty() {
630        // sh(sortedmulti(...)) with divergent path_decl; @1 has empty path,
631        // no override → fails on idx=1.
632        let d = Descriptor {
633            n: 2,
634            path_decl: PathDecl {
635                n: 2,
636                paths: PathDeclPaths::Divergent(vec![bip84_path(), empty_path()]),
637            },
638            use_site_path: UseSitePath::standard_multipath(),
639            tree: Node {
640                tag: Tag::Sh,
641                body: Body::Children(vec![Node {
642                    tag: Tag::SortedMulti,
643                    body: Body::MultiKeys {
644                        k: 1,
645                        indices: vec![0, 1],
646                    },
647                }]),
648            },
649            tlv: TlvSection::new_empty(),
650        };
651        let err = validate_explicit_origin_required(&d).unwrap_err();
652        assert!(matches!(err, Error::MissingExplicitOrigin { idx: 1 }));
653    }
654}
655
656#[cfg(test)]
657mod xpub_bytes_tests {
658    use super::*;
659    use crate::origin_path::{OriginPath, PathDecl, PathDeclPaths};
660    use crate::tag::Tag;
661    use crate::tlv::TlvSection;
662    use crate::tree::{Body, Node};
663    use crate::use_site_path::UseSitePath;
664
665    /// G (the secp256k1 generator) compressed: 0x02 || x(G).
666    /// Used for "valid pubkey" tests.
667    fn valid_compressed_g() -> [u8; 33] {
668        // x(G) = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
669        let mut out = [0u8; 33];
670        out[0] = 0x02;
671        let x: [u8; 32] = [
672            0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87,
673            0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B,
674            0x16, 0xF8, 0x17, 0x98,
675        ];
676        out[1..].copy_from_slice(&x);
677        out
678    }
679
680    fn descriptor_with_pubkeys(pks: Option<Vec<(u8, [u8; 65])>>) -> Descriptor {
681        let mut d = Descriptor {
682            n: 1,
683            path_decl: PathDecl {
684                n: 1,
685                paths: PathDeclPaths::Shared(OriginPath { components: vec![] }),
686            },
687            use_site_path: UseSitePath::standard_multipath(),
688            tree: Node {
689                tag: Tag::Wpkh,
690                body: Body::KeyArg { index: 0 },
691            },
692            tlv: TlvSection::new_empty(),
693        };
694        d.tlv.pubkeys = pks;
695        d
696    }
697
698    #[test]
699    fn validate_xpub_bytes_template_only_no_op() {
700        let d = descriptor_with_pubkeys(None);
701        validate_xpub_bytes(&d).unwrap();
702    }
703
704    #[test]
705    fn validate_xpub_bytes_passes_for_valid_compressed_pubkey() {
706        let mut xpub = [0u8; 65];
707        // Chain code 0..32 — arbitrary 32 bytes are valid.
708        for (i, b) in xpub[0..32].iter_mut().enumerate() {
709            *b = i as u8;
710        }
711        // Compressed pubkey 32..65 = G.
712        xpub[32..65].copy_from_slice(&valid_compressed_g());
713        let d = descriptor_with_pubkeys(Some(vec![(0u8, xpub)]));
714        validate_xpub_bytes(&d).unwrap();
715    }
716
717    #[test]
718    fn validate_xpub_bytes_fails_for_invalid_pubkey_prefix() {
719        // Prefix 0x04 is uncompressed-marker; not a valid 33-byte compressed
720        // pubkey prefix (only 0x02 / 0x03 are).
721        let mut xpub = [0u8; 65];
722        xpub[32] = 0x04;
723        let d = descriptor_with_pubkeys(Some(vec![(0u8, xpub)]));
724        let err = validate_xpub_bytes(&d).unwrap_err();
725        assert!(matches!(err, Error::InvalidXpubBytes { idx: 0 }));
726    }
727
728    #[test]
729    fn validate_xpub_bytes_fails_for_off_curve_x_coordinate() {
730        // 0x02 || all-0xFF x-coord. x = p-1 wraps to a non-curve x in
731        // most cases; in particular this exact value fails to lift in
732        // libsecp256k1's compressed-point parser. Verify via the same
733        // routine the validator uses.
734        let mut xpub = [0u8; 65];
735        xpub[32] = 0x02;
736        for b in xpub[33..65].iter_mut() {
737            *b = 0xFF;
738        }
739        // Sanity: confirm bitcoin's parser actually rejects this, so the
740        // test exercises the failure path in our validator.
741        assert!(bitcoin::secp256k1::PublicKey::from_slice(&xpub[32..65]).is_err());
742        let d = descriptor_with_pubkeys(Some(vec![(0u8, xpub)]));
743        let err = validate_xpub_bytes(&d).unwrap_err();
744        assert!(matches!(err, Error::InvalidXpubBytes { idx: 0 }));
745    }
746
747    #[test]
748    fn validate_xpub_bytes_reports_first_failing_idx() {
749        // Two entries: idx=0 valid, idx=2 invalid → error reports idx=2.
750        let mut good = [0u8; 65];
751        good[32..65].copy_from_slice(&valid_compressed_g());
752        let mut bad = [0u8; 65];
753        bad[32] = 0x04; // invalid prefix
754        let d = descriptor_with_pubkeys(Some(vec![(0u8, good), (2u8, bad)]));
755        let err = validate_xpub_bytes(&d).unwrap_err();
756        assert!(matches!(err, Error::InvalidXpubBytes { idx: 2 }));
757    }
758}