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///                 or Normal{0} when origin_path is empty (depth-0 / no-path key)
81/// ```
82///
83/// An empty `origin_path` (the no-path / depth-0 case, e.g. a WIF) yields
84/// `depth = 0` and `child_number = Normal{0}` (the BIP-32 master
85/// convention) — v0.4.0+; earlier versions required a non-empty path.
86pub fn reconstruct_xpub(compact: &XpubCompact, origin_path: &DerivationPath) -> Result<Xpub> {
87    let network = version_to_network(compact.version)?;
88    let components: Vec<ChildNumber> = origin_path.into_iter().copied().collect();
89    let depth = components.len() as u8;
90    // child_number defaults to the BIP-32 master convention Normal{0} when
91    // origin_path is empty (a depth-0 / no-path key, e.g. a WIF — SPEC §3.6).
92    // For a non-empty path it is the terminal component; this is the exact
93    // inverse of the encode-side guard in encode.rs.
94    let child_number = components
95        .last()
96        .copied()
97        .unwrap_or(ChildNumber::Normal { index: 0 });
98    let public_key = PublicKey::from_slice(&compact.public_key)
99        .map_err(|e| Error::InvalidXpubPublicKey(format!("{e}")))?;
100    Ok(Xpub {
101        network,
102        depth,
103        parent_fingerprint: Fingerprint::from(compact.parent_fingerprint),
104        child_number,
105        public_key,
106        chain_code: ChainCode::from(compact.chain_code),
107    })
108}
109
110/// Encode a compact form to its 73-byte wire layout.
111pub fn encode_xpub_compact(compact: &XpubCompact, out: &mut Vec<u8>) {
112    out.extend_from_slice(&compact.version);
113    out.extend_from_slice(&compact.parent_fingerprint);
114    out.extend_from_slice(&compact.chain_code);
115    out.extend_from_slice(&compact.public_key);
116}
117
118/// Decode 73 bytes into a compact form.
119pub fn decode_xpub_compact(cursor: &mut &[u8]) -> Result<XpubCompact> {
120    if cursor.len() < XPUB_COMPACT_BYTES {
121        return Err(Error::UnexpectedEnd);
122    }
123    let version: [u8; 4] = cursor[0..4].try_into().unwrap();
124    // Validate version eagerly so the error fires here rather than at
125    // reconstruction time.
126    let _ = version_to_network(version)?;
127    let parent_fingerprint: [u8; 4] = cursor[4..8].try_into().unwrap();
128    let chain_code: [u8; 32] = cursor[8..40].try_into().unwrap();
129    let public_key: [u8; 33] = cursor[40..73].try_into().unwrap();
130    *cursor = &cursor[XPUB_COMPACT_BYTES..];
131    Ok(XpubCompact {
132        version,
133        parent_fingerprint,
134        chain_code,
135        public_key,
136    })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::bytecode::test_helpers::synthetic_xpub;
143    use std::str::FromStr;
144
145    #[test]
146    fn round_trip_full_xpub_depth_4() {
147        let path = DerivationPath::from_str("m/48'/0'/0'/2'").unwrap();
148        let xpub_full = synthetic_xpub(&path);
149        let compact = XpubCompact::from_xpub(&xpub_full);
150        // Compact must drop depth and child_number — verify by length only.
151        let mut wire = Vec::new();
152        encode_xpub_compact(&compact, &mut wire);
153        assert_eq!(wire.len(), XPUB_COMPACT_BYTES);
154        // Round-trip on the wire form.
155        let mut cursor: &[u8] = &wire;
156        let decoded = decode_xpub_compact(&mut cursor).unwrap();
157        assert_eq!(decoded, compact);
158        assert!(cursor.is_empty());
159        // Reconstruct with the path the xpub was originally derived at.
160        let reconstructed = reconstruct_xpub(&decoded, &path).unwrap();
161        assert_eq!(reconstructed.depth, 4);
162        assert_eq!(reconstructed.network, xpub_full.network);
163        assert_eq!(
164            reconstructed.parent_fingerprint,
165            xpub_full.parent_fingerprint
166        );
167        assert_eq!(reconstructed.chain_code, xpub_full.chain_code);
168        assert_eq!(reconstructed.public_key, xpub_full.public_key);
169        // child_number reconstruction
170        assert_eq!(reconstructed.child_number, xpub_full.child_number);
171    }
172
173    #[test]
174    fn reconstruct_depth0_empty_path() {
175        let path = DerivationPath::from_str("m").unwrap(); // empty
176        let xpub_full = synthetic_xpub(&path); // depth 0, child Normal{0}
177        let compact = XpubCompact::from_xpub(&xpub_full);
178        let reconstructed = reconstruct_xpub(&compact, &path).unwrap();
179        assert_eq!(reconstructed.depth, 0);
180        assert_eq!(reconstructed.child_number, ChildNumber::Normal { index: 0 });
181        assert_eq!(
182            reconstructed.parent_fingerprint,
183            xpub_full.parent_fingerprint
184        );
185        assert_eq!(reconstructed.chain_code, xpub_full.chain_code);
186        assert_eq!(reconstructed.public_key, xpub_full.public_key);
187        assert_eq!(reconstructed.network, xpub_full.network);
188    }
189
190    #[test]
191    fn rejects_invalid_version() {
192        // 73 bytes with garbage version
193        let mut wire = vec![0xDE, 0xAD, 0xBE, 0xEF];
194        wire.extend_from_slice(&[0u8; 4 + 32 + 33]);
195        let mut cursor: &[u8] = &wire;
196        assert!(matches!(
197            decode_xpub_compact(&mut cursor),
198            Err(Error::InvalidXpubVersion(_)),
199        ));
200    }
201
202    #[test]
203    fn rejects_truncated_input() {
204        let wire = vec![0x04, 0x88]; // way under 73
205        let mut cursor: &[u8] = &wire;
206        assert!(matches!(
207            decode_xpub_compact(&mut cursor),
208            Err(Error::UnexpectedEnd),
209        ));
210    }
211}