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 upstream (MissingExplicitOrigin), so the unwrap_or_else
214 // fallback is unreachable-but-safe.
215 let origin_for_hash = if e.origin_path.components.is_empty() {
216 crate::canonical_origin::canonical_origin(&d.tree)
217 .unwrap_or_else(|| e.origin_path.clone())
218 } else {
219 e.origin_path.clone()
220 };
221 let mut path_scratch = BitWriter::new();
222 origin_for_hash.write(&mut path_scratch)?;
223 let path_bit_len = path_scratch.bit_len();
224 let path_bytes = path_scratch.into_bytes();
225
226 // Use-site path bits.
227 let mut us_scratch = BitWriter::new();
228 e.use_site_path.write(&mut us_scratch)?;
229 let use_site_bit_len = us_scratch.bit_len();
230 let us_bytes = us_scratch.into_bytes();
231
232 // Record bitstream: varint(path_bit_len) || path_bits ||
233 // varint(use_site_bit_len) || use_site_bits, with a single
234 // byte-boundary pad applied by into_bytes().
235 let mut record_bw = BitWriter::new();
236 write_varint(&mut record_bw, path_bit_len as u32)?;
237 re_emit_bits(&mut record_bw, &path_bytes, path_bit_len)?;
238 write_varint(&mut record_bw, use_site_bit_len as u32)?;
239 re_emit_bits(&mut record_bw, &us_bytes, use_site_bit_len)?;
240 let record_bytes = record_bw.into_bytes();
241
242 // Presence byte: bit 0 = fp, bit 1 = xpub; reserved bits 2..7
243 // are explicitly masked to 0 per spec §5.3 (forward-compat:
244 // future versions that define a reserved bit must not collide
245 // with v0.13's hash on the same wire).
246 let fp_present = e.fingerprint.is_some();
247 let xpub_present = e.xpub.is_some();
248 let presence_byte = ((fp_present as u8) | ((xpub_present as u8) << 1)) & 0b0000_0011;
249
250 records_concat.push(presence_byte);
251 records_concat.extend_from_slice(&record_bytes);
252 if let Some(fp) = e.fingerprint {
253 records_concat.extend_from_slice(&fp);
254 }
255 if let Some(xpub) = e.xpub {
256 records_concat.extend_from_slice(&xpub);
257 }
258 }
259
260 // Step 6–7: hash and truncate.
261 let mut hash_input: Vec<u8> =
262 Vec::with_capacity(canonical_template_tree_bytes.len() + records_concat.len());
263 hash_input.extend_from_slice(&canonical_template_tree_bytes);
264 hash_input.extend_from_slice(&records_concat);
265 let hash = sha256::Hash::hash(&hash_input);
266 let mut id = [0u8; 16];
267 id.copy_from_slice(&hash.to_byte_array()[0..16]);
268 Ok(WalletPolicyId(id))
269}
270
271/// Validate a `presence_byte` from a `WalletPolicyId` canonical-record
272/// preimage (spec v0.13 §5.3). Bit 0 = `fp_present`, bit 1 =
273/// `xpub_present`, bits 2..7 reserved (must be 0). Returns
274/// [`Error::InvalidPresenceByte`] with the offending reserved-bit
275/// field if any of bits 2..7 is set.
276///
277/// v0.13's encoder masks reserved bits when building the preimage, so
278/// this helper is unreachable on v0.13 wire today. It enforces the
279/// spec §5.3 "decoders MUST reject" clause for any future
280/// canonical-record consumer (e.g., a verification-mode tool that
281/// reconstructs the preimage to cross-check a `WalletPolicyId`).
282pub fn validate_presence_byte(byte: u8) -> Result<(), Error> {
283 let reserved_bits = byte & 0b1111_1100;
284 if reserved_bits != 0 {
285 return Err(Error::InvalidPresenceByte { reserved_bits });
286 }
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::origin_path::{OriginPath, PathComponent, PathDecl, PathDeclPaths};
294 use crate::tag::Tag;
295 use crate::tlv::TlvSection;
296 use crate::tree::{Body, Node};
297 use crate::use_site_path::UseSitePath;
298
299 fn bip84_descriptor() -> Descriptor {
300 Descriptor {
301 n: 1,
302 path_decl: PathDecl {
303 n: 1,
304 paths: PathDeclPaths::Shared(OriginPath {
305 components: vec![
306 PathComponent {
307 hardened: true,
308 value: 84,
309 },
310 PathComponent {
311 hardened: true,
312 value: 0,
313 },
314 PathComponent {
315 hardened: true,
316 value: 0,
317 },
318 ],
319 }),
320 },
321 use_site_path: UseSitePath::standard_multipath(),
322 tree: Node {
323 tag: Tag::Wpkh,
324 body: Body::KeyArg { index: 0 },
325 },
326 tlv: TlvSection::new_empty(),
327 }
328 }
329
330 #[test]
331 fn md1_encoding_id_deterministic() {
332 let d = bip84_descriptor();
333 let id1 = compute_md1_encoding_id(&d).unwrap();
334 let id2 = compute_md1_encoding_id(&d).unwrap();
335 assert_eq!(id1, id2);
336 }
337
338 #[test]
339 fn md1_encoding_id_differs_for_different_paths() {
340 let d1 = bip84_descriptor();
341 let mut d2 = bip84_descriptor();
342 if let PathDeclPaths::Shared(p) = &mut d2.path_decl.paths {
343 p.components[2] = PathComponent {
344 hardened: true,
345 value: 1,
346 };
347 }
348 let id1 = compute_md1_encoding_id(&d1).unwrap();
349 let id2 = compute_md1_encoding_id(&d2).unwrap();
350 assert_ne!(id1, id2);
351 }
352
353 #[test]
354 fn wdt_id_invariant_to_origin_path_change() {
355 let d1 = bip84_descriptor();
356 let mut d2 = bip84_descriptor();
357 if let PathDeclPaths::Shared(p) = &mut d2.path_decl.paths {
358 p.components[2] = PathComponent {
359 hardened: true,
360 value: 1,
361 };
362 }
363 let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
364 let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
365 // Same template structure (use-site path, tree) → same WDT-Id
366 assert_eq!(id1, id2);
367 }
368
369 #[test]
370 fn wdt_id_differs_for_different_use_site_paths() {
371 let d1 = bip84_descriptor();
372 let mut d2 = bip84_descriptor();
373 d2.use_site_path = UseSitePath {
374 multipath: None,
375 wildcard_hardened: false,
376 };
377 let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
378 let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
379 assert_ne!(id1, id2);
380 }
381
382 #[test]
383 fn wdt_id_invariant_to_fingerprint_addition() {
384 let d1 = bip84_descriptor();
385 let mut d2 = bip84_descriptor();
386 d2.tlv.fingerprints = Some(vec![(0u8, [0xaa, 0xbb, 0xcc, 0xdd])]);
387 let id1 = compute_wallet_descriptor_template_id(&d1).unwrap();
388 let id2 = compute_wallet_descriptor_template_id(&d2).unwrap();
389 // Fingerprints are excluded from WDT-Id hash domain
390 assert_eq!(id1, id2);
391 }
392
393 /// L15: the WDT-id must be invariant to placeholder-index permutation,
394 /// mirroring the policy-id's canonicalization. `wsh(multi(2,@1,@0))`
395 /// (non-canonical placeholder ordering) and `wsh(multi(2,@0,@1))`
396 /// (canonical) describe the same template and MUST share a WDT-id.
397 /// RED today (raw `*idx` is hashed without canonicalization); GREEN
398 /// after compute_wallet_descriptor_template_id canonicalizes a clone.
399 #[test]
400 fn wdt_id_invariant_to_placeholder_ordering() {
401 let mk_d = |indices: Vec<u8>| Descriptor {
402 n: 2,
403 path_decl: PathDecl {
404 n: 2,
405 paths: PathDeclPaths::Shared(OriginPath {
406 components: vec![
407 PathComponent {
408 hardened: true,
409 value: 48,
410 },
411 PathComponent {
412 hardened: true,
413 value: 0,
414 },
415 PathComponent {
416 hardened: true,
417 value: 0,
418 },
419 PathComponent {
420 hardened: true,
421 value: 2,
422 },
423 ],
424 }),
425 },
426 use_site_path: UseSitePath::standard_multipath(),
427 tree: Node {
428 tag: Tag::Wsh,
429 body: Body::Children(vec![Node {
430 tag: Tag::Multi,
431 body: Body::MultiKeys { k: 2, indices },
432 }]),
433 },
434 tlv: TlvSection::new_empty(),
435 };
436 // Non-canonical: tree first-occurrence is @1 then @0.
437 let d_non_canonical = mk_d(vec![1, 0]);
438 // Canonical: tree first-occurrence is @0 then @1.
439 let d_canonical = mk_d(vec![0, 1]);
440 let id_nc = compute_wallet_descriptor_template_id(&d_non_canonical).unwrap();
441 let id_c = compute_wallet_descriptor_template_id(&d_canonical).unwrap();
442 assert_eq!(id_nc, id_c);
443 }
444
445 // ---- v0.13 WalletPolicyId tests ----
446
447 /// Build a deterministic 65-byte xpub for tests: 32 bytes of `0x11`
448 /// (chain code) followed by `0x02 || [0x22; 32]` (compressed pubkey
449 /// with even Y prefix). The pubkey bytes are NOT a valid secp256k1
450 /// point; tests that exercise §6.4 (`InvalidXpubBytes`) will use a
451 /// real point. Phase 4 only hashes raw bytes.
452 fn deterministic_xpub() -> [u8; 65] {
453 let mut x = [0u8; 65];
454 for b in x.iter_mut().take(32) {
455 *b = 0x11;
456 }
457 x[32] = 0x02;
458 for b in x.iter_mut().skip(33) {
459 *b = 0x22;
460 }
461 x
462 }
463
464 /// Construct the dominant case: 1-of-1 cell-7 wpkh wallet with fp
465 /// 0xDEADBEEF and a deterministic xpub at canonical BIP 84 origin.
466 fn cell_7_wpkh_descriptor() -> Descriptor {
467 Descriptor {
468 n: 1,
469 path_decl: PathDecl {
470 n: 1,
471 paths: PathDeclPaths::Shared(OriginPath {
472 components: vec![
473 PathComponent {
474 hardened: true,
475 value: 84,
476 },
477 PathComponent {
478 hardened: true,
479 value: 0,
480 },
481 PathComponent {
482 hardened: true,
483 value: 0,
484 },
485 ],
486 }),
487 },
488 use_site_path: UseSitePath::standard_multipath(),
489 tree: Node {
490 tag: Tag::Wpkh,
491 body: Body::KeyArg { index: 0 },
492 },
493 tlv: {
494 let mut t = TlvSection::new_empty();
495 t.fingerprints = Some(vec![(0u8, [0xDE, 0xAD, 0xBE, 0xEF])]);
496 t.pubkeys = Some(vec![(0u8, deterministic_xpub())]);
497 t
498 },
499 }
500 }
501
502 /// **GOLDEN VECTOR** (load-bearing): byte-exact construction of the
503 /// 1-of-1 cell-7 wpkh `WalletPolicyId` preimage and SHA-256 truncation.
504 ///
505 /// Component bit budget (hand-derived; locks LP4-ext varint unit
506 /// semantics — lengths are in bits, not bytes):
507 ///
508 /// ```text
509 /// canonical_template_tree:
510 /// Tag::Wpkh primary code 0x00 (5 bits) = 5 bits
511 /// KeyArg index @0 (kiw=0 since n=1) = 0 bits
512 /// --------------------------------------------------
513 /// total = 5 bits
514 /// into_bytes() zero-pads to 1 byte = 0x00
515 ///
516 /// origin path m/84'/0'/0':
517 /// depth=3 (4 bits) = 4
518 /// 84' hardened(1) + varint(84) = 1 + (4 + 7) = 12
519 /// 0' hardened(1) + varint(0) = 1 + (4 + 0) = 5
520 /// 0' hardened(1) + varint(0) = 1 + (4 + 0) = 5
521 /// ------------------------------------------------
522 /// total = 26 bits
523 ///
524 /// use-site <0;1>/*:
525 /// has-mp=1 (1) + alt_count-2=0 (3) = 4
526 /// alt0: hardened=0 (1) + varint(0)=4 = 5
527 /// alt1: hardened=0 (1) + varint(1)=5 = 6
528 /// wildcard_hardened=0 (1) = 1
529 /// ------------------------------------------------
530 /// total = 16 bits
531 ///
532 /// record_bw bits:
533 /// varint(26): L=5 (4 bits) + 5-bit payload = 9
534 /// path bits (re-emitted) = 26
535 /// varint(16): L=5 (4 bits) + 5-bit payload = 9
536 /// use-site bits (re-emitted) = 16
537 /// ------------------------------------------------
538 /// total = 60 bits
539 /// into_bytes() zero-pads to 8 bytes (64 bits)
540 ///
541 /// presence_byte = (1 | 1<<1) & 0b11 = 0x03
542 /// fp = [DE, AD, BE, EF] (4 bytes)
543 /// xpub = [11; 32] || 02 || [22; 32] (65 bytes)
544 /// record total = 1 + 8 + 4 + 65 = 78 bytes
545 /// hash_input = canonical_template_tree(1) || record(78) = 79 bytes
546 /// ```
547 ///
548 /// Expected bytes computed independently in `/tmp/golden_vec.py`.
549 #[test]
550 fn golden_vector_wpkh_cell_7() {
551 let d = cell_7_wpkh_descriptor();
552
553 // Independently re-construct the canonical bitstream so the
554 // arithmetic assertion (LP4-ext varint unit confusion gate) is
555 // checked against locally-computed lengths. We mirror the
556 // implementation's component writes here so a unit-confusion
557 // bug surfaces in the assertion below before SHA-256 swallows
558 // it.
559 let path = match &d.path_decl.paths {
560 PathDeclPaths::Shared(p) => p.clone(),
561 _ => panic!("test fixture is shared"),
562 };
563 let mut path_scratch = crate::bitstream::BitWriter::new();
564 path.write(&mut path_scratch).unwrap();
565 let path_bit_len = path_scratch.bit_len();
566 let path_bytes = path_scratch.into_bytes();
567 assert_eq!(path_bit_len, 26, "BIP-84 origin path is 26 bits");
568 assert_eq!(path_bytes, vec![0x3b, 0xd4, 0x84, 0x00]);
569
570 let mut us_scratch = crate::bitstream::BitWriter::new();
571 d.use_site_path.write(&mut us_scratch).unwrap();
572 let use_site_bit_len = us_scratch.bit_len();
573 let us_bytes = us_scratch.into_bytes();
574 assert_eq!(use_site_bit_len, 16, "<0;1>/* use-site is 16 bits");
575 assert_eq!(us_bytes, vec![0x80, 0x06]);
576
577 // Record bitstream construction must match impl exactly.
578 let mut record_bw = crate::bitstream::BitWriter::new();
579 crate::varint::write_varint(&mut record_bw, path_bit_len as u32).unwrap();
580 crate::bitstream::re_emit_bits(&mut record_bw, &path_bytes, path_bit_len).unwrap();
581 crate::varint::write_varint(&mut record_bw, use_site_bit_len as u32).unwrap();
582 crate::bitstream::re_emit_bits(&mut record_bw, &us_bytes, use_site_bit_len).unwrap();
583
584 // ARITHMETIC ASSERTION — load-bearing. varint(26)=9 bits and
585 // varint(16)=9 bits (both need a 5-bit payload because L=5).
586 // Total = 9 + 26 + 9 + 16 = 60. If lengths were in *bytes* (a
587 // common bug), the encoded varints would be much smaller (L=2
588 // for both → 6 bits each) and this assertion would fail.
589 let varint_path_cost = 4 + (32 - (path_bit_len as u32).leading_zeros()) as usize;
590 let varint_us_cost = 4 + (32 - (use_site_bit_len as u32).leading_zeros()) as usize;
591 let expected_record_bits =
592 varint_path_cost + path_bit_len + varint_us_cost + use_site_bit_len;
593 assert_eq!(record_bw.bit_len(), expected_record_bits);
594 assert_eq!(record_bw.bit_len(), 60, "cell-7 record is 60 bits");
595
596 let record_bytes = record_bw.into_bytes();
597 assert_eq!(
598 record_bytes,
599 vec![0x5d, 0x1d, 0xea, 0x42, 0x0b, 0x08, 0x00, 0x60]
600 );
601
602 // Canonical template tree: 5-bit Wpkh primary tag, zero-padded
603 // to one byte.
604 let mut tree_w = crate::bitstream::BitWriter::new();
605 crate::tree::write_node(&mut tree_w, &d.tree, d.key_index_width()).unwrap();
606 let tree_bytes = tree_w.into_bytes();
607 assert_eq!(tree_bytes, vec![0x00]);
608
609 // Full hash input — byte-by-byte.
610 let presence_byte: u8 = 0x03;
611 let fp = [0xDE, 0xAD, 0xBE, 0xEF];
612 let xpub = deterministic_xpub();
613 let mut expected_hash_input: Vec<u8> = Vec::new();
614 expected_hash_input.extend_from_slice(&tree_bytes);
615 expected_hash_input.push(presence_byte);
616 expected_hash_input.extend_from_slice(&record_bytes);
617 expected_hash_input.extend_from_slice(&fp);
618 expected_hash_input.extend_from_slice(&xpub);
619 assert_eq!(expected_hash_input.len(), 79);
620
621 let expected_hex = "00035d1dea420b080060deadbeef\
622 1111111111111111111111111111111111111111111111111111111111111111\
623 02\
624 2222222222222222222222222222222222222222222222222222222222222222";
625 assert_eq!(hex(&expected_hash_input), expected_hex);
626
627 // Final identity bytes (computed by /tmp/golden_vec.py).
628 let expected_id: [u8; 16] = [
629 0x66, 0x50, 0xb9, 0x80, 0x3b, 0x3c, 0x66, 0x21, 0x01, 0x40, 0x54, 0x0d, 0xa8, 0xd7,
630 0x65, 0xa0,
631 ];
632
633 let id = compute_wallet_policy_id(&d).unwrap();
634 assert_eq!(*id.as_bytes(), expected_id);
635 }
636
637 /// Trivial hex helper for byte-exact assertions in the golden test.
638 fn hex(bs: &[u8]) -> String {
639 let mut s = String::with_capacity(bs.len() * 2);
640 for b in bs {
641 s.push_str(&format!("{:02x}", b));
642 }
643 s
644 }
645
646 /// Two encodings of the same logical wallet — one with the canonical
647 /// path explicitly written, one with no explicit path (the encoder
648 /// fills `canonical_origin` into `path_decl` per Option A) — produce
649 /// identical WalletPolicyId. (In practice, both have the same
650 /// `path_decl` payload after canonicalization; this test pins the
651 /// invariant for the trivial case.)
652 #[test]
653 fn walletpolicyid_stable_across_origin_elision() {
654 // Explicit: wpkh(@0) with path_decl = Shared(m/84'/0'/0').
655 let d_explicit = cell_7_wpkh_descriptor();
656 // Elided: same wpkh(@0) wallet, but path_decl is a genuinely EMPTY
657 // Shared origin (no explicit path). The canonical wrapper
658 // (wpkh → m/84'/0'/0') supplies the path at hash time via the L14
659 // canonical-fill. RED today (the empty path hashes a 0000 length
660 // prefix + no components, differing from the explicit component
661 // bits); GREEN after the L14 fill.
662 let mut d_elided = cell_7_wpkh_descriptor();
663 d_elided.path_decl = PathDecl {
664 n: 1,
665 paths: PathDeclPaths::Shared(OriginPath { components: vec![] }),
666 };
667 let id_explicit = compute_wallet_policy_id(&d_explicit).unwrap();
668 let id_elided = compute_wallet_policy_id(&d_elided).unwrap();
669 // The documented "stable across origin-elision" invariant: the
670 // elided form, canonical-filled, must hash identically to the
671 // explicit form.
672 assert_eq!(id_explicit, id_elided);
673 }
674
675 /// Use-site path supplied as the descriptor baseline vs supplied via
676 /// `UseSitePathOverrides[0]` — same resolved bits → same ID.
677 #[test]
678 fn walletpolicyid_stable_across_use_site_elision() {
679 let d_baseline = cell_7_wpkh_descriptor();
680 let mut d_override = cell_7_wpkh_descriptor();
681 d_override.use_site_path = UseSitePath {
682 multipath: None,
683 wildcard_hardened: false,
684 };
685 d_override.tlv.use_site_path_overrides =
686 Some(vec![(0u8, UseSitePath::standard_multipath())]);
687 let id1 = compute_wallet_policy_id(&d_baseline).unwrap();
688 let id2 = compute_wallet_policy_id(&d_override).unwrap();
689 assert_eq!(id1, id2);
690 }
691
692 /// Template-only (no fp, no xpub) WalletPolicyId differs from the
693 /// fully-keyed cell-7 version — presence-significance gate.
694 #[test]
695 fn walletpolicyid_template_only_differs_from_full_cell_7() {
696 let full = cell_7_wpkh_descriptor();
697 let mut template_only = cell_7_wpkh_descriptor();
698 template_only.tlv.fingerprints = None;
699 template_only.tlv.pubkeys = None;
700 let id_full = compute_wallet_policy_id(&full).unwrap();
701 let id_template = compute_wallet_policy_id(&template_only).unwrap();
702 assert_ne!(id_full, id_template);
703 }
704
705 /// 2-of-2 wsh(multi) with `@0` cell-7 (fp+xpub) and `@1` cell-1
706 /// (template-only). presence_bytes are 0b11 and 0b00 respectively;
707 /// distinct from a "both fully populated" or "both template-only"
708 /// version.
709 #[test]
710 fn walletpolicyid_partial_keys_distinct() {
711 #[allow(dead_code)]
712 fn pkk(index: u8) -> Node {
713 Node {
714 tag: Tag::PkK,
715 body: Body::KeyArg { index },
716 }
717 }
718 let bip48_2 = OriginPath {
719 components: vec![
720 PathComponent {
721 hardened: true,
722 value: 48,
723 },
724 PathComponent {
725 hardened: true,
726 value: 0,
727 },
728 PathComponent {
729 hardened: true,
730 value: 0,
731 },
732 PathComponent {
733 hardened: true,
734 value: 2,
735 },
736 ],
737 };
738 let mk_d = |fps: Option<Vec<(u8, [u8; 4])>>, pks: Option<Vec<(u8, [u8; 65])>>| Descriptor {
739 n: 2,
740 path_decl: PathDecl {
741 n: 2,
742 paths: PathDeclPaths::Shared(bip48_2.clone()),
743 },
744 use_site_path: UseSitePath::standard_multipath(),
745 tree: Node {
746 tag: Tag::Wsh,
747 body: Body::Children(vec![Node {
748 tag: Tag::Multi,
749 body: Body::MultiKeys {
750 k: 2,
751 indices: vec![0, 1],
752 },
753 }]),
754 },
755 tlv: {
756 let mut t = TlvSection::new_empty();
757 t.fingerprints = fps;
758 t.pubkeys = pks;
759 t
760 },
761 };
762 let xpub = deterministic_xpub();
763 // Full: both @0 and @1 have fp+xpub.
764 let d_full = mk_d(
765 Some(vec![(0, [0x11; 4]), (1, [0x22; 4])]),
766 Some(vec![(0, xpub), (1, xpub)]),
767 );
768 // Mixed: @0 cell-7, @1 cell-1 (no fp, no xpub).
769 let d_mixed = mk_d(Some(vec![(0, [0x11; 4])]), Some(vec![(0, xpub)]));
770 let id_full = compute_wallet_policy_id(&d_full).unwrap();
771 let id_mixed = compute_wallet_policy_id(&d_mixed).unwrap();
772 assert_ne!(id_full, id_mixed);
773 }
774
775 /// Same per-`@N` records under two different wrapper tags
776 /// (`wpkh(@0)` vs `pkh(@0)`) → distinct WalletPolicyId. Wrapper
777 /// context is hashed via canonical_template_tree_bytes.
778 #[test]
779 fn walletpolicyid_wrapper_context_in_template_hash() {
780 let d_wpkh = cell_7_wpkh_descriptor();
781 let mut d_pkh = cell_7_wpkh_descriptor();
782 d_pkh.tree = Node {
783 tag: Tag::Pkh,
784 body: Body::KeyArg { index: 0 },
785 };
786 // Force same canonical record by overriding origin to the
787 // (BIP-44) canonical for pkh — so the only difference is the
788 // wrapper tag in the template tree.
789 d_pkh.path_decl = PathDecl {
790 n: 1,
791 paths: PathDeclPaths::Shared(OriginPath {
792 components: vec![
793 PathComponent {
794 hardened: true,
795 value: 44,
796 },
797 PathComponent {
798 hardened: true,
799 value: 0,
800 },
801 PathComponent {
802 hardened: true,
803 value: 0,
804 },
805 ],
806 }),
807 };
808 // Reset to wpkh's canonical so records share the bytewise
809 // origin path — this isolates wrapper-context-only difference.
810 d_pkh.path_decl = d_wpkh.path_decl.clone();
811 let id_wpkh = compute_wallet_policy_id(&d_wpkh).unwrap();
812 let id_pkh = compute_wallet_policy_id(&d_pkh).unwrap();
813 assert_ne!(id_wpkh, id_pkh);
814 }
815
816 /// Hand-construct two preimages identical except for nonzero
817 /// reserved bits in `presence_byte`; they MUST hash to the same
818 /// 16-byte WalletPolicyId because the encoder masks reserved bits
819 /// to 0 before writing the byte. Property is enforced indirectly:
820 /// since `compute_wallet_policy_id` is the only public entry point
821 /// and it always masks via `& 0b0000_0011`, two descriptors that
822 /// agree on (fp, xpub) presence must produce identical IDs even if
823 /// the underlying hash bytes were ever drift-injected. This test
824 /// hashes two by-hand preimages to prove SHA-256 is mask-stable.
825 #[test]
826 fn walletpolicyid_reserved_bits_masking_property() {
827 // Construct two preimages: one with presence_byte = 0b11 = 0x03,
828 // one with presence_byte = 0b1111_1111 = 0xff. Apply the
829 // encoder's mask 0b0000_0011 to both BEFORE hashing — both
830 // should reduce to 0x03 and produce the same hash.
831 let common = vec![0x00u8, 0x42, 0x42, 0x42];
832 // Apply the encoder's mask to two distinct candidate presence
833 // bytes (low-bits-only vs. all-ones) — both reduce to 0x03.
834 let candidates = [0b0000_0011u8, 0b1111_1111u8];
835 let mask = 0b0000_0011u8;
836 let masked_a = candidates[0] & mask;
837 let masked_b = candidates[1] & mask;
838 assert_eq!(masked_a, masked_b);
839 let mut input_a = common.clone();
840 input_a.push(masked_a);
841 let mut input_b = common.clone();
842 input_b.push(masked_b);
843 let h_a = bitcoin::hashes::sha256::Hash::hash(&input_a);
844 let h_b = bitcoin::hashes::sha256::Hash::hash(&input_b);
845 assert_eq!(h_a, h_b);
846
847 // Sanity: WITHOUT masking, the hashes differ — proving the
848 // mask is the load-bearing step.
849 let mut unmasked_a = common.clone();
850 unmasked_a.push(candidates[0]);
851 let mut unmasked_b = common.clone();
852 unmasked_b.push(candidates[1]);
853 let h_a_raw = bitcoin::hashes::sha256::Hash::hash(&unmasked_a);
854 let h_b_raw = bitcoin::hashes::sha256::Hash::hash(&unmasked_b);
855 assert_ne!(h_a_raw, h_b_raw);
856 }
857
858 /// `to_phrase()` round-trips through Phrase::from_id_bytes and
859 /// returns 12 BIP 39 words for any non-trivial id.
860 #[test]
861 fn walletpolicyid_to_phrase_returns_12_bip39_words() {
862 let d = cell_7_wpkh_descriptor();
863 let id = compute_wallet_policy_id(&d).unwrap();
864 let phrase = id.to_phrase().unwrap();
865 assert_eq!(phrase.0.len(), 12);
866 for word in &phrase.0 {
867 assert!(!word.is_empty());
868 }
869 }
870
871 /// `compute_wallet_policy_id` canonicalizes its input internally:
872 /// `tr(multi(2, @1, @0))` (non-canonical) and the canonical
873 /// equivalent `tr(multi(2, @0, @1))` (with TLVs renumbered
874 /// consistently) produce identical IDs.
875 #[test]
876 fn compute_wallet_policy_id_canonicalizes_first() {
877 #[allow(dead_code)]
878 fn pkk(index: u8) -> Node {
879 Node {
880 tag: Tag::PkK,
881 body: Body::KeyArg { index },
882 }
883 }
884 let xpub_a = deterministic_xpub();
885 let mut xpub_b = deterministic_xpub();
886 xpub_b[0] = 0x33;
887 let bip48_2 = OriginPath {
888 components: vec![
889 PathComponent {
890 hardened: true,
891 value: 48,
892 },
893 PathComponent {
894 hardened: true,
895 value: 0,
896 },
897 PathComponent {
898 hardened: true,
899 value: 0,
900 },
901 PathComponent {
902 hardened: true,
903 value: 2,
904 },
905 ],
906 };
907 // Non-canonical: tree first-occurrence is @1 then @0; pubkeys
908 // wired by original index — A↔@0, B↔@1.
909 let d_non_canonical = Descriptor {
910 n: 2,
911 path_decl: PathDecl {
912 n: 2,
913 paths: PathDeclPaths::Shared(bip48_2.clone()),
914 },
915 use_site_path: UseSitePath::standard_multipath(),
916 tree: Node {
917 tag: Tag::Wsh,
918 body: Body::Children(vec![Node {
919 tag: Tag::Multi,
920 body: Body::MultiKeys {
921 k: 2,
922 indices: vec![1, 0],
923 },
924 }]),
925 },
926 tlv: {
927 let mut t = TlvSection::new_empty();
928 t.pubkeys = Some(vec![(0, xpub_a), (1, xpub_b)]);
929 t
930 },
931 };
932 // Canonical equivalent: tree first-occurrence is @0 then @1;
933 // pubkeys renumbered to match (original-@1 → new-@0 → carries B,
934 // original-@0 → new-@1 → carries A).
935 let d_canonical = Descriptor {
936 n: 2,
937 path_decl: PathDecl {
938 n: 2,
939 paths: PathDeclPaths::Shared(bip48_2),
940 },
941 use_site_path: UseSitePath::standard_multipath(),
942 tree: Node {
943 tag: Tag::Wsh,
944 body: Body::Children(vec![Node {
945 tag: Tag::Multi,
946 body: Body::MultiKeys {
947 k: 2,
948 indices: vec![0, 1],
949 },
950 }]),
951 },
952 tlv: {
953 let mut t = TlvSection::new_empty();
954 t.pubkeys = Some(vec![(0, xpub_b), (1, xpub_a)]);
955 t
956 },
957 };
958 let id_nc = compute_wallet_policy_id(&d_non_canonical).unwrap();
959 let id_c = compute_wallet_policy_id(&d_canonical).unwrap();
960 assert_eq!(id_nc, id_c);
961 }
962
963 // ─── validate_presence_byte (v0.13.1, spec §5.3) ─────────────────
964
965 #[test]
966 fn validate_presence_byte_accepts_all_four_legal_combinations() {
967 for byte in [0b00, 0b01, 0b10, 0b11] {
968 validate_presence_byte(byte).unwrap();
969 }
970 }
971
972 #[test]
973 fn validate_presence_byte_rejects_lowest_reserved_bit() {
974 // bit 2 set
975 let err = validate_presence_byte(0b0000_0100).unwrap_err();
976 assert!(matches!(
977 err,
978 Error::InvalidPresenceByte {
979 reserved_bits: 0b0000_0100
980 }
981 ));
982 }
983
984 #[test]
985 fn validate_presence_byte_rejects_high_reserved_bit_with_legal_low_bits() {
986 // bit 7 set + fp_present + xpub_present
987 let err = validate_presence_byte(0b1000_0011).unwrap_err();
988 assert!(matches!(
989 err,
990 Error::InvalidPresenceByte {
991 reserved_bits: 0b1000_0000
992 }
993 ));
994 }
995
996 #[test]
997 fn validate_presence_byte_rejects_all_bits_set() {
998 let err = validate_presence_byte(0xFF).unwrap_err();
999 assert!(matches!(
1000 err,
1001 Error::InvalidPresenceByte {
1002 reserved_bits: 0b1111_1100
1003 }
1004 ));
1005 }
1006}