Skip to main content

mk_codec/bytecode/
xpub_compact.rs

1//! 73-byte compact xpub form per `design/SPEC_mk_v0_1.md` §3.6
2//! (closure Q-7).
3//!
4//! Drops `xpub.depth` and `xpub.child_number` from the wire (both
5//! reconstructible from `origin_path`); preserves `xpub.version`,
6//! `xpub.parent_fingerprint`, `xpub.chain_code`, `xpub.public_key`.
7//!
8//! ```text
9//! [version          : 4 B]
10//! [parent_fingerprint: 4 B]
11//! [chain_code       : 32 B]
12//! [public_key       : 33 B]
13//!                     ────
14//!                     73 B
15//! ```
16
17use bitcoin::NetworkKind;
18use bitcoin::bip32::{ChainCode, ChildNumber, DerivationPath, Fingerprint, Xpub};
19use bitcoin::secp256k1::PublicKey;
20
21use crate::consts::XPUB_COMPACT_BYTES;
22use crate::error::{Error, Result};
23
24/// Mainnet xpub version prefix (`xpub`).
25const MAINNET_XPUB_VERSION: [u8; 4] = [0x04, 0x88, 0xB2, 0x1E];
26
27/// Testnet xpub version prefix (`tpub`).
28const TESTNET_XPUB_VERSION: [u8; 4] = [0x04, 0x35, 0x87, 0xCF];
29
30/// 73-byte compact form.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct XpubCompact {
33    /// 4-byte BIP 32 version prefix.
34    pub version: [u8; 4],
35    /// 4-byte parent-key fingerprint.
36    pub parent_fingerprint: [u8; 4],
37    /// 32-byte BIP 32 chain code.
38    pub chain_code: [u8; 32],
39    /// 33-byte compressed secp256k1 public key.
40    pub public_key: [u8; 33],
41}
42
43impl XpubCompact {
44    /// Build a compact form from a full BIP 32 `Xpub`.
45    pub fn from_xpub(xpub: &Xpub) -> Self {
46        let version = network_to_version(xpub.network);
47        XpubCompact {
48            version,
49            parent_fingerprint: xpub.parent_fingerprint.to_bytes(),
50            chain_code: xpub.chain_code.to_bytes(),
51            public_key: xpub.public_key.serialize(),
52        }
53    }
54}
55
56fn network_to_version(network: NetworkKind) -> [u8; 4] {
57    match network {
58        NetworkKind::Main => MAINNET_XPUB_VERSION,
59        NetworkKind::Test => TESTNET_XPUB_VERSION,
60    }
61}
62
63fn version_to_network(version: [u8; 4]) -> Result<NetworkKind> {
64    match version {
65        MAINNET_XPUB_VERSION => Ok(NetworkKind::Main),
66        TESTNET_XPUB_VERSION => Ok(NetworkKind::Test),
67        other => Err(Error::InvalidXpubVersion(u32::from_be_bytes(other))),
68    }
69}
70
71/// Reconstruct a full BIP 32 `Xpub` from a compact form + the origin
72/// path (which provides depth and child_number per Q-7's reconstruction
73/// rule).
74///
75/// Per `design/SPEC_mk_v0_1.md` §3.6:
76///
77/// ```text
78/// depth        := component_count(origin_path)
79/// child_number := last_component(origin_path) (with hardened-bit encoding)
80/// ```
81///
82/// `origin_path` MUST be non-empty (caller responsibility; the spec
83/// guarantees this since standard-table indicators have ≥3 components
84/// and explicit-path encoding requires `count ≥ 1`).
85pub fn reconstruct_xpub(compact: &XpubCompact, origin_path: &DerivationPath) -> Result<Xpub> {
86    let network = version_to_network(compact.version)?;
87    let components: Vec<ChildNumber> = origin_path.into_iter().copied().collect();
88    let depth = components.len() as u8;
89    // origin_path is non-empty per SPEC §3.5: standard-table indicators
90    // dereference to ≥3 components, and explicit-path encoding rejects
91    // count == 0 with PathTooDeep(0) before reaching reconstruct_xpub.
92    let child_number = components
93        .last()
94        .copied()
95        .expect("origin_path must be non-empty per SPEC §3.5");
96    let public_key = PublicKey::from_slice(&compact.public_key)
97        .map_err(|e| Error::InvalidXpubPublicKey(format!("{e}")))?;
98    Ok(Xpub {
99        network,
100        depth,
101        parent_fingerprint: Fingerprint::from(compact.parent_fingerprint),
102        child_number,
103        public_key,
104        chain_code: ChainCode::from(compact.chain_code),
105    })
106}
107
108/// Encode a compact form to its 73-byte wire layout.
109pub fn encode_xpub_compact(compact: &XpubCompact, out: &mut Vec<u8>) {
110    out.extend_from_slice(&compact.version);
111    out.extend_from_slice(&compact.parent_fingerprint);
112    out.extend_from_slice(&compact.chain_code);
113    out.extend_from_slice(&compact.public_key);
114}
115
116/// Decode 73 bytes into a compact form.
117pub fn decode_xpub_compact(cursor: &mut &[u8]) -> Result<XpubCompact> {
118    if cursor.len() < XPUB_COMPACT_BYTES {
119        return Err(Error::UnexpectedEnd);
120    }
121    let version: [u8; 4] = cursor[0..4].try_into().unwrap();
122    // Validate version eagerly so the error fires here rather than at
123    // reconstruction time.
124    let _ = version_to_network(version)?;
125    let parent_fingerprint: [u8; 4] = cursor[4..8].try_into().unwrap();
126    let chain_code: [u8; 32] = cursor[8..40].try_into().unwrap();
127    let public_key: [u8; 33] = cursor[40..73].try_into().unwrap();
128    *cursor = &cursor[XPUB_COMPACT_BYTES..];
129    Ok(XpubCompact {
130        version,
131        parent_fingerprint,
132        chain_code,
133        public_key,
134    })
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::bytecode::test_helpers::synthetic_xpub;
141    use std::str::FromStr;
142
143    #[test]
144    fn round_trip_full_xpub_depth_4() {
145        let path = DerivationPath::from_str("m/48'/0'/0'/2'").unwrap();
146        let xpub_full = synthetic_xpub(&path);
147        let compact = XpubCompact::from_xpub(&xpub_full);
148        // Compact must drop depth and child_number — verify by length only.
149        let mut wire = Vec::new();
150        encode_xpub_compact(&compact, &mut wire);
151        assert_eq!(wire.len(), XPUB_COMPACT_BYTES);
152        // Round-trip on the wire form.
153        let mut cursor: &[u8] = &wire;
154        let decoded = decode_xpub_compact(&mut cursor).unwrap();
155        assert_eq!(decoded, compact);
156        assert!(cursor.is_empty());
157        // Reconstruct with the path the xpub was originally derived at.
158        let reconstructed = reconstruct_xpub(&decoded, &path).unwrap();
159        assert_eq!(reconstructed.depth, 4);
160        assert_eq!(reconstructed.network, xpub_full.network);
161        assert_eq!(
162            reconstructed.parent_fingerprint,
163            xpub_full.parent_fingerprint
164        );
165        assert_eq!(reconstructed.chain_code, xpub_full.chain_code);
166        assert_eq!(reconstructed.public_key, xpub_full.public_key);
167        // child_number reconstruction
168        assert_eq!(reconstructed.child_number, xpub_full.child_number);
169    }
170
171    #[test]
172    fn rejects_invalid_version() {
173        // 73 bytes with garbage version
174        let mut wire = vec![0xDE, 0xAD, 0xBE, 0xEF];
175        wire.extend_from_slice(&[0u8; 4 + 32 + 33]);
176        let mut cursor: &[u8] = &wire;
177        assert!(matches!(
178            decode_xpub_compact(&mut cursor),
179            Err(Error::InvalidXpubVersion(_)),
180        ));
181    }
182
183    #[test]
184    fn rejects_truncated_input() {
185        let wire = vec![0x04, 0x88]; // way under 73
186        let mut cursor: &[u8] = &wire;
187        assert!(matches!(
188            decode_xpub_compact(&mut cursor),
189            Err(Error::UnexpectedEnd),
190        ));
191    }
192}