Skip to main content

mk_codec/bytecode/
path.rs

1//! Origin-path codec — standard-table dictionary + `0xFE` explicit-path
2//! escape hatch.
3//!
4//! Per `design/SPEC_mk_v0_1.md` §3.5 (closure Q-3: cap = 10).
5//!
6//! mk1-internal indicator-byte path dictionary. md1 v0.11+ encodes paths
7//! explicitly via `OriginPath` and does not carry a path-dictionary
8//! table; mk1's dictionary is therefore standalone, not a sibling
9//! mirror. Historically (md-codec v0.10.x and earlier), md1 carried a
10//! compatible table via `Tag::SharedPath` / `Tag::OriginPaths`; the
11//! v0.11 architectural cleanup retired that table per
12//! `descriptor-mnemonic/design/SPEC_v0_11_wire_format.md` §1.4. The
13//! testnet companion `0x16` to mainnet `0x06` (BIP 48 nested-segwit
14//! multisig) was added in mk-codec v0.2.0; the addition is wire-additive
15//! (v0.1.x decoders reject `0x16` as `Error::InvalidPathIndicator(0x16)`,
16//! v0.2+ decoders accept and resolve to `m/48'/1'/0'/1'`).
17//!
18//! Explicit-path encoding: indicator `0xFE`, 1-byte component count
19//! (0..=10; 0 = no-path / depth-0 key, e.g. a WIF), then each component as
20//! LEB128-encoded u32 with the BIP 32 hardened-bit in the high bit.
21
22use bitcoin::bip32::{ChildNumber, DerivationPath};
23
24use crate::consts::MAX_PATH_COMPONENTS;
25use crate::error::{Error, Result};
26
27/// Indicator byte for an explicit (non-standard-table) path.
28pub const EXPLICIT_PATH_INDICATOR: u8 = 0xFE;
29
30/// Standard-table dictionary entries — `(indicator_byte, path_string)`.
31///
32/// mk1-internal table — not a sibling mirror. md1 v0.11+ does not carry
33/// a path-dictionary table (per
34/// `descriptor-mnemonic/design/SPEC_v0_11_wire_format.md` §1.4). The 14
35/// entries are: 7 mainnet (`0x01`..=`0x07`) and 7 testnet
36/// (`0x11`..=`0x17`). `0x16` (BIP 48 testnet nested-segwit multisig) was
37/// added in v0.2.0.
38pub const STANDARD_PATHS: &[(u8, &str)] = &[
39    // Mainnet
40    (0x01, "m/44'/0'/0'"),    // BIP 44 mainnet
41    (0x02, "m/49'/0'/0'"),    // BIP 49 mainnet
42    (0x03, "m/84'/0'/0'"),    // BIP 84 mainnet
43    (0x04, "m/86'/0'/0'"),    // BIP 86 mainnet
44    (0x05, "m/48'/0'/0'/2'"), // BIP 48 segwit-v0 multisig mainnet
45    (0x06, "m/48'/0'/0'/1'"), // BIP 48 nested-segwit multisig mainnet
46    (0x07, "m/87'/0'/0'"),    // BIP 87 multisig mainnet
47    // Testnet
48    (0x11, "m/44'/1'/0'"),
49    (0x12, "m/49'/1'/0'"),
50    (0x13, "m/84'/1'/0'"),
51    (0x14, "m/86'/1'/0'"),
52    (0x15, "m/48'/1'/0'/2'"),
53    (0x16, "m/48'/1'/0'/1'"), // v0.2.0+; was reserved-pending in v0.1.x
54    (0x17, "m/87'/1'/0'"),
55];
56
57/// Look up a standard-table indicator → `DerivationPath`. Returns
58/// `None` for indicators outside the dictionary (reserved values
59/// `0x00`, `0x08`..=`0x10`, `0x18`..=`0xFD`, `0xFF`).
60pub fn lookup_indicator(indicator: u8) -> Option<DerivationPath> {
61    STANDARD_PATHS
62        .iter()
63        .find(|(b, _)| *b == indicator)
64        .and_then(|(_, p)| p.parse().ok())
65}
66
67/// Look up `DerivationPath` → standard-table indicator. Returns `None`
68/// if the path is not in the dictionary (encoder falls through to
69/// explicit-path encoding). Comparison is structural (parses each
70/// table entry to a `DerivationPath`); this avoids the `m/`-prefix
71/// pitfall in `bitcoin::bip32::DerivationPath`'s Display.
72pub fn lookup_path(path: &DerivationPath) -> Option<u8> {
73    STANDARD_PATHS
74        .iter()
75        .find(|(_, p)| {
76            p.parse::<DerivationPath>()
77                .map(|table_path| &table_path == path)
78                .unwrap_or(false)
79        })
80        .map(|(b, _)| *b)
81}
82
83/// Encode a path: 1-byte standard-table indicator if available, else
84/// explicit-path escape hatch (`0xFE` + count + LEB128 components).
85pub fn encode_path(path: &DerivationPath) -> Vec<u8> {
86    if let Some(indicator) = lookup_path(path) {
87        return vec![indicator];
88    }
89    let mut out = Vec::with_capacity(2 + 5 * MAX_PATH_COMPONENTS as usize);
90    out.push(EXPLICIT_PATH_INDICATOR);
91    let components: Vec<ChildNumber> = path.into_iter().copied().collect();
92    out.push(components.len() as u8);
93    for cn in components {
94        let raw: u32 = u32::from(cn);
95        leb128_encode(raw, &mut out);
96    }
97    out
98}
99
100/// Decode a path field starting at `*cursor` (advances the cursor).
101pub fn decode_path(cursor: &mut &[u8]) -> Result<DerivationPath> {
102    let indicator = read_u8(cursor)?;
103    if indicator == EXPLICIT_PATH_INDICATOR {
104        return decode_explicit_path(cursor);
105    }
106    if let Some(path) = lookup_indicator(indicator) {
107        return Ok(path);
108    }
109    Err(Error::InvalidPathIndicator(indicator))
110}
111
112fn decode_explicit_path(cursor: &mut &[u8]) -> Result<DerivationPath> {
113    let count = read_u8(cursor)?;
114    if count > MAX_PATH_COMPONENTS {
115        return Err(Error::PathTooDeep(count));
116    }
117    // count == 0 → no-path / depth-0 root key (e.g. a WIF). The component loop
118    // below runs zero times → DerivationPath::from(vec![]) = empty path "m".
119    let mut components: Vec<ChildNumber> = Vec::with_capacity(count as usize);
120    for _ in 0..count {
121        let raw = leb128_decode_u32(cursor)?;
122        let cn = if raw & 0x8000_0000 != 0 {
123            ChildNumber::from_hardened_idx(raw & 0x7FFF_FFFF)
124                .map_err(|e| Error::InvalidPathComponent(format!("{e}")))?
125        } else {
126            ChildNumber::from_normal_idx(raw)
127                .map_err(|e| Error::InvalidPathComponent(format!("{e}")))?
128        };
129        components.push(cn);
130    }
131    Ok(DerivationPath::from(components))
132}
133
134fn leb128_encode(mut value: u32, out: &mut Vec<u8>) {
135    loop {
136        let mut byte = (value & 0x7F) as u8;
137        value >>= 7;
138        if value != 0 {
139            byte |= 0x80;
140            out.push(byte);
141        } else {
142            out.push(byte);
143            break;
144        }
145    }
146}
147
148fn leb128_decode_u32(cursor: &mut &[u8]) -> Result<u32> {
149    let mut result: u64 = 0;
150    let mut shift: u32 = 0;
151    loop {
152        let byte = read_u8(cursor)?;
153        result |= ((byte & 0x7F) as u64) << shift;
154        if byte & 0x80 == 0 {
155            break;
156        }
157        shift += 7;
158        // u32 max needs ⌈32/7⌉ = 5 bytes; bail at the 6th byte (shift=35).
159        if shift >= 35 {
160            return Err(Error::InvalidPathComponent(format!(
161                "LEB128 overflow at shift {shift}"
162            )));
163        }
164    }
165    if result > u32::MAX as u64 {
166        return Err(Error::InvalidPathComponent(format!(
167            "LEB128 value {result} > u32::MAX"
168        )));
169    }
170    Ok(result as u32)
171}
172
173fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
174    if cursor.is_empty() {
175        return Err(Error::UnexpectedEnd);
176    }
177    let b = cursor[0];
178    *cursor = &cursor[1..];
179    Ok(b)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::str::FromStr;
186
187    #[test]
188    fn round_trip_all_standard_paths() {
189        for (indicator, path_str) in STANDARD_PATHS {
190            let path = DerivationPath::from_str(path_str).unwrap();
191            let encoded = encode_path(&path);
192            assert_eq!(encoded, vec![*indicator], "round-trip {path_str}");
193            let mut cursor: &[u8] = &encoded;
194            let decoded = decode_path(&mut cursor).unwrap();
195            assert_eq!(decoded, path, "round-trip parsed {path_str}");
196            assert!(cursor.is_empty());
197        }
198    }
199
200    #[test]
201    fn round_trip_explicit_path_simple() {
202        let path = DerivationPath::from_str("m/0/1/2").unwrap();
203        let encoded = encode_path(&path);
204        // 0xFE + count(3) + leb128(0,1,2) = each fits in 1 byte
205        assert_eq!(encoded[0], 0xFE);
206        assert_eq!(encoded[1], 3);
207        let mut cursor: &[u8] = &encoded;
208        let decoded = decode_path(&mut cursor).unwrap();
209        assert_eq!(decoded, path);
210    }
211
212    #[test]
213    fn round_trip_explicit_path_all_hardened() {
214        // m/9999'/1234'/56'/7' — hardened, requires 5 LEB128 bytes per component
215        let path = DerivationPath::from_str("m/9999'/1234'/56'/7'").unwrap();
216        let encoded = encode_path(&path);
217        assert_eq!(encoded[0], 0xFE);
218        assert_eq!(encoded[1], 4);
219        // 0xFE + 1 (count) + 4 * 5 = 22 bytes
220        assert_eq!(encoded.len(), 1 + 1 + 4 * 5);
221        let mut cursor: &[u8] = &encoded;
222        let decoded = decode_path(&mut cursor).unwrap();
223        assert_eq!(decoded, path);
224    }
225
226    #[test]
227    fn round_trip_explicit_path_at_cap() {
228        // 10 components — cap exact
229        let path = DerivationPath::from_str("m/0'/1'/2'/3'/4'/5'/6'/7'/8'/9'").unwrap();
230        let encoded = encode_path(&path);
231        let mut cursor: &[u8] = &encoded;
232        let decoded = decode_path(&mut cursor).unwrap();
233        assert_eq!(decoded, path);
234    }
235
236    #[test]
237    fn rejects_path_too_deep() {
238        // Construct an explicit-path encoding with count = 11
239        let mut bytes = vec![0xFE, 11u8];
240        for i in 0..11 {
241            bytes.push(i); // single-byte LEB128
242        }
243        let mut cursor: &[u8] = &bytes;
244        assert!(matches!(
245            decode_path(&mut cursor),
246            Err(Error::PathTooDeep(11)),
247        ));
248    }
249
250    #[test]
251    fn accepts_path_count_zero_as_empty_path() {
252        // count = 0 is the no-path / depth-0 case (e.g. a WIF). v0.4.0+: decode
253        // returns the empty path; older decoders rejected it as PathTooDeep(0).
254        let bytes = vec![0xFE, 0u8];
255        let mut cursor: &[u8] = &bytes;
256        let decoded = decode_path(&mut cursor).unwrap();
257        assert_eq!(decoded.into_iter().count(), 0, "empty path");
258        assert!(cursor.is_empty());
259    }
260
261    #[test]
262    fn round_trip_empty_path() {
263        let path = DerivationPath::from_str("m").unwrap(); // empty
264        let encoded = encode_path(&path);
265        assert_eq!(encoded, vec![0xFE, 0x00]);
266        let mut cursor: &[u8] = &encoded;
267        let decoded = decode_path(&mut cursor).unwrap();
268        assert_eq!(decoded, path);
269        assert!(cursor.is_empty());
270    }
271
272    #[test]
273    fn rejects_reserved_indicator_zero() {
274        let bytes = vec![0x00];
275        let mut cursor: &[u8] = &bytes;
276        assert!(matches!(
277            decode_path(&mut cursor),
278            Err(Error::InvalidPathIndicator(0x00)),
279        ));
280    }
281
282    #[test]
283    fn round_trip_indicator_0x16_added_in_v0_2() {
284        // 0x16 was reserved-pending in v0.1.x; added to STANDARD_PATHS
285        // in v0.2.0. Resolves to BIP 48 testnet nested-segwit multisig
286        // (`m/48'/1'/0'/1'`). Historical context: this entry tracked an
287        // md1-side gap at the time the mk-codec v0.2.0 cycle ran;
288        // md1 v0.11+ has since dropped path dictionaries entirely (the
289        // mirror invariant is retired — see this module's rustdoc).
290        let path = DerivationPath::from_str("m/48'/1'/0'/1'").unwrap();
291        let encoded = encode_path(&path);
292        assert_eq!(encoded, vec![0x16]);
293        let mut cursor: &[u8] = &encoded;
294        let decoded = decode_path(&mut cursor).unwrap();
295        assert_eq!(decoded, path);
296        assert!(cursor.is_empty());
297    }
298
299    #[test]
300    fn rejects_reserved_indicator_high_range() {
301        // 0xFD (just below 0xFE explicit) is reserved
302        let bytes = vec![0xFD];
303        let mut cursor: &[u8] = &bytes;
304        assert!(matches!(
305            decode_path(&mut cursor),
306            Err(Error::InvalidPathIndicator(0xFD)),
307        ));
308        // 0xFF is reserved
309        let bytes = vec![0xFF];
310        let mut cursor: &[u8] = &bytes;
311        assert!(matches!(
312            decode_path(&mut cursor),
313            Err(Error::InvalidPathIndicator(0xFF)),
314        ));
315    }
316
317    #[test]
318    fn rejects_truncated_explicit_path() {
319        // 0xFE indicator + count(2) + only one component byte
320        let bytes = vec![0xFE, 2u8, 0u8];
321        let mut cursor: &[u8] = &bytes;
322        assert!(matches!(
323            decode_path(&mut cursor),
324            Err(Error::UnexpectedEnd),
325        ));
326    }
327
328    #[test]
329    fn leb128_encode_examples() {
330        // 0 → [0]
331        let mut out = Vec::new();
332        leb128_encode(0, &mut out);
333        assert_eq!(out, vec![0]);
334        // 127 → [0x7F]
335        let mut out = Vec::new();
336        leb128_encode(127, &mut out);
337        assert_eq!(out, vec![0x7F]);
338        // 128 → [0x80, 0x01]
339        let mut out = Vec::new();
340        leb128_encode(128, &mut out);
341        assert_eq!(out, vec![0x80, 0x01]);
342        // 0x80000000 (hardened bit set) → [0x80, 0x80, 0x80, 0x80, 0x08]
343        let mut out = Vec::new();
344        leb128_encode(0x8000_0000, &mut out);
345        assert_eq!(out, vec![0x80, 0x80, 0x80, 0x80, 0x08]);
346    }
347}