Skip to main content

bsv_primitives/base58/
mod.rs

1//! Base58 encoding and decoding with optional checksum support.
2//!
3//! Provides raw Base58 encode/decode (matching the Go BSV SDK's
4//! `compat/base58` package) and Base58Check encode/decode (with
5//! double-SHA-256 checksum) used for WIF private keys and Bitcoin
6//! addresses.
7
8use crate::PrimitivesError;
9use crate::hash::sha256d;
10
11/// Bitcoin's modified Base58 alphabet.
12///
13/// Excludes 0, O, I, l to reduce visual ambiguity.
14const _ALPHABET: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
15
16/// Encode a byte slice to a Base58 string.
17///
18/// Uses Bitcoin's modified Base58 alphabet. Leading zero bytes
19/// are encoded as leading '1' characters.
20///
21/// # Arguments
22/// * `data` - The bytes to encode.
23///
24/// # Returns
25/// A Base58-encoded string.
26pub fn encode(data: &[u8]) -> String {
27    bs58::encode(data).with_alphabet(bs58::Alphabet::BITCOIN).into_string()
28}
29
30/// Decode a Base58 string to a byte vector.
31///
32/// Leading '1' characters decode to leading zero bytes.
33///
34/// # Arguments
35/// * `s` - The Base58 string to decode.
36///
37/// # Returns
38/// `Ok(Vec<u8>)` on success, or an error for invalid characters.
39pub fn decode(s: &str) -> Result<Vec<u8>, PrimitivesError> {
40    bs58::decode(s)
41        .with_alphabet(bs58::Alphabet::BITCOIN)
42        .into_vec()
43        .map_err(|e| PrimitivesError::InvalidBase58(e.to_string()))
44}
45
46/// Encode a byte slice with a 4-byte double-SHA-256 checksum appended (Base58Check).
47///
48/// The checksum is the first 4 bytes of SHA-256d(data). The result
49/// is `encode(data || checksum)`.
50///
51/// # Arguments
52/// * `data` - The bytes to encode (typically version byte + payload).
53///
54/// # Returns
55/// A Base58Check-encoded string.
56pub fn check_encode(data: &[u8]) -> String {
57    let checksum = sha256d(data);
58    let mut payload = data.to_vec();
59    payload.extend_from_slice(&checksum[..4]);
60    encode(&payload)
61}
62
63/// Decode a Base58Check string, verifying the 4-byte checksum.
64///
65/// Strips and validates the trailing 4-byte double-SHA-256 checksum.
66///
67/// # Arguments
68/// * `s` - The Base58Check string to decode.
69///
70/// # Returns
71/// `Ok(Vec<u8>)` of the payload (without checksum) on success, or an
72/// error for invalid encoding or checksum mismatch.
73pub fn check_decode(s: &str) -> Result<Vec<u8>, PrimitivesError> {
74    let decoded = decode(s)?;
75    if decoded.len() < 4 {
76        return Err(PrimitivesError::InvalidBase58(
77            "data too short for checksum".to_string()
78        ));
79    }
80    let (payload, checksum) = decoded.split_at(decoded.len() - 4);
81    let expected = sha256d(payload);
82    if checksum != &expected[..4] {
83        return Err(PrimitivesError::ChecksumMismatch);
84    }
85    Ok(payload.to_vec())
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    // -- Tests from Go SDK base58_test.go TestBase58 --
93
94    #[test]
95    fn test_base58_empty_string() {
96        let input = hex::decode("").unwrap();
97        assert_eq!(encode(&input), "");
98        let decoded = decode("").unwrap();
99        assert_eq!(decoded, input);
100    }
101
102    #[test]
103    fn test_base58_single_zero_byte() {
104        let input = hex::decode("00").unwrap();
105        assert_eq!(encode(&input), "1");
106        let decoded = decode("1").unwrap();
107        assert_eq!(decoded, input);
108    }
109
110    #[test]
111    fn test_base58_decoded_address() {
112        let input = hex::decode("00010966776006953D5567439E5E39F86A0D273BEED61967F6").unwrap();
113        let encoded = encode(&input);
114        assert_eq!(encoded, "16UwLL9Risc3QfPqBUvKofHmBQ7wMtjvM");
115        let decoded = decode("16UwLL9Risc3QfPqBUvKofHmBQ7wMtjvM").unwrap();
116        assert_eq!(decoded, input);
117    }
118
119    #[test]
120    fn test_base58_decoded_hash() {
121        let input = hex::decode("0123456789ABCDEF").unwrap();
122        let encoded = encode(&input);
123        assert_eq!(encoded, "C3CPq7c8PY");
124        let decoded = decode("C3CPq7c8PY").unwrap();
125        assert_eq!(decoded, input);
126    }
127
128    #[test]
129    fn test_base58_leading_zeros() {
130        let input = hex::decode("000000287FB4CD").unwrap();
131        let encoded = encode(&input);
132        assert_eq!(encoded, "111233QC4");
133        let decoded = decode("111233QC4").unwrap();
134        assert_eq!(decoded, input);
135    }
136
137    // -- Tests from Go SDK base58_test.go TestBase58DecodeInvalid --
138
139    #[test]
140    fn test_base58_decode_invalid_character() {
141        assert!(decode("invalid!@#$%").is_err());
142    }
143
144    #[test]
145    fn test_base58_decode_mixed_valid_invalid() {
146        assert!(decode("1234!@#$%").is_err());
147    }
148
149    // -- Tests from Go SDK base58_test.go TestBase58EncodeEdgeCases --
150
151    #[test]
152    fn test_base58_encode_nil_input() {
153        assert_eq!(encode(&[]), "");
154    }
155
156    #[test]
157    fn test_base58_encode_all_zeros() {
158        assert_eq!(encode(&[0, 0, 0, 0]), "1111");
159    }
160
161    #[test]
162    fn test_base58_encode_large_number() {
163        assert_eq!(encode(&[255, 255, 255, 255]), "7YXq9G");
164    }
165
166    // -- Base58Check tests --
167
168    #[test]
169    fn test_base58_check_roundtrip() {
170        let payload = hex::decode("00f54a5851e9372b87810a8e60cdd2e7cfd80b6e31").unwrap();
171        let encoded = check_encode(&payload);
172        let decoded = check_decode(&encoded).unwrap();
173        assert_eq!(decoded, payload);
174    }
175
176    #[test]
177    fn test_base58_check_bad_checksum() {
178        // Encode then tamper with the last character.
179        let payload = vec![0x80, 0x01, 0x02, 0x03];
180        let mut encoded = check_encode(&payload);
181        let last = encoded.pop().unwrap();
182        let replacement = if last == '1' { '2' } else { '1' };
183        encoded.push(replacement);
184        assert!(check_decode(&encoded).is_err());
185    }
186}