Skip to main content

bsv/script/
bip276.rs

1//! BIP276 script encoding/decoding.
2//!
3//! Format: `{prefix}:{version_hex}{network_hex}{data_hex}{checksum_hex}`
4//! where checksum is the first 4 bytes of hash256 of the payload (everything before checksum).
5//! Translates the Go SDK bip276.go.
6
7use crate::primitives::hash::hash256;
8use crate::primitives::utils::{from_hex, to_hex};
9use crate::script::error::ScriptError;
10use crate::script::script::Script;
11
12/// Default BIP276 prefix.
13pub const BIP276_PREFIX: &str = "bitcoin-script";
14
15/// Encode data in BIP276 format.
16///
17/// Format: `{prefix}:{version:02x}{network:02x}{data_hex}{checksum_hex}`
18///
19/// Version and network must be > 0.
20pub fn encode_bip276(
21    prefix: &str,
22    version: u8,
23    network: u8,
24    data: &[u8],
25) -> Result<String, ScriptError> {
26    if version == 0 {
27        return Err(ScriptError::InvalidFormat(
28            "BIP276 version must be > 0".to_string(),
29        ));
30    }
31    if network == 0 {
32        return Err(ScriptError::InvalidFormat(
33            "BIP276 network must be > 0".to_string(),
34        ));
35    }
36
37    let data_hex = to_hex(data);
38    let payload = format!("{prefix}:{version:02x}{network:02x}{data_hex}");
39
40    let checksum = hash256(payload.as_bytes());
41    let checksum_hex = to_hex(&checksum[..4]);
42
43    Ok(format!("{payload}{checksum_hex}"))
44}
45
46/// Decode a BIP276 encoded string.
47///
48/// Returns (prefix, version, network, data_bytes) on success.
49/// Validates the checksum against hash256.
50pub fn decode_bip276(encoded: &str) -> Result<(String, u8, u8, Vec<u8>), ScriptError> {
51    let colon_pos = encoded
52        .find(':')
53        .ok_or_else(|| ScriptError::InvalidFormat("BIP276: missing ':' separator".to_string()))?;
54
55    let prefix = &encoded[..colon_pos];
56    let rest = &encoded[colon_pos + 1..];
57
58    // rest = version(2) + network(2) + data(variable) + checksum(8)
59    if rest.len() < 12 {
60        // minimum: 2 version + 2 network + 0 data + 8 checksum = 12
61        return Err(ScriptError::InvalidFormat(
62            "BIP276: encoded data too short".to_string(),
63        ));
64    }
65
66    let version = u8::from_str_radix(&rest[..2], 16)
67        .map_err(|_| ScriptError::InvalidFormat("BIP276: invalid version hex".to_string()))?;
68    let network = u8::from_str_radix(&rest[2..4], 16)
69        .map_err(|_| ScriptError::InvalidFormat("BIP276: invalid network hex".to_string()))?;
70
71    // Last 8 hex chars are the checksum
72    let checksum_hex = &encoded[encoded.len() - 8..];
73    let payload = &encoded[..encoded.len() - 8];
74
75    // Verify checksum
76    let expected_checksum = hash256(payload.as_bytes());
77    let expected_hex = to_hex(&expected_checksum[..4]);
78    if checksum_hex != expected_hex {
79        return Err(ScriptError::InvalidFormat(
80            "BIP276: checksum mismatch".to_string(),
81        ));
82    }
83
84    // Extract data hex (between network and checksum)
85    let data_hex = &rest[4..rest.len() - 8];
86    let data = if data_hex.is_empty() {
87        Vec::new()
88    } else {
89        from_hex(data_hex)
90            .map_err(|e| ScriptError::InvalidFormat(format!("BIP276: invalid data hex: {e}")))?
91    };
92
93    Ok((prefix.to_string(), version, network, data))
94}
95
96/// Convenience function: encode a Script in BIP276 format with standard prefix.
97pub fn encode_script_bip276(script: &Script, network: u8) -> Result<String, ScriptError> {
98    encode_bip276(BIP276_PREFIX, 1, network, &script.to_binary())
99}
100
101/// Convenience function: decode a BIP276 string expecting standard script prefix.
102pub fn decode_script_bip276(encoded: &str) -> Result<Script, ScriptError> {
103    let (prefix, _version, _network, data) = decode_bip276(encoded)?;
104    if prefix != BIP276_PREFIX {
105        return Err(ScriptError::InvalidFormat(format!(
106            "BIP276: expected prefix '{BIP276_PREFIX}', got '{prefix}'"
107        )));
108    }
109    Ok(Script::from_binary(&data))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_encode_decode_roundtrip() {
118        let data = vec![0x76, 0xa9, 0x14];
119        let encoded = encode_bip276("bitcoin-script", 1, 1, &data).unwrap();
120
121        let (prefix, version, network, decoded_data) = decode_bip276(&encoded).unwrap();
122        assert_eq!(prefix, "bitcoin-script");
123        assert_eq!(version, 1);
124        assert_eq!(network, 1);
125        assert_eq!(decoded_data, data);
126    }
127
128    #[test]
129    fn test_encode_format() {
130        let data = vec![0xab, 0xcd];
131        let encoded = encode_bip276("bitcoin-script", 1, 2, &data).unwrap();
132
133        // Should start with prefix:
134        assert!(encoded.starts_with("bitcoin-script:"));
135        // After colon: 01 (version) 02 (network) abcd (data) + 8 hex checksum
136        let rest = &encoded["bitcoin-script:".len()..];
137        assert!(rest.starts_with("0102abcd"));
138        // Total rest length: 4 (version+network) + 4 (data) + 8 (checksum) = 16
139        assert_eq!(rest.len(), 16);
140    }
141
142    #[test]
143    fn test_invalid_checksum() {
144        let data = vec![0x76, 0xa9];
145        let mut encoded = encode_bip276("bitcoin-script", 1, 1, &data).unwrap();
146
147        // Tamper with last character
148        let len = encoded.len();
149        let last = encoded.chars().last().unwrap();
150        encoded.truncate(len - 1);
151        encoded.push(if last == '0' { '1' } else { '0' });
152
153        let result = decode_bip276(&encoded);
154        assert!(result.is_err());
155    }
156
157    #[test]
158    fn test_invalid_version_zero() {
159        let result = encode_bip276("bitcoin-script", 0, 1, &[0x01]);
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn test_invalid_network_zero() {
165        let result = encode_bip276("bitcoin-script", 1, 0, &[0x01]);
166        assert!(result.is_err());
167    }
168
169    #[test]
170    fn test_script_bip276_roundtrip() {
171        // P2PKH-like script bytes
172        let script = Script::from_binary(&[0x76, 0xa9, 0x14, 0xab, 0xab]);
173        let encoded = encode_script_bip276(&script, 1).unwrap();
174        let decoded = decode_script_bip276(&encoded).unwrap();
175        assert_eq!(decoded.to_binary(), script.to_binary());
176    }
177
178    #[test]
179    fn test_decode_wrong_prefix() {
180        // Encode with a custom prefix, try to decode as script
181        let encoded = encode_bip276("custom-prefix", 1, 1, &[0x01]).unwrap();
182        let result = decode_script_bip276(&encoded);
183        assert!(result.is_err());
184    }
185
186    #[test]
187    fn test_empty_data() {
188        let encoded = encode_bip276("bitcoin-script", 1, 1, &[]).unwrap();
189        let (_, _, _, data) = decode_bip276(&encoded).unwrap();
190        assert!(data.is_empty());
191    }
192}