md-codec 0.35.0

Reference implementation of the Mnemonic Descriptor (MD) format for engravable BIP 388 wallet policy backups
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
//! Identity computation per spec §8.

use crate::bitstream::{BitWriter, re_emit_bits};
use crate::canonicalize::{canonicalize_placeholder_indices, expand_per_at_n};
use crate::encode::{Descriptor, encode_payload};
use crate::error::Error;
use crate::phrase::Phrase;
use crate::varint::write_varint;
use bitcoin::hashes::{Hash, sha256};

/// 128-bit canonical identifier for an md1 encoding (spec §8).
///
/// Computed as the first 16 bytes of `SHA-256` over the canonical
/// bit-packed payload bytes produced by [`encode_payload`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Md1EncodingId([u8; 16]);

impl Md1EncodingId {
    /// Construct from a raw 16-byte array.
    pub fn new(bytes: [u8; 16]) -> Self {
        Self(bytes)
    }

    /// Borrow the underlying 16-byte identifier.
    pub fn as_bytes(&self) -> &[u8; 16] {
        &self.0
    }

    /// Return the 4-byte fingerprint (first 4 bytes of the id).
    pub fn fingerprint(&self) -> [u8; 4] {
        let mut fp = [0u8; 4];
        fp.copy_from_slice(&self.0[0..4]);
        fp
    }
}

/// Compute the [`Md1EncodingId`] for a descriptor by hashing its canonical
/// bit-packed payload encoding (spec §8).
pub fn compute_md1_encoding_id(d: &Descriptor) -> Result<Md1EncodingId, Error> {
    let (bytes, _bit_len) = encode_payload(d)?;
    let hash = sha256::Hash::hash(&bytes);
    let mut id = [0u8; 16];
    id.copy_from_slice(&hash.to_byte_array()[0..16]);
    Ok(Md1EncodingId(id))
}

/// 128-bit BIP 388 wallet-descriptor-template identifier (spec §8.1, γ-flavor).
///
/// Hashes ONLY the BIP 388 template content: use-site-path-decl bits, tree
/// bits, and the `UseSitePathOverrides` TLV entry bits when present. Excludes
/// the header, origin-path-decl, `Fingerprints` TLV, HRP, and BCH checksum,
/// so it is invariant to origin-path changes (e.g. account index) and to
/// fingerprint additions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WalletDescriptorTemplateId([u8; 16]);

impl WalletDescriptorTemplateId {
    /// Construct from a raw 16-byte array.
    pub fn new(bytes: [u8; 16]) -> Self {
        Self(bytes)
    }

    /// Borrow the underlying 16-byte identifier.
    pub fn as_bytes(&self) -> &[u8; 16] {
        &self.0
    }
}

/// Compute the [`WalletDescriptorTemplateId`] for a descriptor by hashing only
/// the BIP 388 template content per spec §8.1.
pub fn compute_wallet_descriptor_template_id(
    d: &Descriptor,
) -> Result<WalletDescriptorTemplateId, Error> {
    let mut w = BitWriter::new();
    // Per spec §8.1: use-site-path-decl bits || tree bits || UseSitePathOverrides TLV bits
    let kiw = d.key_index_width();
    d.use_site_path.write(&mut w)?;
    crate::tree::write_node(&mut w, &d.tree, kiw)?;
    if let Some(overrides) = &d.tlv.use_site_path_overrides {
        // Re-encode the UseSitePathOverrides TLV ENTRY (tag + length + payload).
        let mut sub = BitWriter::new();
        for (idx, path) in overrides {
            sub.write_bits(u64::from(*idx), kiw as usize);
            path.write(&mut sub)?;
        }
        let bit_len = sub.bit_len();
        w.write_bits(u64::from(crate::tlv::TLV_USE_SITE_PATH_OVERRIDES), 5);
        crate::varint::write_varint(&mut w, bit_len as u32)?;
        let payload = sub.into_bytes();
        let mut subr = crate::bitstream::BitReader::new(&payload);
        let mut remaining = bit_len;
        while remaining > 0 {
            let chunk = remaining.min(8);
            let bits = subr.read_bits(chunk)?;
            w.write_bits(bits, chunk);
            remaining -= chunk;
        }
    }
    let bytes = w.into_bytes();
    let hash = sha256::Hash::hash(&bytes);
    let mut id = [0u8; 16];
    id.copy_from_slice(&hash.to_byte_array()[0..16]);
    Ok(WalletDescriptorTemplateId(id))
}

/// 128-bit canonical wallet-policy identifier (spec v0.13 §5.3).
///
/// Hashes the canonical-expanded BIP 388 wallet *policy* — template tree
/// plus per-`@N` origin / use-site / fp / xpub records — so that two
/// engravings of the same logical wallet produce identical IDs whether
/// they elide canonical paths or write them out explicitly. Stable
/// across origin- and use-site-elision; presence-significant on
/// fingerprint and xpub axes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WalletPolicyId([u8; 16]);

impl WalletPolicyId {
    /// Construct from a raw 16-byte array.
    pub fn new(bytes: [u8; 16]) -> Self {
        Self(bytes)
    }

    /// Borrow the underlying 16-byte identifier.
    pub fn as_bytes(&self) -> &[u8; 16] {
        &self.0
    }

    /// Render this identifier as a 12-word BIP 39 phrase (spec §8.4).
    pub fn to_phrase(&self) -> Result<Phrase, Error> {
        Phrase::from_id_bytes(self.as_bytes())
    }
}

/// Compute the [`WalletPolicyId`] for a descriptor by hashing its
/// canonical-expanded wallet-policy preimage per spec v0.13 §5.3.
///
/// Construction (byte-exact, no encoder divergence):
///
/// 1. Canonicalize placeholder indices on a clone of `d` (Phase 3a) —
///    callers don't need to remember the precondition.
/// 2. Compute `canonical_template_tree_bytes` by writing the
///    placeholder-form tree via [`crate::tree::write_node`] into a fresh
///    [`BitWriter`] and finalizing (zero-pad to whole-byte boundary).
/// 3. Expand to per-`@N` records via [`expand_per_at_n`] (Phase 3b).
/// 4. For each record (idx-ascending), allocate a fresh `BitWriter`,
///    write `path_bit_len` (LP4-ext varint, in *bits*), then re-emit
///    the path's bits MSB-first via [`re_emit_bits`]; same for the
///    use-site path. Finalize the bitstream — single byte-boundary pad.
/// 5. Build `presence_byte = (fp_present | (xpub_present << 1)) &
///    0b0000_0011` (explicit reserved-bit mask) and concatenate
///    `presence_byte || record_bytes || fp? || xpub?`.
/// 6. Hash input = `canonical_template_tree_bytes || concat(records)`.
/// 7. Return `SHA-256(input)[0..16]`.
///
/// # Errors
///
/// Propagates [`Error::MissingExplicitOrigin`] from [`expand_per_at_n`]
/// for non-canonical wrappers without an explicit origin path; other
/// canonicalization or encoding errors as appropriate.
///
/// # INVARIANT (Option A, spec v0.13 §3 + §5.3)
///
/// `path_decl.paths` is always populated post-decode (v0.11 wire
/// invariant). Canonical-fill into `path_decl` happens at encode time
/// only (per spec §6.3). Consequently this function does NOT consult
/// [`crate::canonical_origin::canonical_origin`] for path resolution at
/// hash time — it reads `OriginPathOverrides[idx]` if present, else
/// `path_decl.paths` resolved per the divergent_paths flag, via
/// [`expand_per_at_n`]. Any future change that elides `path_decl` on
/// the wire requires re-introducing `canonical_origin` lookups in both
/// this function and [`expand_per_at_n`].
pub fn compute_wallet_policy_id(d: &Descriptor) -> Result<WalletPolicyId, Error> {
    // Step 1: canonicalize on a clone so callers don't have to remember
    // the precondition and we never mutate the caller's descriptor.
    let mut d_canonical = d.clone();
    canonicalize_placeholder_indices(&mut d_canonical)?;
    let d = &d_canonical;

    // Step 2: canonical_template_tree_bytes — placeholder-form tree only.
    let mut tree_w = BitWriter::new();
    crate::tree::write_node(&mut tree_w, &d.tree, d.key_index_width())?;
    let canonical_template_tree_bytes = tree_w.into_bytes();

    // Step 3: expand to per-@N records.
    let expanded = expand_per_at_n(d)?;

    // Step 4–5: build each canonical record and concatenate.
    let mut records_concat: Vec<u8> = Vec::new();
    for e in &expanded {
        // Origin path bits (scratch BitWriter; bit_len() captures unpadded
        // length, into_bytes() zero-pads to the next byte boundary).
        let mut path_scratch = BitWriter::new();
        e.origin_path.write(&mut path_scratch)?;
        let path_bit_len = path_scratch.bit_len();
        let path_bytes = path_scratch.into_bytes();

        // Use-site path bits.
        let mut us_scratch = BitWriter::new();
        e.use_site_path.write(&mut us_scratch)?;
        let use_site_bit_len = us_scratch.bit_len();
        let us_bytes = us_scratch.into_bytes();

        // Record bitstream: varint(path_bit_len) || path_bits ||
        // varint(use_site_bit_len) || use_site_bits, with a single
        // byte-boundary pad applied by into_bytes().
        let mut record_bw = BitWriter::new();
        write_varint(&mut record_bw, path_bit_len as u32)?;
        re_emit_bits(&mut record_bw, &path_bytes, path_bit_len)?;
        write_varint(&mut record_bw, use_site_bit_len as u32)?;
        re_emit_bits(&mut record_bw, &us_bytes, use_site_bit_len)?;
        let record_bytes = record_bw.into_bytes();

        // Presence byte: bit 0 = fp, bit 1 = xpub; reserved bits 2..7
        // are explicitly masked to 0 per spec §5.3 (forward-compat:
        // future versions that define a reserved bit must not collide
        // with v0.13's hash on the same wire).
        let fp_present = e.fingerprint.is_some();
        let xpub_present = e.xpub.is_some();
        let presence_byte = ((fp_present as u8) | ((xpub_present as u8) << 1)) & 0b0000_0011;

        records_concat.push(presence_byte);
        records_concat.extend_from_slice(&record_bytes);
        if let Some(fp) = e.fingerprint {
            records_concat.extend_from_slice(&fp);
        }
        if let Some(xpub) = e.xpub {
            records_concat.extend_from_slice(&xpub);
        }
    }

    // Step 6–7: hash and truncate.
    let mut hash_input: Vec<u8> =
        Vec::with_capacity(canonical_template_tree_bytes.len() + records_concat.len());
    hash_input.extend_from_slice(&canonical_template_tree_bytes);
    hash_input.extend_from_slice(&records_concat);
    let hash = sha256::Hash::hash(&hash_input);
    let mut id = [0u8; 16];
    id.copy_from_slice(&hash.to_byte_array()[0..16]);
    Ok(WalletPolicyId(id))
}

/// Validate a `presence_byte` from a `WalletPolicyId` canonical-record
/// preimage (spec v0.13 §5.3). Bit 0 = `fp_present`, bit 1 =
/// `xpub_present`, bits 2..7 reserved (must be 0). Returns
/// [`Error::InvalidPresenceByte`] with the offending reserved-bit
/// field if any of bits 2..7 is set.
///
/// v0.13's encoder masks reserved bits when building the preimage, so
/// this helper is unreachable on v0.13 wire today. It enforces the
/// spec §5.3 "decoders MUST reject" clause for any future
/// canonical-record consumer (e.g., a verification-mode tool that
/// reconstructs the preimage to cross-check a `WalletPolicyId`).
pub fn validate_presence_byte(byte: u8) -> Result<(), Error> {
    let reserved_bits = byte & 0b1111_1100;
    if reserved_bits != 0 {
        return Err(Error::InvalidPresenceByte { reserved_bits });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
    use crate::tag::Tag;
    use crate::tlv::TlvSection;
    use crate::tree::{Body, Node};
    use crate::use_site_path::UseSitePath;

    fn bip84_descriptor() -> Descriptor {
        Descriptor {
            n: 1,
            path_decl: PathDecl {
                n: 1,
                paths: PathDeclPaths::Shared(OriginPath {
                    components: vec![
                        PathComponent {
                            hardened: true,
                            value: 84,
                        },
                        PathComponent {
                            hardened: true,
                            value: 0,
                        },
                        PathComponent {
                            hardened: true,
                            value: 0,
                        },
                    ],
                }),
            },
            use_site_path: UseSitePath::standard_multipath(),
            tree: Node {
                tag: Tag::Wpkh,
                body: Body::KeyArg { index: 0 },
            },
            tlv: TlvSection::new_empty(),
        }
    }

    #[test]
    fn md1_encoding_id_deterministic() {
        let d = bip84_descriptor();
        let id1 = compute_md1_encoding_id(&d).unwrap();
        let id2 = compute_md1_encoding_id(&d).unwrap();
        assert_eq!(id1, id2);
    }

    #[test]
    fn md1_encoding_id_differs_for_different_paths() {
        let d1 = bip84_descriptor();
        let mut d2 = bip84_descriptor();
        if let PathDeclPaths::Shared(p) = &mut d2.path_decl.paths {
            p.components[2] = PathComponent {
                hardened: true,
                value: 1,
            };
        }
        let id1 = compute_md1_encoding_id(&d1).unwrap();
        let id2 = compute_md1_encoding_id(&d2).unwrap();
        assert_ne!(id1, id2);
    }

    #[test]
    fn wdt_id_invariant_to_origin_path_change() {
        let d1 = bip84_descriptor();
        let mut d2 = bip84_descriptor();
        if let PathDeclPaths::Shared(p) = &mut d2.path_decl.paths {
            p.components[2] = PathComponent {
                hardened: true,
                value: 1,
            };
        }
        let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
        let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
        // Same template structure (use-site path, tree) → same WDT-Id
        assert_eq!(id1, id2);
    }

    #[test]
    fn wdt_id_differs_for_different_use_site_paths() {
        let d1 = bip84_descriptor();
        let mut d2 = bip84_descriptor();
        d2.use_site_path = UseSitePath {
            multipath: None,
            wildcard_hardened: false,
        };
        let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
        let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
        assert_ne!(id1, id2);
    }

    #[test]
    fn wdt_id_invariant_to_fingerprint_addition() {
        let d1 = bip84_descriptor();
        let mut d2 = bip84_descriptor();
        d2.tlv.fingerprints = Some(vec![(0u8, [0xaa, 0xbb, 0xcc, 0xdd])]);
        let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
        let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
        // Fingerprints are excluded from WDT-Id hash domain
        assert_eq!(id1, id2);
    }

    // ---- v0.13 WalletPolicyId tests ----

    /// Build a deterministic 65-byte xpub for tests: 32 bytes of `0x11`
    /// (chain code) followed by `0x02 || [0x22; 32]` (compressed pubkey
    /// with even Y prefix). The pubkey bytes are NOT a valid secp256k1
    /// point; tests that exercise §6.4 (`InvalidXpubBytes`) will use a
    /// real point. Phase 4 only hashes raw bytes.
    fn deterministic_xpub() -> [u8; 65] {
        let mut x = [0u8; 65];
        for b in x.iter_mut().take(32) {
            *b = 0x11;
        }
        x[32] = 0x02;
        for b in x.iter_mut().skip(33) {
            *b = 0x22;
        }
        x
    }

    /// Construct the dominant case: 1-of-1 cell-7 wpkh wallet with fp
    /// 0xDEADBEEF and a deterministic xpub at canonical BIP 84 origin.
    fn cell_7_wpkh_descriptor() -> Descriptor {
        Descriptor {
            n: 1,
            path_decl: PathDecl {
                n: 1,
                paths: PathDeclPaths::Shared(OriginPath {
                    components: vec![
                        PathComponent {
                            hardened: true,
                            value: 84,
                        },
                        PathComponent {
                            hardened: true,
                            value: 0,
                        },
                        PathComponent {
                            hardened: true,
                            value: 0,
                        },
                    ],
                }),
            },
            use_site_path: UseSitePath::standard_multipath(),
            tree: Node {
                tag: Tag::Wpkh,
                body: Body::KeyArg { index: 0 },
            },
            tlv: {
                let mut t = TlvSection::new_empty();
                t.fingerprints = Some(vec![(0u8, [0xDE, 0xAD, 0xBE, 0xEF])]);
                t.pubkeys = Some(vec![(0u8, deterministic_xpub())]);
                t
            },
        }
    }

    /// **GOLDEN VECTOR** (load-bearing): byte-exact construction of the
    /// 1-of-1 cell-7 wpkh `WalletPolicyId` preimage and SHA-256 truncation.
    ///
    /// Component bit budget (hand-derived; locks LP4-ext varint unit
    /// semantics — lengths are in bits, not bytes):
    ///
    /// ```text
    /// canonical_template_tree:
    ///   Tag::Wpkh primary code 0x00 (5 bits)         = 5 bits
    ///   KeyArg index @0  (kiw=0 since n=1)            = 0 bits
    ///   --------------------------------------------------
    ///   total                                          = 5 bits
    ///   into_bytes() zero-pads to 1 byte              = 0x00
    ///
    /// origin path m/84'/0'/0':
    ///   depth=3       (4 bits)                        =  4
    ///   84'  hardened(1) + varint(84)  = 1 + (4 + 7)  = 12
    ///   0'   hardened(1) + varint(0)   = 1 + (4 + 0)  =  5
    ///   0'   hardened(1) + varint(0)   = 1 + (4 + 0)  =  5
    ///   ------------------------------------------------
    ///   total                                          = 26 bits
    ///
    /// use-site <0;1>/*:
    ///   has-mp=1 (1) + alt_count-2=0 (3)              =  4
    ///   alt0: hardened=0 (1) + varint(0)=4            =  5
    ///   alt1: hardened=0 (1) + varint(1)=5            =  6
    ///   wildcard_hardened=0 (1)                        =  1
    ///   ------------------------------------------------
    ///   total                                          = 16 bits
    ///
    /// record_bw bits:
    ///   varint(26): L=5 (4 bits) + 5-bit payload      =  9
    ///   path bits  (re-emitted)                       = 26
    ///   varint(16): L=5 (4 bits) + 5-bit payload      =  9
    ///   use-site bits (re-emitted)                    = 16
    ///   ------------------------------------------------
    ///   total                                          = 60 bits
    ///   into_bytes() zero-pads to 8 bytes (64 bits)
    ///
    /// presence_byte = (1 | 1<<1) & 0b11 = 0x03
    /// fp = [DE, AD, BE, EF] (4 bytes)
    /// xpub = [11; 32] || 02 || [22; 32]  (65 bytes)
    /// record total =  1 + 8 + 4 + 65 = 78 bytes
    /// hash_input  = canonical_template_tree(1) || record(78) = 79 bytes
    /// ```
    ///
    /// Expected bytes computed independently in `/tmp/golden_vec.py`.
    #[test]
    fn golden_vector_wpkh_cell_7() {
        let d = cell_7_wpkh_descriptor();

        // Independently re-construct the canonical bitstream so the
        // arithmetic assertion (LP4-ext varint unit confusion gate) is
        // checked against locally-computed lengths. We mirror the
        // implementation's component writes here so a unit-confusion
        // bug surfaces in the assertion below before SHA-256 swallows
        // it.
        let path = match &d.path_decl.paths {
            PathDeclPaths::Shared(p) => p.clone(),
            _ => panic!("test fixture is shared"),
        };
        let mut path_scratch = crate::bitstream::BitWriter::new();
        path.write(&mut path_scratch).unwrap();
        let path_bit_len = path_scratch.bit_len();
        let path_bytes = path_scratch.into_bytes();
        assert_eq!(path_bit_len, 26, "BIP-84 origin path is 26 bits");
        assert_eq!(path_bytes, vec![0x3b, 0xd4, 0x84, 0x00]);

        let mut us_scratch = crate::bitstream::BitWriter::new();
        d.use_site_path.write(&mut us_scratch).unwrap();
        let use_site_bit_len = us_scratch.bit_len();
        let us_bytes = us_scratch.into_bytes();
        assert_eq!(use_site_bit_len, 16, "<0;1>/* use-site is 16 bits");
        assert_eq!(us_bytes, vec![0x80, 0x06]);

        // Record bitstream construction must match impl exactly.
        let mut record_bw = crate::bitstream::BitWriter::new();
        crate::varint::write_varint(&mut record_bw, path_bit_len as u32).unwrap();
        crate::bitstream::re_emit_bits(&mut record_bw, &path_bytes, path_bit_len).unwrap();
        crate::varint::write_varint(&mut record_bw, use_site_bit_len as u32).unwrap();
        crate::bitstream::re_emit_bits(&mut record_bw, &us_bytes, use_site_bit_len).unwrap();

        // ARITHMETIC ASSERTION — load-bearing. varint(26)=9 bits and
        // varint(16)=9 bits (both need a 5-bit payload because L=5).
        // Total = 9 + 26 + 9 + 16 = 60. If lengths were in *bytes* (a
        // common bug), the encoded varints would be much smaller (L=2
        // for both → 6 bits each) and this assertion would fail.
        let varint_path_cost = 4 + (32 - (path_bit_len as u32).leading_zeros()) as usize;
        let varint_us_cost = 4 + (32 - (use_site_bit_len as u32).leading_zeros()) as usize;
        let expected_record_bits =
            varint_path_cost + path_bit_len + varint_us_cost + use_site_bit_len;
        assert_eq!(record_bw.bit_len(), expected_record_bits);
        assert_eq!(record_bw.bit_len(), 60, "cell-7 record is 60 bits");

        let record_bytes = record_bw.into_bytes();
        assert_eq!(
            record_bytes,
            vec![0x5d, 0x1d, 0xea, 0x42, 0x0b, 0x08, 0x00, 0x60]
        );

        // Canonical template tree: 5-bit Wpkh primary tag, zero-padded
        // to one byte.
        let mut tree_w = crate::bitstream::BitWriter::new();
        crate::tree::write_node(&mut tree_w, &d.tree, d.key_index_width()).unwrap();
        let tree_bytes = tree_w.into_bytes();
        assert_eq!(tree_bytes, vec![0x00]);

        // Full hash input — byte-by-byte.
        let presence_byte: u8 = 0x03;
        let fp = [0xDE, 0xAD, 0xBE, 0xEF];
        let xpub = deterministic_xpub();
        let mut expected_hash_input: Vec<u8> = Vec::new();
        expected_hash_input.extend_from_slice(&tree_bytes);
        expected_hash_input.push(presence_byte);
        expected_hash_input.extend_from_slice(&record_bytes);
        expected_hash_input.extend_from_slice(&fp);
        expected_hash_input.extend_from_slice(&xpub);
        assert_eq!(expected_hash_input.len(), 79);

        let expected_hex = "00035d1dea420b080060deadbeef\
            1111111111111111111111111111111111111111111111111111111111111111\
            02\
            2222222222222222222222222222222222222222222222222222222222222222";
        assert_eq!(hex(&expected_hash_input), expected_hex);

        // Final identity bytes (computed by /tmp/golden_vec.py).
        let expected_id: [u8; 16] = [
            0x66, 0x50, 0xb9, 0x80, 0x3b, 0x3c, 0x66, 0x21, 0x01, 0x40, 0x54, 0x0d, 0xa8, 0xd7,
            0x65, 0xa0,
        ];

        let id = compute_wallet_policy_id(&d).unwrap();
        assert_eq!(*id.as_bytes(), expected_id);
    }

    /// Trivial hex helper for byte-exact assertions in the golden test.
    fn hex(bs: &[u8]) -> String {
        let mut s = String::with_capacity(bs.len() * 2);
        for b in bs {
            s.push_str(&format!("{:02x}", b));
        }
        s
    }

    /// Two encodings of the same logical wallet — one with the canonical
    /// path explicitly written, one with no explicit path (the encoder
    /// fills `canonical_origin` into `path_decl` per Option A) — produce
    /// identical WalletPolicyId. (In practice, both have the same
    /// `path_decl` payload after canonicalization; this test pins the
    /// invariant for the trivial case.)
    #[test]
    fn walletpolicyid_stable_across_origin_elision() {
        let d_explicit = cell_7_wpkh_descriptor();
        // Wallet B: same path supplied via OriginPathOverrides[0]
        // instead of a Shared(BIP84) baseline — final canonical-record
        // origin path is identical, so the IDs MUST match.
        let mut d_override = cell_7_wpkh_descriptor();
        let bip84 = match &d_override.path_decl.paths {
            PathDeclPaths::Shared(p) => p.clone(),
            _ => panic!(),
        };
        d_override.tlv.origin_path_overrides = Some(vec![(0u8, bip84)]);
        // Override beats baseline in expand_per_at_n; produces the same
        // canonical record bytes either way.
        let id1 = compute_wallet_policy_id(&d_explicit).unwrap();
        let id2 = compute_wallet_policy_id(&d_override).unwrap();
        assert_eq!(id1, id2);
    }

    /// Use-site path supplied as the descriptor baseline vs supplied via
    /// `UseSitePathOverrides[0]` — same resolved bits → same ID.
    #[test]
    fn walletpolicyid_stable_across_use_site_elision() {
        let d_baseline = cell_7_wpkh_descriptor();
        let mut d_override = cell_7_wpkh_descriptor();
        d_override.use_site_path = UseSitePath {
            multipath: None,
            wildcard_hardened: false,
        };
        d_override.tlv.use_site_path_overrides =
            Some(vec![(0u8, UseSitePath::standard_multipath())]);
        let id1 = compute_wallet_policy_id(&d_baseline).unwrap();
        let id2 = compute_wallet_policy_id(&d_override).unwrap();
        assert_eq!(id1, id2);
    }

    /// Template-only (no fp, no xpub) WalletPolicyId differs from the
    /// fully-keyed cell-7 version — presence-significance gate.
    #[test]
    fn walletpolicyid_template_only_differs_from_full_cell_7() {
        let full = cell_7_wpkh_descriptor();
        let mut template_only = cell_7_wpkh_descriptor();
        template_only.tlv.fingerprints = None;
        template_only.tlv.pubkeys = None;
        let id_full = compute_wallet_policy_id(&full).unwrap();
        let id_template = compute_wallet_policy_id(&template_only).unwrap();
        assert_ne!(id_full, id_template);
    }

    /// 2-of-2 wsh(multi) with `@0` cell-7 (fp+xpub) and `@1` cell-1
    /// (template-only). presence_bytes are 0b11 and 0b00 respectively;
    /// distinct from a "both fully populated" or "both template-only"
    /// version.
    #[test]
    fn walletpolicyid_partial_keys_distinct() {
        #[allow(dead_code)]
        fn pkk(index: u8) -> Node {
            Node {
                tag: Tag::PkK,
                body: Body::KeyArg { index },
            }
        }
        let bip48_2 = OriginPath {
            components: vec![
                PathComponent {
                    hardened: true,
                    value: 48,
                },
                PathComponent {
                    hardened: true,
                    value: 0,
                },
                PathComponent {
                    hardened: true,
                    value: 0,
                },
                PathComponent {
                    hardened: true,
                    value: 2,
                },
            ],
        };
        let mk_d = |fps: Option<Vec<(u8, [u8; 4])>>, pks: Option<Vec<(u8, [u8; 65])>>| Descriptor {
            n: 2,
            path_decl: PathDecl {
                n: 2,
                paths: PathDeclPaths::Shared(bip48_2.clone()),
            },
            use_site_path: UseSitePath::standard_multipath(),
            tree: Node {
                tag: Tag::Wsh,
                body: Body::Children(vec![Node {
                    tag: Tag::Multi,
                    body: Body::MultiKeys {
                        k: 2,
                        indices: vec![0, 1],
                    },
                }]),
            },
            tlv: {
                let mut t = TlvSection::new_empty();
                t.fingerprints = fps;
                t.pubkeys = pks;
                t
            },
        };
        let xpub = deterministic_xpub();
        // Full: both @0 and @1 have fp+xpub.
        let d_full = mk_d(
            Some(vec![(0, [0x11; 4]), (1, [0x22; 4])]),
            Some(vec![(0, xpub), (1, xpub)]),
        );
        // Mixed: @0 cell-7, @1 cell-1 (no fp, no xpub).
        let d_mixed = mk_d(Some(vec![(0, [0x11; 4])]), Some(vec![(0, xpub)]));
        let id_full = compute_wallet_policy_id(&d_full).unwrap();
        let id_mixed = compute_wallet_policy_id(&d_mixed).unwrap();
        assert_ne!(id_full, id_mixed);
    }

    /// Same per-`@N` records under two different wrapper tags
    /// (`wpkh(@0)` vs `pkh(@0)`) → distinct WalletPolicyId. Wrapper
    /// context is hashed via canonical_template_tree_bytes.
    #[test]
    fn walletpolicyid_wrapper_context_in_template_hash() {
        let d_wpkh = cell_7_wpkh_descriptor();
        let mut d_pkh = cell_7_wpkh_descriptor();
        d_pkh.tree = Node {
            tag: Tag::Pkh,
            body: Body::KeyArg { index: 0 },
        };
        // Force same canonical record by overriding origin to the
        // (BIP-44) canonical for pkh — so the only difference is the
        // wrapper tag in the template tree.
        d_pkh.path_decl = PathDecl {
            n: 1,
            paths: PathDeclPaths::Shared(OriginPath {
                components: vec![
                    PathComponent {
                        hardened: true,
                        value: 44,
                    },
                    PathComponent {
                        hardened: true,
                        value: 0,
                    },
                    PathComponent {
                        hardened: true,
                        value: 0,
                    },
                ],
            }),
        };
        // Reset to wpkh's canonical so records share the bytewise
        // origin path — this isolates wrapper-context-only difference.
        d_pkh.path_decl = d_wpkh.path_decl.clone();
        let id_wpkh = compute_wallet_policy_id(&d_wpkh).unwrap();
        let id_pkh = compute_wallet_policy_id(&d_pkh).unwrap();
        assert_ne!(id_wpkh, id_pkh);
    }

    /// Hand-construct two preimages identical except for nonzero
    /// reserved bits in `presence_byte`; they MUST hash to the same
    /// 16-byte WalletPolicyId because the encoder masks reserved bits
    /// to 0 before writing the byte. Property is enforced indirectly:
    /// since `compute_wallet_policy_id` is the only public entry point
    /// and it always masks via `& 0b0000_0011`, two descriptors that
    /// agree on (fp, xpub) presence must produce identical IDs even if
    /// the underlying hash bytes were ever drift-injected. This test
    /// hashes two by-hand preimages to prove SHA-256 is mask-stable.
    #[test]
    fn walletpolicyid_reserved_bits_masking_property() {
        // Construct two preimages: one with presence_byte = 0b11 = 0x03,
        // one with presence_byte = 0b1111_1111 = 0xff. Apply the
        // encoder's mask 0b0000_0011 to both BEFORE hashing — both
        // should reduce to 0x03 and produce the same hash.
        let common = vec![0x00u8, 0x42, 0x42, 0x42];
        // Apply the encoder's mask to two distinct candidate presence
        // bytes (low-bits-only vs. all-ones) — both reduce to 0x03.
        let candidates = [0b0000_0011u8, 0b1111_1111u8];
        let mask = 0b0000_0011u8;
        let masked_a = candidates[0] & mask;
        let masked_b = candidates[1] & mask;
        assert_eq!(masked_a, masked_b);
        let mut input_a = common.clone();
        input_a.push(masked_a);
        let mut input_b = common.clone();
        input_b.push(masked_b);
        let h_a = bitcoin::hashes::sha256::Hash::hash(&input_a);
        let h_b = bitcoin::hashes::sha256::Hash::hash(&input_b);
        assert_eq!(h_a, h_b);

        // Sanity: WITHOUT masking, the hashes differ — proving the
        // mask is the load-bearing step.
        let mut unmasked_a = common.clone();
        unmasked_a.push(candidates[0]);
        let mut unmasked_b = common.clone();
        unmasked_b.push(candidates[1]);
        let h_a_raw = bitcoin::hashes::sha256::Hash::hash(&unmasked_a);
        let h_b_raw = bitcoin::hashes::sha256::Hash::hash(&unmasked_b);
        assert_ne!(h_a_raw, h_b_raw);
    }

    /// `to_phrase()` round-trips through Phrase::from_id_bytes and
    /// returns 12 BIP 39 words for any non-trivial id.
    #[test]
    fn walletpolicyid_to_phrase_returns_12_bip39_words() {
        let d = cell_7_wpkh_descriptor();
        let id = compute_wallet_policy_id(&d).unwrap();
        let phrase = id.to_phrase().unwrap();
        assert_eq!(phrase.0.len(), 12);
        for word in &phrase.0 {
            assert!(!word.is_empty());
        }
    }

    /// `compute_wallet_policy_id` canonicalizes its input internally:
    /// `tr(multi(2, @1, @0))` (non-canonical) and the canonical
    /// equivalent `tr(multi(2, @0, @1))` (with TLVs renumbered
    /// consistently) produce identical IDs.
    #[test]
    fn compute_wallet_policy_id_canonicalizes_first() {
        #[allow(dead_code)]
        fn pkk(index: u8) -> Node {
            Node {
                tag: Tag::PkK,
                body: Body::KeyArg { index },
            }
        }
        let xpub_a = deterministic_xpub();
        let mut xpub_b = deterministic_xpub();
        xpub_b[0] = 0x33;
        let bip48_2 = OriginPath {
            components: vec![
                PathComponent {
                    hardened: true,
                    value: 48,
                },
                PathComponent {
                    hardened: true,
                    value: 0,
                },
                PathComponent {
                    hardened: true,
                    value: 0,
                },
                PathComponent {
                    hardened: true,
                    value: 2,
                },
            ],
        };
        // Non-canonical: tree first-occurrence is @1 then @0; pubkeys
        // wired by original index — A↔@0, B↔@1.
        let d_non_canonical = Descriptor {
            n: 2,
            path_decl: PathDecl {
                n: 2,
                paths: PathDeclPaths::Shared(bip48_2.clone()),
            },
            use_site_path: UseSitePath::standard_multipath(),
            tree: Node {
                tag: Tag::Wsh,
                body: Body::Children(vec![Node {
                    tag: Tag::Multi,
                    body: Body::MultiKeys {
                        k: 2,
                        indices: vec![1, 0],
                    },
                }]),
            },
            tlv: {
                let mut t = TlvSection::new_empty();
                t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b)]);
                t
            },
        };
        // Canonical equivalent: tree first-occurrence is @0 then @1;
        // pubkeys renumbered to match (original-@1 → new-@0 → carries B,
        // original-@0 → new-@1 → carries A).
        let d_canonical = Descriptor {
            n: 2,
            path_decl: PathDecl {
                n: 2,
                paths: PathDeclPaths::Shared(bip48_2),
            },
            use_site_path: UseSitePath::standard_multipath(),
            tree: Node {
                tag: Tag::Wsh,
                body: Body::Children(vec![Node {
                    tag: Tag::Multi,
                    body: Body::MultiKeys {
                        k: 2,
                        indices: vec![0, 1],
                    },
                }]),
            },
            tlv: {
                let mut t = TlvSection::new_empty();
                t.pubkeys = Some(vec![(0, xpub_b), (1, xpub_a)]);
                t
            },
        };
        let id_nc = compute_wallet_policy_id(&d_non_canonical).unwrap();
        let id_c = compute_wallet_policy_id(&d_canonical).unwrap();
        assert_eq!(id_nc, id_c);
    }

    // ─── validate_presence_byte (v0.13.1, spec §5.3) ─────────────────

    #[test]
    fn validate_presence_byte_accepts_all_four_legal_combinations() {
        for byte in [0b00, 0b01, 0b10, 0b11] {
            validate_presence_byte(byte).unwrap();
        }
    }

    #[test]
    fn validate_presence_byte_rejects_lowest_reserved_bit() {
        // bit 2 set
        let err = validate_presence_byte(0b0000_0100).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidPresenceByte {
                reserved_bits: 0b0000_0100
            }
        ));
    }

    #[test]
    fn validate_presence_byte_rejects_high_reserved_bit_with_legal_low_bits() {
        // bit 7 set + fp_present + xpub_present
        let err = validate_presence_byte(0b1000_0011).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidPresenceByte {
                reserved_bits: 0b1000_0000
            }
        ));
    }

    #[test]
    fn validate_presence_byte_rejects_all_bits_set() {
        let err = validate_presence_byte(0xFF).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidPresenceByte {
                reserved_bits: 0b1111_1100
            }
        ));
    }
}