Skip to main content

chains_sdk/
encoding.rs

1//! Shared encoding utilities used across all chain modules.
2//!
3//! Centralizes Bitcoin compact-size (varint), Bech32/Bech32m, and Base58Check
4//! encoding so chain modules don't duplicate these building blocks.
5
6use crate::crypto;
7use crate::error::SignerError;
8
9// ─── Compact Size (Bitcoin VarInt) ──────────────────────────────────
10
11/// Encode a Bitcoin compact-size integer into a buffer.
12///
13/// | Range | Encoding |
14/// |-------|----------|
15/// | 0–0xFC | 1 byte |
16/// | 0xFD–0xFFFF | 0xFD + 2 bytes LE |
17/// | 0x10000–0xFFFFFFFF | 0xFE + 4 bytes LE |
18/// | 0x100000000+ | 0xFF + 8 bytes LE |
19pub fn encode_compact_size(buf: &mut Vec<u8>, value: u64) {
20    if value < 0xFD {
21        buf.push(value as u8);
22    } else if value <= 0xFFFF {
23        buf.push(0xFD);
24        buf.extend_from_slice(&(value as u16).to_le_bytes());
25    } else if value <= 0xFFFF_FFFF {
26        buf.push(0xFE);
27        buf.extend_from_slice(&(value as u32).to_le_bytes());
28    } else {
29        buf.push(0xFF);
30        buf.extend_from_slice(&value.to_le_bytes());
31    }
32}
33
34/// Read a Bitcoin compact-size integer from a byte slice at the given offset.
35///
36/// Advances `offset` past the consumed bytes.
37pub fn read_compact_size(data: &[u8], offset: &mut usize) -> Result<u64, SignerError> {
38    if *offset >= data.len() {
39        return Err(SignerError::EncodingError(
40            "compact size: unexpected EOF".into(),
41        ));
42    }
43    let first = data[*offset];
44    *offset += 1;
45    match first {
46        0x00..=0xFC => Ok(first as u64),
47        0xFD => {
48            let end = offset.checked_add(2).ok_or_else(|| {
49                SignerError::EncodingError("compact size: u16 offset overflow".into())
50            })?;
51            if end > data.len() {
52                return Err(SignerError::EncodingError(
53                    "compact size: truncated u16".into(),
54                ));
55            }
56            let val = u16::from_le_bytes([data[*offset], data[*offset + 1]]);
57            *offset = end;
58            // Canonical: reject values that fit in single-byte form
59            if val < 0xFD {
60                return Err(SignerError::EncodingError(
61                    "compact size: non-canonical 0xFD encoding for value < 253".into(),
62                ));
63            }
64            Ok(val as u64)
65        }
66        0xFE => {
67            let end = offset.checked_add(4).ok_or_else(|| {
68                SignerError::EncodingError("compact size: u32 offset overflow".into())
69            })?;
70            if end > data.len() {
71                return Err(SignerError::EncodingError(
72                    "compact size: truncated u32".into(),
73                ));
74            }
75            let mut buf = [0u8; 4];
76            buf.copy_from_slice(&data[*offset..end]);
77            *offset = end;
78            let val = u32::from_le_bytes(buf);
79            // Canonical: reject values that fit in 2-byte form
80            if val <= 0xFFFF {
81                return Err(SignerError::EncodingError(
82                    "compact size: non-canonical 0xFE encoding for value <= 0xFFFF".into(),
83                ));
84            }
85            Ok(val as u64)
86        }
87        0xFF => {
88            let end = offset.checked_add(8).ok_or_else(|| {
89                SignerError::EncodingError("compact size: u64 offset overflow".into())
90            })?;
91            if end > data.len() {
92                return Err(SignerError::EncodingError(
93                    "compact size: truncated u64".into(),
94                ));
95            }
96            let mut buf = [0u8; 8];
97            buf.copy_from_slice(&data[*offset..end]);
98            *offset = end;
99            let val = u64::from_le_bytes(buf);
100            // Canonical: reject values that fit in 4-byte form
101            if val <= 0xFFFF_FFFF {
102                return Err(SignerError::EncodingError(
103                    "compact size: non-canonical 0xFF encoding for value <= 0xFFFFFFFF".into(),
104                ));
105            }
106            Ok(val)
107        }
108    }
109}
110
111// ─── Bech32 / Bech32m ───────────────────────────────────────────────
112
113/// Encode a SegWit/Taproot address using Bech32 (v0) or Bech32m (v1+).
114///
115/// Automatically selects the correct variant based on witness version.
116pub fn bech32_encode(
117    hrp: &str,
118    witness_version: u8,
119    program: &[u8],
120) -> Result<String, SignerError> {
121    use bech32::Hrp;
122    let hrp =
123        Hrp::parse(hrp).map_err(|e| SignerError::EncodingError(format!("bech32 hrp: {e}")))?;
124    let version = bech32::Fe32::try_from(witness_version)
125        .map_err(|e| SignerError::EncodingError(format!("witness version: {e}")))?;
126    bech32::segwit::encode(hrp, version, program)
127        .map_err(|e| SignerError::EncodingError(format!("bech32 encode: {e}")))
128}
129
130// ─── Base58Check ────────────────────────────────────────────────────
131
132/// Encode data with Base58Check: `Base58(version ‖ payload ‖ checksum[0..4])`.
133///
134/// Used by Bitcoin P2PKH, WIF, xprv/xpub, XRP, and NEO addresses.
135pub fn base58check_encode(version: u8, payload: &[u8]) -> String {
136    let mut data = Vec::with_capacity(1 + payload.len() + 4);
137    data.push(version);
138    data.extend_from_slice(payload);
139    let checksum = crypto::double_sha256(&data);
140    data.extend_from_slice(&checksum[..4]);
141    bs58::encode(&data).into_string()
142}
143
144/// Decode a Base58Check string. Returns `(version, payload)`.
145///
146/// Validates the 4-byte checksum.
147pub fn base58check_decode(s: &str) -> Result<(u8, Vec<u8>), SignerError> {
148    let decoded = bs58::decode(s)
149        .into_vec()
150        .map_err(|e| SignerError::EncodingError(format!("base58: {e}")))?;
151    if decoded.len() < 5 {
152        return Err(SignerError::EncodingError("base58check too short".into()));
153    }
154    let payload_end = decoded.len() - 4;
155    let checksum = crypto::double_sha256(&decoded[..payload_end]);
156    use subtle::ConstantTimeEq;
157    if checksum[..4].ct_eq(&decoded[payload_end..]).unwrap_u8() != 1 {
158        return Err(SignerError::EncodingError(
159            "base58check: invalid checksum".into(),
160        ));
161    }
162    Ok((decoded[0], decoded[1..payload_end].to_vec()))
163}
164
165#[cfg(test)]
166#[allow(clippy::unwrap_used, clippy::expect_used)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_compact_size_roundtrip() {
172        for val in [
173            0u64,
174            1,
175            252,
176            253,
177            0xFFFF,
178            0x10000,
179            0xFFFF_FFFF,
180            0x1_0000_0000,
181        ] {
182            let mut buf = Vec::new();
183            encode_compact_size(&mut buf, val);
184            let mut offset = 0;
185            let parsed = read_compact_size(&buf, &mut offset).expect("ok");
186            assert_eq!(parsed, val, "failed for {val}");
187            assert_eq!(offset, buf.len());
188        }
189    }
190
191    #[test]
192    fn test_compact_size_single_byte() {
193        let mut buf = Vec::new();
194        encode_compact_size(&mut buf, 42);
195        assert_eq!(buf, vec![42]);
196    }
197
198    #[test]
199    fn test_compact_size_eof() {
200        let mut offset = 0;
201        assert!(read_compact_size(&[], &mut offset).is_err());
202    }
203
204    #[test]
205    fn test_base58check_roundtrip() {
206        let encoded = base58check_encode(0x00, &[0xAA; 20]);
207        let (version, payload) = base58check_decode(&encoded).expect("ok");
208        assert_eq!(version, 0x00);
209        assert_eq!(payload, vec![0xAA; 20]);
210    }
211
212    #[test]
213    fn test_base58check_invalid_checksum() {
214        let mut encoded = base58check_encode(0x00, &[0xBB; 20]);
215        // Corrupt the last character
216        encoded.pop();
217        encoded.push('1');
218        // Might decode but checksum should fail (or bs58 decode fails)
219        let result = base58check_decode(&encoded);
220        assert!(result.is_err());
221    }
222
223    #[test]
224    fn test_base58check_too_short() {
225        assert!(base58check_decode("1").is_err());
226    }
227
228    #[test]
229    fn test_bech32_encode_v0() {
230        let addr = bech32_encode("bc", 0, &[0xAA; 20]).expect("ok");
231        assert!(addr.starts_with("bc1q"));
232    }
233
234    #[test]
235    fn test_bech32_encode_v1() {
236        let addr = bech32_encode("bc", 1, &[0xBB; 32]).expect("ok");
237        assert!(addr.starts_with("bc1p"));
238    }
239
240    #[test]
241    fn test_bech32_encode_testnet() {
242        let addr = bech32_encode("tb", 0, &[0xCC; 20]).expect("ok");
243        assert!(addr.starts_with("tb1q"));
244    }
245
246    // ─── Bech32 Known Address Vectors ───────────────────────────
247
248    #[test]
249    fn test_bech32_bip173_p2wpkh_vector() {
250        // BIP-173 test vector: P2WPKH bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
251        let program = hex::decode("751e76e8199196d454941c45d1b3a323f1433bd6").unwrap();
252        let addr = bech32_encode("bc", 0, &program).unwrap();
253        assert_eq!(addr, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
254    }
255
256    #[test]
257    fn test_bech32_bip350_p2tr_vector() {
258        // BIP-350: P2TR address with witness version 1
259        let program =
260            hex::decode("a60869f0dbcf1dc659c9cecbee736b12006a35d655ac7e1caeff5ebc1085a044")
261                .unwrap();
262        let addr = bech32_encode("bc", 1, &program).unwrap();
263        assert!(addr.starts_with("bc1p"));
264        assert_eq!(addr.len(), 62); // Bech32m P2TR addresses are 62 chars
265    }
266
267    #[test]
268    fn test_bech32_invalid_hrp() {
269        assert!(bech32_encode("", 0, &[0; 20]).is_err());
270    }
271
272    // ─── Base58Check Known Address Vectors ──────────────────────
273
274    #[test]
275    fn test_base58check_bitcoin_p2pkh_genesis() {
276        // Bitcoin genesis coinbase P2PKH: HASH160 of generator pubkey
277        // Version 0x00 + 751e76e8199196d454941c45d1b3a323f1433bd6
278        let hash160 = hex::decode("751e76e8199196d454941c45d1b3a323f1433bd6").unwrap();
279        let addr = base58check_encode(0x00, &hash160);
280        assert_eq!(addr, "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH");
281    }
282
283    #[test]
284    fn test_base58check_decode_known_address() {
285        let (version, payload) = base58check_decode("1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH").unwrap();
286        assert_eq!(version, 0x00);
287        assert_eq!(
288            hex::encode(payload),
289            "751e76e8199196d454941c45d1b3a323f1433bd6"
290        );
291    }
292
293    // ─── Compact Size Boundary Values ───────────────────────────
294
295    #[test]
296    fn test_compact_size_boundary_252() {
297        let mut buf = Vec::new();
298        encode_compact_size(&mut buf, 252);
299        assert_eq!(buf.len(), 1); // single byte
300        assert_eq!(buf[0], 252);
301    }
302
303    #[test]
304    fn test_compact_size_boundary_253() {
305        let mut buf = Vec::new();
306        encode_compact_size(&mut buf, 253);
307        assert_eq!(buf[0], 0xFD); // 3 bytes: 0xFD + u16 LE
308        assert_eq!(buf.len(), 3);
309        let mut offset = 0;
310        assert_eq!(read_compact_size(&buf, &mut offset).unwrap(), 253);
311    }
312
313    #[test]
314    fn test_compact_size_truncated_u16() {
315        let buf = vec![0xFD, 0x01]; // need 2 bytes, only 1
316        let mut offset = 0;
317        assert!(read_compact_size(&buf, &mut offset).is_err());
318    }
319
320    #[test]
321    fn test_compact_size_truncated_u32() {
322        let buf = vec![0xFE, 0x01, 0x00]; // need 4 bytes, only 2
323        let mut offset = 0;
324        assert!(read_compact_size(&buf, &mut offset).is_err());
325    }
326
327    #[test]
328    fn test_compact_size_truncated_u64() {
329        let buf = vec![0xFF, 0x01, 0x00, 0x00, 0x00]; // need 8 bytes, only 4
330        let mut offset = 0;
331        assert!(read_compact_size(&buf, &mut offset).is_err());
332    }
333}