Skip to main content

mk_codec/bytecode/
encode.rs

1//! Top-level bytecode encoder: `KeyCard` → canonical `Vec<u8>`.
2//!
3//! Per `design/SPEC_mk_v0_1.md` §3.2 payload field order (closure Q-6):
4//!
5//! ```text
6//! [bytecode_header   : 1 B]
7//! [stub_count        : 1 B; MUST be ≥ 1]
8//! [policy_id_stubs   : 4 × N B]
9//! [origin_fingerprint: 4 B]   ← present iff bytecode_header bit 2 set
10//! [origin_path       : variable]
11//! [xpub_compact      : 73 B]
12//! ```
13
14use crate::bytecode::header::BytecodeHeader;
15use crate::bytecode::path::encode_path;
16use crate::bytecode::xpub_compact::{XpubCompact, encode_xpub_compact};
17use crate::error::{Error, Result};
18use crate::key_card::KeyCard;
19
20/// Encode a `KeyCard` to its canonical bytecode form (pre-chunking).
21pub fn encode_bytecode(card: &KeyCard) -> Result<Vec<u8>> {
22    if card.policy_id_stubs.is_empty() {
23        return Err(Error::InvalidPolicyIdStubCount);
24    }
25    if card.policy_id_stubs.len() > u8::MAX as usize {
26        return Err(Error::InvalidPolicyIdStubCount);
27    }
28
29    // Encoder-side invariant (SPEC_mk_v0_1.md §4): compact-73 reconstructs depth/
30    // child_number from origin_path on decode; reject any xpub whose depth/
31    // child_number disagree, else the emitted card decodes to a different-
32    // metadata xpub (the decoder cannot detect — no on-wire depth).
33    let path_depth = card.origin_path.into_iter().count();
34    let path_child = card.origin_path.into_iter().last().copied();
35    if card.xpub.depth as usize != path_depth || Some(card.xpub.child_number) != path_child {
36        return Err(Error::XpubOriginPathMismatch {
37            xpub_depth: card.xpub.depth,
38            path_depth: path_depth as u8,
39            xpub_child: card.xpub.child_number,
40            path_child,
41        });
42    }
43
44    let header = BytecodeHeader {
45        version: 0,
46        fingerprint_flag: card.origin_fingerprint.is_some(),
47    };
48
49    let mut out: Vec<u8> = Vec::new();
50    out.push(header.to_byte());
51    out.push(card.policy_id_stubs.len() as u8);
52    for stub in &card.policy_id_stubs {
53        out.extend_from_slice(stub);
54    }
55    if let Some(fp) = &card.origin_fingerprint {
56        out.extend_from_slice(fp.as_bytes());
57    }
58    out.extend_from_slice(&encode_path(&card.origin_path));
59    let compact = XpubCompact::from_xpub(&card.xpub);
60    encode_xpub_compact(&compact, &mut out);
61    Ok(out)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::bytecode::test_helpers::synthetic_xpub;
68    use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint};
69    use std::str::FromStr;
70
71    fn fixture_card_1stub_with_fp() -> KeyCard {
72        let path = DerivationPath::from_str("m/48'/0'/0'/2'").unwrap();
73        KeyCard {
74            policy_id_stubs: vec![[0xAA; 4]],
75            origin_fingerprint: Some(Fingerprint::from([0xD3, 0x4D, 0xB3, 0x3F])),
76            xpub: synthetic_xpub(&path),
77            origin_path: path,
78        }
79    }
80
81    #[test]
82    fn encodes_typical_1stub_card_to_84_bytes() {
83        let card = fixture_card_1stub_with_fp();
84        let wire = encode_bytecode(&card).unwrap();
85        // header(1) + stub_count(1) + 1*stub(4) + fp(4) + std-table indicator(1) + xpub_compact(73) = 84
86        assert_eq!(wire.len(), 84);
87        assert_eq!(wire[0], 0x04, "fingerprint flag set");
88        assert_eq!(wire[1], 1, "stub_count = 1");
89        assert_eq!(&wire[2..6], &[0xAA; 4], "stub bytes");
90        assert_eq!(&wire[6..10], &[0xD3, 0x4D, 0xB3, 0x3F], "fp bytes");
91        assert_eq!(wire[10], 0x05, "std-table indicator for m/48'/0'/0'/2'");
92    }
93
94    #[test]
95    fn encodes_card_without_fingerprint_to_80_bytes() {
96        let mut card = fixture_card_1stub_with_fp();
97        card.origin_fingerprint = None;
98        let wire = encode_bytecode(&card).unwrap();
99        // 84 - 4 (omitted fp) = 80
100        assert_eq!(wire.len(), 80);
101        assert_eq!(wire[0], 0x00, "fingerprint flag unset");
102    }
103
104    #[test]
105    fn rejects_zero_stubs() {
106        let mut card = fixture_card_1stub_with_fp();
107        card.policy_id_stubs.clear();
108        assert!(matches!(
109            encode_bytecode(&card),
110            Err(Error::InvalidPolicyIdStubCount),
111        ));
112    }
113
114    #[test]
115    fn deterministic_output() {
116        let card = fixture_card_1stub_with_fp();
117        let a = encode_bytecode(&card).unwrap();
118        let b = encode_bytecode(&card).unwrap();
119        assert_eq!(a, b, "encoder must be byte-deterministic");
120    }
121
122    // ── XpubOriginPathMismatch encoder-side guard (SPEC §5) ──────────────────
123
124    // Cell 1: xpub.depth ≠ component_count(origin_path) → reject.
125    #[test]
126    fn rejects_xpub_depth_mismatch() {
127        let mut card = fixture_card_1stub_with_fp(); // path m/48'/0'/0'/2' → depth 4
128        card.xpub.depth = 3;
129        assert!(matches!(
130            encode_bytecode(&card),
131            Err(Error::XpubOriginPathMismatch {
132                xpub_depth: 3,
133                path_depth: 4,
134                ..
135            }),
136        ));
137    }
138
139    // Cell 2 + 6: same depth, wrong terminal child (the previously-silent case;
140    // the fixture is a standard-table path, so this also covers the dictionary
141    // child-mismatch). A depth-only check (as the toolkit's does) would MISS this.
142    #[test]
143    fn rejects_xpub_child_mismatch_same_depth() {
144        let mut card = fixture_card_1stub_with_fp(); // terminal child = 2'
145        card.xpub.child_number = ChildNumber::Hardened { index: 1 }; // → 1', depth still 4
146        assert!(matches!(
147            encode_bytecode(&card),
148            Err(Error::XpubOriginPathMismatch { .. }),
149        ));
150    }
151
152    // Cell 3: empty origin_path (depth-0, hand-buildable via pub fields) → reject
153    // via the child clause (Some(Normal{0}) != None), no panic; path_child = None.
154    #[test]
155    fn rejects_empty_origin_path() {
156        let path = DerivationPath::from_str("m").unwrap(); // empty path
157        let card = KeyCard {
158            policy_id_stubs: vec![[0xAA; 4]],
159            origin_fingerprint: None,
160            xpub: synthetic_xpub(&path), // depth 0, child Normal{0}
161            origin_path: path,
162        };
163        assert!(matches!(
164            encode_bytecode(&card),
165            Err(Error::XpubOriginPathMismatch {
166                path_child: None,
167                ..
168            }),
169        ));
170    }
171
172    // Cell 4: an aligned EXPLICIT-path card (not in the standard table) encodes
173    // OK — guards against false-positives on explicit-mode paths. (The existing
174    // `encodes_typical_1stub_card_to_84_bytes` covers the standard-table-aligned
175    // case = SPEC cell 5; `xpub_compact.rs::round_trip_full_xpub_depth_4` covers
176    // the reconstruct round-trip = SPEC cell 4 losslessness.)
177    #[test]
178    fn aligned_explicit_path_card_encodes() {
179        let path = DerivationPath::from_str("m/44'/0'/0'/0/5").unwrap(); // 5 comps, explicit
180        let card = KeyCard {
181            policy_id_stubs: vec![[0xAA; 4]],
182            origin_fingerprint: None,
183            xpub: synthetic_xpub(&path), // depth 5, child Normal{5} — aligned
184            origin_path: path,
185        };
186        assert!(
187            encode_bytecode(&card).is_ok(),
188            "aligned explicit-path card must encode"
189        );
190    }
191}