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 bitcoin::bip32::ChildNumber;
15
16use crate::bytecode::header::BytecodeHeader;
17use crate::bytecode::path::encode_path;
18use crate::bytecode::xpub_compact::{XpubCompact, encode_xpub_compact};
19use crate::error::{Error, Result};
20use crate::key_card::KeyCard;
21
22/// Encode a `KeyCard` to its canonical bytecode form (pre-chunking).
23pub fn encode_bytecode(card: &KeyCard) -> Result<Vec<u8>> {
24    if card.policy_id_stubs.is_empty() {
25        return Err(Error::InvalidPolicyIdStubCount);
26    }
27    if card.policy_id_stubs.len() > u8::MAX as usize {
28        return Err(Error::InvalidPolicyIdStubCount);
29    }
30
31    // Encoder-side invariant (SPEC_mk_v0_1.md §4): compact-73 reconstructs depth/
32    // child_number from origin_path on decode; reject any xpub whose depth/
33    // child_number disagree, else the emitted card decodes to a different-
34    // metadata xpub (the decoder cannot detect — no on-wire depth).
35    // expected_child mirrors reconstruct_xpub exactly: the terminal component,
36    // or Normal{0} for an empty path (depth-0 / no-path key, e.g. a WIF). A card
37    // encodes iff it survives compact-drop + reconstruction unchanged.
38    let path_depth = card.origin_path.into_iter().count();
39    let path_child = card.origin_path.into_iter().last().copied();
40    let expected_child = path_child.unwrap_or(ChildNumber::Normal { index: 0 });
41    if card.xpub.depth as usize != path_depth || card.xpub.child_number != expected_child {
42        return Err(Error::XpubOriginPathMismatch {
43            xpub_depth: card.xpub.depth,
44            path_depth: path_depth as u8,
45            xpub_child: card.xpub.child_number,
46            path_child,
47        });
48    }
49
50    let header = BytecodeHeader {
51        version: 0,
52        fingerprint_flag: card.origin_fingerprint.is_some(),
53    };
54
55    let mut out: Vec<u8> = Vec::new();
56    out.push(header.to_byte());
57    out.push(card.policy_id_stubs.len() as u8);
58    for stub in &card.policy_id_stubs {
59        out.extend_from_slice(stub);
60    }
61    if let Some(fp) = &card.origin_fingerprint {
62        out.extend_from_slice(fp.as_bytes());
63    }
64    out.extend_from_slice(&encode_path(&card.origin_path));
65    let compact = XpubCompact::from_xpub(&card.xpub);
66    encode_xpub_compact(&compact, &mut out);
67    Ok(out)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::bytecode::test_helpers::synthetic_xpub;
74    use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint};
75    use std::str::FromStr;
76
77    fn fixture_card_1stub_with_fp() -> KeyCard {
78        let path = DerivationPath::from_str("m/48'/0'/0'/2'").unwrap();
79        KeyCard {
80            policy_id_stubs: vec![[0xAA; 4]],
81            origin_fingerprint: Some(Fingerprint::from([0xD3, 0x4D, 0xB3, 0x3F])),
82            xpub: synthetic_xpub(&path),
83            origin_path: path,
84        }
85    }
86
87    #[test]
88    fn encodes_typical_1stub_card_to_84_bytes() {
89        let card = fixture_card_1stub_with_fp();
90        let wire = encode_bytecode(&card).unwrap();
91        // header(1) + stub_count(1) + 1*stub(4) + fp(4) + std-table indicator(1) + xpub_compact(73) = 84
92        assert_eq!(wire.len(), 84);
93        assert_eq!(wire[0], 0x04, "fingerprint flag set");
94        assert_eq!(wire[1], 1, "stub_count = 1");
95        assert_eq!(&wire[2..6], &[0xAA; 4], "stub bytes");
96        assert_eq!(&wire[6..10], &[0xD3, 0x4D, 0xB3, 0x3F], "fp bytes");
97        assert_eq!(wire[10], 0x05, "std-table indicator for m/48'/0'/0'/2'");
98    }
99
100    #[test]
101    fn encodes_card_without_fingerprint_to_80_bytes() {
102        let mut card = fixture_card_1stub_with_fp();
103        card.origin_fingerprint = None;
104        let wire = encode_bytecode(&card).unwrap();
105        // 84 - 4 (omitted fp) = 80
106        assert_eq!(wire.len(), 80);
107        assert_eq!(wire[0], 0x00, "fingerprint flag unset");
108    }
109
110    #[test]
111    fn rejects_zero_stubs() {
112        let mut card = fixture_card_1stub_with_fp();
113        card.policy_id_stubs.clear();
114        assert!(matches!(
115            encode_bytecode(&card),
116            Err(Error::InvalidPolicyIdStubCount),
117        ));
118    }
119
120    #[test]
121    fn deterministic_output() {
122        let card = fixture_card_1stub_with_fp();
123        let a = encode_bytecode(&card).unwrap();
124        let b = encode_bytecode(&card).unwrap();
125        assert_eq!(a, b, "encoder must be byte-deterministic");
126    }
127
128    // ── XpubOriginPathMismatch encoder-side guard (SPEC §5) ──────────────────
129
130    // Cell 1: xpub.depth ≠ component_count(origin_path) → reject.
131    #[test]
132    fn rejects_xpub_depth_mismatch() {
133        let mut card = fixture_card_1stub_with_fp(); // path m/48'/0'/0'/2' → depth 4
134        card.xpub.depth = 3;
135        assert!(matches!(
136            encode_bytecode(&card),
137            Err(Error::XpubOriginPathMismatch {
138                xpub_depth: 3,
139                path_depth: 4,
140                ..
141            }),
142        ));
143    }
144
145    // Cell 2 + 6: same depth, wrong terminal child (the previously-silent case;
146    // the fixture is a standard-table path, so this also covers the dictionary
147    // child-mismatch). A depth-only check (as the toolkit's does) would MISS this.
148    #[test]
149    fn rejects_xpub_child_mismatch_same_depth() {
150        let mut card = fixture_card_1stub_with_fp(); // terminal child = 2'
151        card.xpub.child_number = ChildNumber::Hardened { index: 1 }; // → 1', depth still 4
152        assert!(matches!(
153            encode_bytecode(&card),
154            Err(Error::XpubOriginPathMismatch { .. }),
155        ));
156    }
157
158    // Cell 5 (v0.4.0): a consistent depth-0 / no-path card (empty path, depth 0,
159    // child Normal{0} — the WIF shape) now ENCODES. Was rejected pre-0.4.0.
160    #[test]
161    fn accepts_consistent_depth0_card() {
162        let path = DerivationPath::from_str("m").unwrap(); // empty path
163        let card = KeyCard {
164            policy_id_stubs: vec![[0xAA; 4]],
165            origin_fingerprint: None,
166            xpub: synthetic_xpub(&path), // depth 0, child Normal{0}
167            origin_path: path,
168        };
169        assert!(
170            encode_bytecode(&card).is_ok(),
171            "consistent depth-0 no-path card must encode"
172        );
173    }
174
175    // Cell 6: a depth-0 card with a non-canonical terminal child (Normal{5})
176    // would NOT round-trip (reconstruct yields Normal{0}) → still rejected.
177    #[test]
178    fn rejects_depth0_noncanonical_child() {
179        let path = DerivationPath::from_str("m").unwrap(); // empty path
180        let mut card = KeyCard {
181            policy_id_stubs: vec![[0xAA; 4]],
182            origin_fingerprint: None,
183            xpub: synthetic_xpub(&path), // depth 0, child Normal{0}
184            origin_path: path,
185        };
186        card.xpub.child_number = ChildNumber::Normal { index: 5 };
187        assert!(matches!(
188            encode_bytecode(&card),
189            Err(Error::XpubOriginPathMismatch {
190                xpub_depth: 0,
191                path_depth: 0,
192                path_child: None,
193                ..
194            }),
195        ));
196    }
197
198    // Cell 8: the WIF/no-path card survives the full bytecode round-trip
199    // (encode_bytecode → decode_bytecode), proving end-to-end support.
200    #[test]
201    fn depth0_card_round_trips() {
202        use crate::bytecode::decode::decode_bytecode;
203        let path = DerivationPath::from_str("m").unwrap();
204        let card = KeyCard {
205            policy_id_stubs: vec![[0xAA; 4]],
206            origin_fingerprint: None,
207            xpub: synthetic_xpub(&path),
208            origin_path: path.clone(),
209        };
210        let wire = encode_bytecode(&card).unwrap();
211        let decoded = decode_bytecode(&wire).unwrap();
212        assert_eq!(decoded.origin_path, path);
213        assert_eq!(decoded.xpub.depth, 0);
214        assert_eq!(decoded.xpub.child_number, ChildNumber::Normal { index: 0 });
215        assert_eq!(decoded.xpub.public_key, card.xpub.public_key);
216        assert_eq!(decoded.xpub.chain_code, card.xpub.chain_code);
217    }
218
219    // Cell 4: an aligned EXPLICIT-path card (not in the standard table) encodes
220    // OK — guards against false-positives on explicit-mode paths. (The existing
221    // `encodes_typical_1stub_card_to_84_bytes` covers the standard-table-aligned
222    // case = SPEC cell 5; `xpub_compact.rs::round_trip_full_xpub_depth_4` covers
223    // the reconstruct round-trip = SPEC cell 4 losslessness.)
224    #[test]
225    fn aligned_explicit_path_card_encodes() {
226        let path = DerivationPath::from_str("m/44'/0'/0'/0/5").unwrap(); // 5 comps, explicit
227        let card = KeyCard {
228            policy_id_stubs: vec![[0xAA; 4]],
229            origin_fingerprint: None,
230            xpub: synthetic_xpub(&path), // depth 5, child Normal{5} — aligned
231            origin_path: path,
232        };
233        assert!(
234            encode_bytecode(&card).is_ok(),
235            "aligned explicit-path card must encode"
236        );
237    }
238}