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