Skip to main content

md_codec/
identity.rs

1//! Identity computation per spec §8.
2
3use crate::bitstream::{BitWriter, re_emit_bits};
4use crate::canonicalize::{canonicalize_placeholder_indices, expand_per_at_n};
5use crate::encode::{Descriptor, encode_payload};
6use crate::error::Error;
7use crate::phrase::Phrase;
8use crate::varint::write_varint;
9use bitcoin::hashes::{Hash, sha256};
10
11/// 128-bit canonical identifier for an md1 encoding (spec §8).
12///
13/// Computed as the first 16 bytes of `SHA-256` over the canonical
14/// bit-packed payload bytes produced by [`encode_payload`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct Md1EncodingId([u8; 16]);
17
18impl Md1EncodingId {
19    /// Construct from a raw 16-byte array.
20    pub fn new(bytes: [u8; 16]) -> Self {
21        Self(bytes)
22    }
23
24    /// Borrow the underlying 16-byte identifier.
25    pub fn as_bytes(&self) -> &[u8; 16] {
26        &self.0
27    }
28
29    /// Return the 4-byte fingerprint (first 4 bytes of the id).
30    pub fn fingerprint(&self) -> [u8; 4] {
31        let mut fp = [0u8; 4];
32        fp.copy_from_slice(&self.0[0..4]);
33        fp
34    }
35}
36
37/// Compute the [`Md1EncodingId`] for a descriptor by hashing its canonical
38/// bit-packed payload encoding (spec §8).
39pub fn compute_md1_encoding_id(d: &Descriptor) -> Result<Md1EncodingId, Error> {
40    let (bytes, _bit_len) = encode_payload(d)?;
41    let hash = sha256::Hash::hash(&bytes);
42    let mut id = [0u8; 16];
43    id.copy_from_slice(&hash.to_byte_array()[0..16]);
44    Ok(Md1EncodingId(id))
45}
46
47/// 128-bit BIP 388 wallet-descriptor-template identifier (spec §8.1, γ-flavor).
48///
49/// Hashes ONLY the BIP 388 template content: use-site-path-decl bits, tree
50/// bits, and the `UseSitePathOverrides` TLV entry bits when present. Excludes
51/// the header, origin-path-decl, `Fingerprints` TLV, HRP, and BCH checksum,
52/// so it is invariant to origin-path changes (e.g. account index) and to
53/// fingerprint additions.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub struct WalletDescriptorTemplateId([u8; 16]);
56
57impl WalletDescriptorTemplateId {
58    /// Construct from a raw 16-byte array.
59    pub fn new(bytes: [u8; 16]) -> Self {
60        Self(bytes)
61    }
62
63    /// Borrow the underlying 16-byte identifier.
64    pub fn as_bytes(&self) -> &[u8; 16] {
65        &self.0
66    }
67}
68
69/// Compute the [`WalletDescriptorTemplateId`] for a descriptor by hashing only
70/// the BIP 388 template content per spec §8.1.
71pub fn compute_wallet_descriptor_template_id(
72    d: &Descriptor,
73) -> Result<WalletDescriptorTemplateId, Error> {
74    // L15: canonicalize placeholder ordering on a clone first (mirror
75    // compute_wallet_policy_id) so the WDT-id is invariant to placeholder
76    // index permutation. The identity fast-path leaves already-canonical
77    // inputs (the toolkit's @0,@1,… ordering) byte-identical.
78    let mut d_canonical = d.clone();
79    canonicalize_placeholder_indices(&mut d_canonical)?;
80    let d = &d_canonical;
81    let mut w = BitWriter::new();
82    // Per spec §8.1: use-site-path-decl bits || tree bits || UseSitePathOverrides TLV bits
83    let kiw = d.key_index_width();
84    d.use_site_path.write(&mut w)?;
85    crate::tree::write_node(&mut w, &d.tree, kiw)?;
86    if let Some(overrides) = &d.tlv.use_site_path_overrides {
87        // Re-encode the UseSitePathOverrides TLV ENTRY (tag + length + payload).
88        let mut sub = BitWriter::new();
89        for (idx, path) in overrides {
90            sub.write_bits(u64::from(*idx), kiw as usize);
91            path.write(&mut sub)?;
92        }
93        let bit_len = sub.bit_len();
94        w.write_bits(u64::from(crate::tlv::TLV_USE_SITE_PATH_OVERRIDES), 5);
95        crate::varint::write_varint(&mut w, bit_len as u32)?;
96        let payload = sub.into_bytes();
97        let mut subr = crate::bitstream::BitReader::new(&payload);
98        let mut remaining = bit_len;
99        while remaining > 0 {
100            let chunk = remaining.min(8);
101            let bits = subr.read_bits(chunk)?;
102            w.write_bits(bits, chunk);
103            remaining -= chunk;
104        }
105    }
106    let bytes = w.into_bytes();
107    let hash = sha256::Hash::hash(&bytes);
108    let mut id = [0u8; 16];
109    id.copy_from_slice(&hash.to_byte_array()[0..16]);
110    Ok(WalletDescriptorTemplateId(id))
111}
112
113/// 128-bit canonical wallet-policy identifier (spec v0.13 §5.3).
114///
115/// Hashes the canonical-expanded BIP 388 wallet *policy* — template tree
116/// plus per-`@N` origin / use-site / fp / xpub records — so that two
117/// engravings of the same logical wallet produce identical IDs whether
118/// they elide canonical paths or write them out explicitly. Stable
119/// across origin- and use-site-elision; presence-significant on
120/// fingerprint and xpub axes.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122pub struct WalletPolicyId([u8; 16]);
123
124impl WalletPolicyId {
125    /// Construct from a raw 16-byte array.
126    pub fn new(bytes: [u8; 16]) -> Self {
127        Self(bytes)
128    }
129
130    /// Borrow the underlying 16-byte identifier.
131    pub fn as_bytes(&self) -> &[u8; 16] {
132        &self.0
133    }
134
135    /// Render this identifier as a 12-word BIP 39 phrase (spec §8.4).
136    pub fn to_phrase(&self) -> Result<Phrase, Error> {
137        Phrase::from_id_bytes(self.as_bytes())
138    }
139}
140
141/// Compute the [`WalletPolicyId`] for a descriptor by hashing its
142/// canonical-expanded wallet-policy preimage per spec v0.13 §5.3.
143///
144/// Construction (byte-exact, no encoder divergence):
145///
146/// 1. Canonicalize placeholder indices on a clone of `d` (Phase 3a) —
147///    callers don't need to remember the precondition.
148/// 2. Compute `canonical_template_tree_bytes` by writing the
149///    placeholder-form tree via [`crate::tree::write_node`] into a fresh
150///    [`BitWriter`] and finalizing (zero-pad to whole-byte boundary).
151/// 3. Expand to per-`@N` records via [`expand_per_at_n`] (Phase 3b).
152/// 4. For each record (idx-ascending), allocate a fresh `BitWriter`,
153///    write `path_bit_len` (LP4-ext varint, in *bits*), then re-emit
154///    the path's bits MSB-first via [`re_emit_bits`]; same for the
155///    use-site path. Finalize the bitstream — single byte-boundary pad.
156/// 5. Build `presence_byte = (fp_present | (xpub_present << 1)) &
157///    0b0000_0011` (explicit reserved-bit mask) and concatenate
158///    `presence_byte || record_bytes || fp? || xpub?`.
159/// 6. Hash input = `canonical_template_tree_bytes || concat(records)`.
160/// 7. Return `SHA-256(input)[0..16]`.
161///
162/// # Errors
163///
164/// Propagates [`Error::MissingExplicitOrigin`] from [`expand_per_at_n`]
165/// for non-canonical wrappers without an explicit origin path; other
166/// canonicalization or encoding errors as appropriate.
167///
168/// # INVARIANT (Option A, spec v0.13 §3 + §5.3)
169///
170/// `path_decl.paths` is always populated post-decode (v0.11 wire
171/// invariant). Canonical-fill into `path_decl` happens at encode time
172/// only (per spec §6.3). For a decoded wire this function therefore
173/// reads `OriginPathOverrides[idx]` if present, else `path_decl.paths`
174/// resolved per the divergent_paths flag, via [`expand_per_at_n`].
175///
176/// L14 (cycle-10): for an in-memory `Descriptor` built with an ELIDED
177/// (empty-components) origin — which `expand_per_at_n` surfaces as an
178/// empty `e.origin_path` — this function canonical-fills that single
179/// empty case from [`crate::canonical_origin::canonical_origin`] so the
180/// policy-id honors its documented "stable across origin-elision"
181/// invariant (an elided origin hashes identically to the explicit form).
182/// Decoded wires are unaffected (their `path_decl` is always populated,
183/// so the empty-origin branch is never taken). Any future change that
184/// elides `path_decl` on the wire would extend this canonical_origin
185/// lookup to the decode path here and in [`expand_per_at_n`].
186pub fn compute_wallet_policy_id(d: &Descriptor) -> Result<WalletPolicyId, Error> {
187    // Step 1: canonicalize on a clone so callers don't have to remember
188    // the precondition and we never mutate the caller's descriptor.
189    let mut d_canonical = d.clone();
190    canonicalize_placeholder_indices(&mut d_canonical)?;
191    let d = &d_canonical;
192
193    // Step 2: canonical_template_tree_bytes — placeholder-form tree only.
194    let mut tree_w = BitWriter::new();
195    crate::tree::write_node(&mut tree_w, &d.tree, d.key_index_width())?;
196    let canonical_template_tree_bytes = tree_w.into_bytes();
197
198    // Step 3: expand to per-@N records.
199    let expanded = expand_per_at_n(d)?;
200
201    // Step 4–5: build each canonical record and concatenate.
202    let mut records_concat: Vec<u8> = Vec::new();
203    for e in &expanded {
204        // Origin path bits (scratch BitWriter; bit_len() captures unpadded
205        // length, into_bytes() zero-pads to the next byte boundary).
206        //
207        // L14: canonical-fill an elided (empty) origin so the policy-id
208        // honors its documented "stable across origin-elision" invariant.
209        // An empty resolved origin with a canonical wrapper hashes
210        // identically to the explicit form. expand_per_at_n already returns
211        // explicit paths verbatim, so only the empty case needs the fill;
212        // when canonical_origin is None the empty path is structurally
213        // precluded HERE (expand_per_at_n's own MissingExplicitOrigin
214        // gate), so the unwrap_or_else fallback is unreachable-but-safe.
215        //
216        // P0 update (pathless/dead-card partial-decode): partial-allowing
217        // decode (`decode_payload_with_opts` / `decode_md1_string_with_opts`
218        // / `chunk::reassemble_with_opts`, `allow_unresolved_origin: true`)
219        // now lets a `canonical_origin(&d.tree) == None` + empty-origin
220        // `Descriptor` exist in-process — the render-only callers (md-cli /
221        // toolkit) query `Descriptor::unresolved_origin_indices()` on it
222        // directly and never route it through `compute_wallet_policy_id`.
223        // `expand_per_at_n` itself is NOT partial-aware: it is called
224        // unconditionally on `d` a few lines above and still raises
225        // `MissingExplicitOrigin` for any canonical_origin==None +
226        // empty-origin descriptor regardless of how that `Descriptor` was
227        // built or decoded (see its own doc comment). So the
228        // `unwrap_or_else` fallback below stays unreachable-but-safe —
229        // `compute_wallet_policy_id` remains fail-closed even though
230        // partial `Descriptor`s now exist in the same process.
231        let origin_for_hash = if e.origin_path.components.is_empty() {
232            crate::canonical_origin::canonical_origin(&d.tree)
233                .unwrap_or_else(|| e.origin_path.clone())
234        } else {
235            e.origin_path.clone()
236        };
237        let mut path_scratch = BitWriter::new();
238        origin_for_hash.write(&mut path_scratch)?;
239        let path_bit_len = path_scratch.bit_len();
240        let path_bytes = path_scratch.into_bytes();
241
242        // Use-site path bits.
243        let mut us_scratch = BitWriter::new();
244        e.use_site_path.write(&mut us_scratch)?;
245        let use_site_bit_len = us_scratch.bit_len();
246        let us_bytes = us_scratch.into_bytes();
247
248        // Record bitstream: varint(path_bit_len) || path_bits ||
249        // varint(use_site_bit_len) || use_site_bits, with a single
250        // byte-boundary pad applied by into_bytes().
251        let mut record_bw = BitWriter::new();
252        write_varint(&mut record_bw, path_bit_len as u32)?;
253        re_emit_bits(&mut record_bw, &path_bytes, path_bit_len)?;
254        write_varint(&mut record_bw, use_site_bit_len as u32)?;
255        re_emit_bits(&mut record_bw, &us_bytes, use_site_bit_len)?;
256        let record_bytes = record_bw.into_bytes();
257
258        // Presence byte: bit 0 = fp, bit 1 = xpub; reserved bits 2..7
259        // are explicitly masked to 0 per spec §5.3 (forward-compat:
260        // future versions that define a reserved bit must not collide
261        // with v0.13's hash on the same wire).
262        let fp_present = e.fingerprint.is_some();
263        let xpub_present = e.xpub.is_some();
264        let presence_byte = ((fp_present as u8) | ((xpub_present as u8) << 1)) & 0b0000_0011;
265
266        records_concat.push(presence_byte);
267        records_concat.extend_from_slice(&record_bytes);
268        if let Some(fp) = e.fingerprint {
269            records_concat.extend_from_slice(&fp);
270        }
271        if let Some(xpub) = e.xpub {
272            records_concat.extend_from_slice(&xpub);
273        }
274    }
275
276    // Step 6–7: hash and truncate.
277    let mut hash_input: Vec<u8> =
278        Vec::with_capacity(canonical_template_tree_bytes.len() + records_concat.len());
279    hash_input.extend_from_slice(&canonical_template_tree_bytes);
280    hash_input.extend_from_slice(&records_concat);
281    let hash = sha256::Hash::hash(&hash_input);
282    let mut id = [0u8; 16];
283    id.copy_from_slice(&hash.to_byte_array()[0..16]);
284    Ok(WalletPolicyId(id))
285}
286
287/// Validate a `presence_byte` from a `WalletPolicyId` canonical-record
288/// preimage (spec v0.13 §5.3). Bit 0 = `fp_present`, bit 1 =
289/// `xpub_present`, bits 2..7 reserved (must be 0). Returns
290/// [`Error::InvalidPresenceByte`] with the offending reserved-bit
291/// field if any of bits 2..7 is set.
292///
293/// v0.13's encoder masks reserved bits when building the preimage, so
294/// this helper is unreachable on v0.13 wire today. It enforces the
295/// spec §5.3 "decoders MUST reject" clause for any future
296/// canonical-record consumer (e.g., a verification-mode tool that
297/// reconstructs the preimage to cross-check a `WalletPolicyId`).
298pub fn validate_presence_byte(byte: u8) -> Result<(), Error> {
299    let reserved_bits = byte & 0b1111_1100;
300    if reserved_bits != 0 {
301        return Err(Error::InvalidPresenceByte { reserved_bits });
302    }
303    Ok(())
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
310    use crate::tag::Tag;
311    use crate::tlv::TlvSection;
312    use crate::tree::{Body, Node};
313    use crate::use_site_path::UseSitePath;
314
315    fn bip84_descriptor() -> Descriptor {
316        Descriptor {
317            n: 1,
318            path_decl: PathDecl {
319                n: 1,
320                paths: PathDeclPaths::Shared(OriginPath {
321                    components: vec![
322                        PathComponent {
323                            hardened: true,
324                            value: 84,
325                        },
326                        PathComponent {
327                            hardened: true,
328                            value: 0,
329                        },
330                        PathComponent {
331                            hardened: true,
332                            value: 0,
333                        },
334                    ],
335                }),
336            },
337            use_site_path: UseSitePath::standard_multipath(),
338            tree: Node {
339                tag: Tag::Wpkh,
340                body: Body::KeyArg { index: 0 },
341            },
342            tlv: TlvSection::new_empty(),
343        }
344    }
345
346    #[test]
347    fn md1_encoding_id_deterministic() {
348        let d = bip84_descriptor();
349        let id1 = compute_md1_encoding_id(&d).unwrap();
350        let id2 = compute_md1_encoding_id(&d).unwrap();
351        assert_eq!(id1, id2);
352    }
353
354    #[test]
355    fn md1_encoding_id_differs_for_different_paths() {
356        let d1 = bip84_descriptor();
357        let mut d2 = bip84_descriptor();
358        if let PathDeclPaths::Shared(p) = &mut d2.path_decl.paths {
359            p.components[2] = PathComponent {
360                hardened: true,
361                value: 1,
362            };
363        }
364        let id1 = compute_md1_encoding_id(&d1).unwrap();
365        let id2 = compute_md1_encoding_id(&d2).unwrap();
366        assert_ne!(id1, id2);
367    }
368
369    #[test]
370    fn wdt_id_invariant_to_origin_path_change() {
371        let d1 = bip84_descriptor();
372        let mut d2 = bip84_descriptor();
373        if let PathDeclPaths::Shared(p) = &mut d2.path_decl.paths {
374            p.components[2] = PathComponent {
375                hardened: true,
376                value: 1,
377            };
378        }
379        let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
380        let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
381        // Same template structure (use-site path, tree) → same WDT-Id
382        assert_eq!(id1, id2);
383    }
384
385    #[test]
386    fn wdt_id_differs_for_different_use_site_paths() {
387        let d1 = bip84_descriptor();
388        let mut d2 = bip84_descriptor();
389        d2.use_site_path = UseSitePath {
390            multipath: None,
391            wildcard_hardened: false,
392        };
393        let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
394        let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
395        assert_ne!(id1, id2);
396    }
397
398    #[test]
399    fn wdt_id_invariant_to_fingerprint_addition() {
400        let d1 = bip84_descriptor();
401        let mut d2 = bip84_descriptor();
402        d2.tlv.fingerprints = Some(vec![(0u8, [0xaa, 0xbb, 0xcc, 0xdd])]);
403        let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
404        let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
405        // Fingerprints are excluded from WDT-Id hash domain
406        assert_eq!(id1, id2);
407    }
408
409    /// L15: the WDT-id must be invariant to placeholder-index permutation,
410    /// mirroring the policy-id's canonicalization. `wsh(multi(2,@1,@0))`
411    /// (non-canonical placeholder ordering) and `wsh(multi(2,@0,@1))`
412    /// (canonical) describe the same template and MUST share a WDT-id.
413    /// RED today (raw `*idx` is hashed without canonicalization); GREEN
414    /// after compute_wallet_descriptor_template_id canonicalizes a clone.
415    #[test]
416    fn wdt_id_invariant_to_placeholder_ordering() {
417        let mk_d = |indices: Vec<u8>| Descriptor {
418            n: 2,
419            path_decl: PathDecl {
420                n: 2,
421                paths: PathDeclPaths::Shared(OriginPath {
422                    components: vec![
423                        PathComponent {
424                            hardened: true,
425                            value: 48,
426                        },
427                        PathComponent {
428                            hardened: true,
429                            value: 0,
430                        },
431                        PathComponent {
432                            hardened: true,
433                            value: 0,
434                        },
435                        PathComponent {
436                            hardened: true,
437                            value: 2,
438                        },
439                    ],
440                }),
441            },
442            use_site_path: UseSitePath::standard_multipath(),
443            tree: Node {
444                tag: Tag::Wsh,
445                body: Body::Children(vec![Node {
446                    tag: Tag::Multi,
447                    body: Body::MultiKeys { k: 2, indices },
448                }]),
449            },
450            tlv: TlvSection::new_empty(),
451        };
452        // Non-canonical: tree first-occurrence is @1 then @0.
453        let d_non_canonical = mk_d(vec![1, 0]);
454        // Canonical: tree first-occurrence is @0 then @1.
455        let d_canonical = mk_d(vec![0, 1]);
456        let id_nc = compute_wallet_descriptor_template_id(&d_non_canonical).unwrap();
457        let id_c = compute_wallet_descriptor_template_id(&d_canonical).unwrap();
458        assert_eq!(id_nc, id_c);
459    }
460
461    // ---- v0.13 WalletPolicyId tests ----
462
463    /// Build a deterministic 65-byte xpub for tests: 32 bytes of `0x11`
464    /// (chain code) followed by `0x02 || [0x22; 32]` (compressed pubkey
465    /// with even Y prefix). The pubkey bytes are NOT a valid secp256k1
466    /// point; tests that exercise §6.4 (`InvalidXpubBytes`) will use a
467    /// real point. Phase 4 only hashes raw bytes.
468    fn deterministic_xpub() -> [u8; 65] {
469        let mut x = [0u8; 65];
470        for b in x.iter_mut().take(32) {
471            *b = 0x11;
472        }
473        x[32] = 0x02;
474        for b in x.iter_mut().skip(33) {
475            *b = 0x22;
476        }
477        x
478    }
479
480    /// Construct the dominant case: 1-of-1 cell-7 wpkh wallet with fp
481    /// 0xDEADBEEF and a deterministic xpub at canonical BIP 84 origin.
482    fn cell_7_wpkh_descriptor() -> Descriptor {
483        Descriptor {
484            n: 1,
485            path_decl: PathDecl {
486                n: 1,
487                paths: PathDeclPaths::Shared(OriginPath {
488                    components: vec![
489                        PathComponent {
490                            hardened: true,
491                            value: 84,
492                        },
493                        PathComponent {
494                            hardened: true,
495                            value: 0,
496                        },
497                        PathComponent {
498                            hardened: true,
499                            value: 0,
500                        },
501                    ],
502                }),
503            },
504            use_site_path: UseSitePath::standard_multipath(),
505            tree: Node {
506                tag: Tag::Wpkh,
507                body: Body::KeyArg { index: 0 },
508            },
509            tlv: {
510                let mut t = TlvSection::new_empty();
511                t.fingerprints = Some(vec![(0u8, [0xDE, 0xAD, 0xBE, 0xEF])]);
512                t.pubkeys = Some(vec![(0u8, deterministic_xpub())]);
513                t
514            },
515        }
516    }
517
518    /// **GOLDEN VECTOR** (load-bearing): byte-exact construction of the
519    /// 1-of-1 cell-7 wpkh `WalletPolicyId` preimage and SHA-256 truncation.
520    ///
521    /// Component bit budget (hand-derived; locks LP4-ext varint unit
522    /// semantics — lengths are in bits, not bytes):
523    ///
524    /// ```text
525    /// canonical_template_tree:
526    ///   Tag::Wpkh primary code 0x00 (5 bits)         = 5 bits
527    ///   KeyArg index @0  (kiw=0 since n=1)            = 0 bits
528    ///   --------------------------------------------------
529    ///   total                                          = 5 bits
530    ///   into_bytes() zero-pads to 1 byte              = 0x00
531    ///
532    /// origin path m/84'/0'/0':
533    ///   depth=3       (4 bits)                        =  4
534    ///   84'  hardened(1) + varint(84)  = 1 + (4 + 7)  = 12
535    ///   0'   hardened(1) + varint(0)   = 1 + (4 + 0)  =  5
536    ///   0'   hardened(1) + varint(0)   = 1 + (4 + 0)  =  5
537    ///   ------------------------------------------------
538    ///   total                                          = 26 bits
539    ///
540    /// use-site <0;1>/*:
541    ///   has-mp=1 (1) + alt_count-2=0 (3)              =  4
542    ///   alt0: hardened=0 (1) + varint(0)=4            =  5
543    ///   alt1: hardened=0 (1) + varint(1)=5            =  6
544    ///   wildcard_hardened=0 (1)                        =  1
545    ///   ------------------------------------------------
546    ///   total                                          = 16 bits
547    ///
548    /// record_bw bits:
549    ///   varint(26): L=5 (4 bits) + 5-bit payload      =  9
550    ///   path bits  (re-emitted)                       = 26
551    ///   varint(16): L=5 (4 bits) + 5-bit payload      =  9
552    ///   use-site bits (re-emitted)                    = 16
553    ///   ------------------------------------------------
554    ///   total                                          = 60 bits
555    ///   into_bytes() zero-pads to 8 bytes (64 bits)
556    ///
557    /// presence_byte = (1 | 1<<1) & 0b11 = 0x03
558    /// fp = [DE, AD, BE, EF] (4 bytes)
559    /// xpub = [11; 32] || 02 || [22; 32]  (65 bytes)
560    /// record total =  1 + 8 + 4 + 65 = 78 bytes
561    /// hash_input  = canonical_template_tree(1) || record(78) = 79 bytes
562    /// ```
563    ///
564    /// Expected bytes computed independently in `/tmp/golden_vec.py`.
565    #[test]
566    fn golden_vector_wpkh_cell_7() {
567        let d = cell_7_wpkh_descriptor();
568
569        // Independently re-construct the canonical bitstream so the
570        // arithmetic assertion (LP4-ext varint unit confusion gate) is
571        // checked against locally-computed lengths. We mirror the
572        // implementation's component writes here so a unit-confusion
573        // bug surfaces in the assertion below before SHA-256 swallows
574        // it.
575        let path = match &d.path_decl.paths {
576            PathDeclPaths::Shared(p) => p.clone(),
577            _ => panic!("test fixture is shared"),
578        };
579        let mut path_scratch = crate::bitstream::BitWriter::new();
580        path.write(&mut path_scratch).unwrap();
581        let path_bit_len = path_scratch.bit_len();
582        let path_bytes = path_scratch.into_bytes();
583        assert_eq!(path_bit_len, 26, "BIP-84 origin path is 26 bits");
584        assert_eq!(path_bytes, vec![0x3b, 0xd4, 0x84, 0x00]);
585
586        let mut us_scratch = crate::bitstream::BitWriter::new();
587        d.use_site_path.write(&mut us_scratch).unwrap();
588        let use_site_bit_len = us_scratch.bit_len();
589        let us_bytes = us_scratch.into_bytes();
590        assert_eq!(use_site_bit_len, 16, "<0;1>/* use-site is 16 bits");
591        assert_eq!(us_bytes, vec![0x80, 0x06]);
592
593        // Record bitstream construction must match impl exactly.
594        let mut record_bw = crate::bitstream::BitWriter::new();
595        crate::varint::write_varint(&mut record_bw, path_bit_len as u32).unwrap();
596        crate::bitstream::re_emit_bits(&mut record_bw, &path_bytes, path_bit_len).unwrap();
597        crate::varint::write_varint(&mut record_bw, use_site_bit_len as u32).unwrap();
598        crate::bitstream::re_emit_bits(&mut record_bw, &us_bytes, use_site_bit_len).unwrap();
599
600        // ARITHMETIC ASSERTION — load-bearing. varint(26)=9 bits and
601        // varint(16)=9 bits (both need a 5-bit payload because L=5).
602        // Total = 9 + 26 + 9 + 16 = 60. If lengths were in *bytes* (a
603        // common bug), the encoded varints would be much smaller (L=2
604        // for both → 6 bits each) and this assertion would fail.
605        let varint_path_cost = 4 + (32 - (path_bit_len as u32).leading_zeros()) as usize;
606        let varint_us_cost = 4 + (32 - (use_site_bit_len as u32).leading_zeros()) as usize;
607        let expected_record_bits =
608            varint_path_cost + path_bit_len + varint_us_cost + use_site_bit_len;
609        assert_eq!(record_bw.bit_len(), expected_record_bits);
610        assert_eq!(record_bw.bit_len(), 60, "cell-7 record is 60 bits");
611
612        let record_bytes = record_bw.into_bytes();
613        assert_eq!(
614            record_bytes,
615            vec![0x5d, 0x1d, 0xea, 0x42, 0x0b, 0x08, 0x00, 0x60]
616        );
617
618        // Canonical template tree: 5-bit Wpkh primary tag, zero-padded
619        // to one byte.
620        let mut tree_w = crate::bitstream::BitWriter::new();
621        crate::tree::write_node(&mut tree_w, &d.tree, d.key_index_width()).unwrap();
622        let tree_bytes = tree_w.into_bytes();
623        assert_eq!(tree_bytes, vec![0x00]);
624
625        // Full hash input — byte-by-byte.
626        let presence_byte: u8 = 0x03;
627        let fp = [0xDE, 0xAD, 0xBE, 0xEF];
628        let xpub = deterministic_xpub();
629        let mut expected_hash_input: Vec<u8> = Vec::new();
630        expected_hash_input.extend_from_slice(&tree_bytes);
631        expected_hash_input.push(presence_byte);
632        expected_hash_input.extend_from_slice(&record_bytes);
633        expected_hash_input.extend_from_slice(&fp);
634        expected_hash_input.extend_from_slice(&xpub);
635        assert_eq!(expected_hash_input.len(), 79);
636
637        let expected_hex = "00035d1dea420b080060deadbeef\
638            1111111111111111111111111111111111111111111111111111111111111111\
639            02\
640            2222222222222222222222222222222222222222222222222222222222222222";
641        assert_eq!(hex(&expected_hash_input), expected_hex);
642
643        // Final identity bytes (computed by /tmp/golden_vec.py).
644        let expected_id: [u8; 16] = [
645            0x66, 0x50, 0xb9, 0x80, 0x3b, 0x3c, 0x66, 0x21, 0x01, 0x40, 0x54, 0x0d, 0xa8, 0xd7,
646            0x65, 0xa0,
647        ];
648
649        let id = compute_wallet_policy_id(&d).unwrap();
650        assert_eq!(*id.as_bytes(), expected_id);
651    }
652
653    /// Trivial hex helper for byte-exact assertions in the golden test.
654    fn hex(bs: &[u8]) -> String {
655        let mut s = String::with_capacity(bs.len() * 2);
656        for b in bs {
657            s.push_str(&format!("{:02x}", b));
658        }
659        s
660    }
661
662    /// Two encodings of the same logical wallet — one with the canonical
663    /// path explicitly written, one with no explicit path (the encoder
664    /// fills `canonical_origin` into `path_decl` per Option A) — produce
665    /// identical WalletPolicyId. (In practice, both have the same
666    /// `path_decl` payload after canonicalization; this test pins the
667    /// invariant for the trivial case.)
668    #[test]
669    fn walletpolicyid_stable_across_origin_elision() {
670        // Explicit: wpkh(@0) with path_decl = Shared(m/84'/0'/0').
671        let d_explicit = cell_7_wpkh_descriptor();
672        // Elided: same wpkh(@0) wallet, but path_decl is a genuinely EMPTY
673        // Shared origin (no explicit path). The canonical wrapper
674        // (wpkh → m/84'/0'/0') supplies the path at hash time via the L14
675        // canonical-fill. RED today (the empty path hashes a 0000 length
676        // prefix + no components, differing from the explicit component
677        // bits); GREEN after the L14 fill.
678        let mut d_elided = cell_7_wpkh_descriptor();
679        d_elided.path_decl = PathDecl {
680            n: 1,
681            paths: PathDeclPaths::Shared(OriginPath { components: vec![] }),
682        };
683        let id_explicit = compute_wallet_policy_id(&d_explicit).unwrap();
684        let id_elided = compute_wallet_policy_id(&d_elided).unwrap();
685        // The documented "stable across origin-elision" invariant: the
686        // elided form, canonical-filled, must hash identically to the
687        // explicit form.
688        assert_eq!(id_explicit, id_elided);
689    }
690
691    /// Use-site path supplied as the descriptor baseline vs supplied via
692    /// `UseSitePathOverrides[0]` — same resolved bits → same ID.
693    #[test]
694    fn walletpolicyid_stable_across_use_site_elision() {
695        let d_baseline = cell_7_wpkh_descriptor();
696        let mut d_override = cell_7_wpkh_descriptor();
697        d_override.use_site_path = UseSitePath {
698            multipath: None,
699            wildcard_hardened: false,
700        };
701        d_override.tlv.use_site_path_overrides =
702            Some(vec![(0u8, UseSitePath::standard_multipath())]);
703        let id1 = compute_wallet_policy_id(&d_baseline).unwrap();
704        let id2 = compute_wallet_policy_id(&d_override).unwrap();
705        assert_eq!(id1, id2);
706    }
707
708    /// Template-only (no fp, no xpub) WalletPolicyId differs from the
709    /// fully-keyed cell-7 version — presence-significance gate.
710    #[test]
711    fn walletpolicyid_template_only_differs_from_full_cell_7() {
712        let full = cell_7_wpkh_descriptor();
713        let mut template_only = cell_7_wpkh_descriptor();
714        template_only.tlv.fingerprints = None;
715        template_only.tlv.pubkeys = None;
716        let id_full = compute_wallet_policy_id(&full).unwrap();
717        let id_template = compute_wallet_policy_id(&template_only).unwrap();
718        assert_ne!(id_full, id_template);
719    }
720
721    /// 2-of-2 wsh(multi) with `@0` cell-7 (fp+xpub) and `@1` cell-1
722    /// (template-only). presence_bytes are 0b11 and 0b00 respectively;
723    /// distinct from a "both fully populated" or "both template-only"
724    /// version.
725    #[test]
726    fn walletpolicyid_partial_keys_distinct() {
727        #[allow(dead_code)]
728        fn pkk(index: u8) -> Node {
729            Node {
730                tag: Tag::PkK,
731                body: Body::KeyArg { index },
732            }
733        }
734        let bip48_2 = OriginPath {
735            components: vec![
736                PathComponent {
737                    hardened: true,
738                    value: 48,
739                },
740                PathComponent {
741                    hardened: true,
742                    value: 0,
743                },
744                PathComponent {
745                    hardened: true,
746                    value: 0,
747                },
748                PathComponent {
749                    hardened: true,
750                    value: 2,
751                },
752            ],
753        };
754        let mk_d = |fps: Option<Vec<(u8, [u8; 4])>>, pks: Option<Vec<(u8, [u8; 65])>>| Descriptor {
755            n: 2,
756            path_decl: PathDecl {
757                n: 2,
758                paths: PathDeclPaths::Shared(bip48_2.clone()),
759            },
760            use_site_path: UseSitePath::standard_multipath(),
761            tree: Node {
762                tag: Tag::Wsh,
763                body: Body::Children(vec![Node {
764                    tag: Tag::Multi,
765                    body: Body::MultiKeys {
766                        k: 2,
767                        indices: vec![0, 1],
768                    },
769                }]),
770            },
771            tlv: {
772                let mut t = TlvSection::new_empty();
773                t.fingerprints = fps;
774                t.pubkeys = pks;
775                t
776            },
777        };
778        let xpub = deterministic_xpub();
779        // Full: both @0 and @1 have fp+xpub.
780        let d_full = mk_d(
781            Some(vec![(0, [0x11; 4]), (1, [0x22; 4])]),
782            Some(vec![(0, xpub), (1, xpub)]),
783        );
784        // Mixed: @0 cell-7, @1 cell-1 (no fp, no xpub).
785        let d_mixed = mk_d(Some(vec![(0, [0x11; 4])]), Some(vec![(0, xpub)]));
786        let id_full = compute_wallet_policy_id(&d_full).unwrap();
787        let id_mixed = compute_wallet_policy_id(&d_mixed).unwrap();
788        assert_ne!(id_full, id_mixed);
789    }
790
791    /// Same per-`@N` records under two different wrapper tags
792    /// (`wpkh(@0)` vs `pkh(@0)`) → distinct WalletPolicyId. Wrapper
793    /// context is hashed via canonical_template_tree_bytes.
794    #[test]
795    fn walletpolicyid_wrapper_context_in_template_hash() {
796        let d_wpkh = cell_7_wpkh_descriptor();
797        let mut d_pkh = cell_7_wpkh_descriptor();
798        d_pkh.tree = Node {
799            tag: Tag::Pkh,
800            body: Body::KeyArg { index: 0 },
801        };
802        // Force same canonical record by overriding origin to the
803        // (BIP-44) canonical for pkh — so the only difference is the
804        // wrapper tag in the template tree.
805        d_pkh.path_decl = PathDecl {
806            n: 1,
807            paths: PathDeclPaths::Shared(OriginPath {
808                components: vec![
809                    PathComponent {
810                        hardened: true,
811                        value: 44,
812                    },
813                    PathComponent {
814                        hardened: true,
815                        value: 0,
816                    },
817                    PathComponent {
818                        hardened: true,
819                        value: 0,
820                    },
821                ],
822            }),
823        };
824        // Reset to wpkh's canonical so records share the bytewise
825        // origin path — this isolates wrapper-context-only difference.
826        d_pkh.path_decl = d_wpkh.path_decl.clone();
827        let id_wpkh = compute_wallet_policy_id(&d_wpkh).unwrap();
828        let id_pkh = compute_wallet_policy_id(&d_pkh).unwrap();
829        assert_ne!(id_wpkh, id_pkh);
830    }
831
832    /// Hand-construct two preimages identical except for nonzero
833    /// reserved bits in `presence_byte`; they MUST hash to the same
834    /// 16-byte WalletPolicyId because the encoder masks reserved bits
835    /// to 0 before writing the byte. Property is enforced indirectly:
836    /// since `compute_wallet_policy_id` is the only public entry point
837    /// and it always masks via `& 0b0000_0011`, two descriptors that
838    /// agree on (fp, xpub) presence must produce identical IDs even if
839    /// the underlying hash bytes were ever drift-injected. This test
840    /// hashes two by-hand preimages to prove SHA-256 is mask-stable.
841    #[test]
842    fn walletpolicyid_reserved_bits_masking_property() {
843        // Construct two preimages: one with presence_byte = 0b11 = 0x03,
844        // one with presence_byte = 0b1111_1111 = 0xff. Apply the
845        // encoder's mask 0b0000_0011 to both BEFORE hashing — both
846        // should reduce to 0x03 and produce the same hash.
847        let common = vec![0x00u8, 0x42, 0x42, 0x42];
848        // Apply the encoder's mask to two distinct candidate presence
849        // bytes (low-bits-only vs. all-ones) — both reduce to 0x03.
850        let candidates = [0b0000_0011u8, 0b1111_1111u8];
851        let mask = 0b0000_0011u8;
852        let masked_a = candidates[0] & mask;
853        let masked_b = candidates[1] & mask;
854        assert_eq!(masked_a, masked_b);
855        let mut input_a = common.clone();
856        input_a.push(masked_a);
857        let mut input_b = common.clone();
858        input_b.push(masked_b);
859        let h_a = bitcoin::hashes::sha256::Hash::hash(&input_a);
860        let h_b = bitcoin::hashes::sha256::Hash::hash(&input_b);
861        assert_eq!(h_a, h_b);
862
863        // Sanity: WITHOUT masking, the hashes differ — proving the
864        // mask is the load-bearing step.
865        let mut unmasked_a = common.clone();
866        unmasked_a.push(candidates[0]);
867        let mut unmasked_b = common.clone();
868        unmasked_b.push(candidates[1]);
869        let h_a_raw = bitcoin::hashes::sha256::Hash::hash(&unmasked_a);
870        let h_b_raw = bitcoin::hashes::sha256::Hash::hash(&unmasked_b);
871        assert_ne!(h_a_raw, h_b_raw);
872    }
873
874    /// `to_phrase()` round-trips through Phrase::from_id_bytes and
875    /// returns 12 BIP 39 words for any non-trivial id.
876    #[test]
877    fn walletpolicyid_to_phrase_returns_12_bip39_words() {
878        let d = cell_7_wpkh_descriptor();
879        let id = compute_wallet_policy_id(&d).unwrap();
880        let phrase = id.to_phrase().unwrap();
881        assert_eq!(phrase.0.len(), 12);
882        for word in &phrase.0 {
883            assert!(!word.is_empty());
884        }
885    }
886
887    /// `compute_wallet_policy_id` canonicalizes its input internally:
888    /// `tr(multi(2, @1, @0))` (non-canonical) and the canonical
889    /// equivalent `tr(multi(2, @0, @1))` (with TLVs renumbered
890    /// consistently) produce identical IDs.
891    #[test]
892    fn compute_wallet_policy_id_canonicalizes_first() {
893        #[allow(dead_code)]
894        fn pkk(index: u8) -> Node {
895            Node {
896                tag: Tag::PkK,
897                body: Body::KeyArg { index },
898            }
899        }
900        let xpub_a = deterministic_xpub();
901        let mut xpub_b = deterministic_xpub();
902        xpub_b[0] = 0x33;
903        let bip48_2 = OriginPath {
904            components: vec![
905                PathComponent {
906                    hardened: true,
907                    value: 48,
908                },
909                PathComponent {
910                    hardened: true,
911                    value: 0,
912                },
913                PathComponent {
914                    hardened: true,
915                    value: 0,
916                },
917                PathComponent {
918                    hardened: true,
919                    value: 2,
920                },
921            ],
922        };
923        // Non-canonical: tree first-occurrence is @1 then @0; pubkeys
924        // wired by original index — A↔@0, B↔@1.
925        let d_non_canonical = Descriptor {
926            n: 2,
927            path_decl: PathDecl {
928                n: 2,
929                paths: PathDeclPaths::Shared(bip48_2.clone()),
930            },
931            use_site_path: UseSitePath::standard_multipath(),
932            tree: Node {
933                tag: Tag::Wsh,
934                body: Body::Children(vec![Node {
935                    tag: Tag::Multi,
936                    body: Body::MultiKeys {
937                        k: 2,
938                        indices: vec![1, 0],
939                    },
940                }]),
941            },
942            tlv: {
943                let mut t = TlvSection::new_empty();
944                t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b)]);
945                t
946            },
947        };
948        // Canonical equivalent: tree first-occurrence is @0 then @1;
949        // pubkeys renumbered to match (original-@1 → new-@0 → carries B,
950        // original-@0 → new-@1 → carries A).
951        let d_canonical = Descriptor {
952            n: 2,
953            path_decl: PathDecl {
954                n: 2,
955                paths: PathDeclPaths::Shared(bip48_2),
956            },
957            use_site_path: UseSitePath::standard_multipath(),
958            tree: Node {
959                tag: Tag::Wsh,
960                body: Body::Children(vec![Node {
961                    tag: Tag::Multi,
962                    body: Body::MultiKeys {
963                        k: 2,
964                        indices: vec![0, 1],
965                    },
966                }]),
967            },
968            tlv: {
969                let mut t = TlvSection::new_empty();
970                t.pubkeys = Some(vec![(0, xpub_b), (1, xpub_a)]);
971                t
972            },
973        };
974        let id_nc = compute_wallet_policy_id(&d_non_canonical).unwrap();
975        let id_c = compute_wallet_policy_id(&d_canonical).unwrap();
976        assert_eq!(id_nc, id_c);
977    }
978
979    // ─── validate_presence_byte (v0.13.1, spec §5.3) ─────────────────
980
981    #[test]
982    fn validate_presence_byte_accepts_all_four_legal_combinations() {
983        for byte in [0b00, 0b01, 0b10, 0b11] {
984            validate_presence_byte(byte).unwrap();
985        }
986    }
987
988    #[test]
989    fn validate_presence_byte_rejects_lowest_reserved_bit() {
990        // bit 2 set
991        let err = validate_presence_byte(0b0000_0100).unwrap_err();
992        assert!(matches!(
993            err,
994            Error::InvalidPresenceByte {
995                reserved_bits: 0b0000_0100
996            }
997        ));
998    }
999
1000    #[test]
1001    fn validate_presence_byte_rejects_high_reserved_bit_with_legal_low_bits() {
1002        // bit 7 set + fp_present + xpub_present
1003        let err = validate_presence_byte(0b1000_0011).unwrap_err();
1004        assert!(matches!(
1005            err,
1006            Error::InvalidPresenceByte {
1007                reserved_bits: 0b1000_0000
1008            }
1009        ));
1010    }
1011
1012    #[test]
1013    fn validate_presence_byte_rejects_all_bits_set() {
1014        let err = validate_presence_byte(0xFF).unwrap_err();
1015        assert!(matches!(
1016            err,
1017            Error::InvalidPresenceByte {
1018                reserved_bits: 0b1111_1100
1019            }
1020        ));
1021    }
1022}