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!("{}:{:02x}{:02x}{}", prefix, version, network, 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 '{}', got '{}'",
107            BIP276_PREFIX, prefix
108        )));
109    }
110    Ok(Script::from_binary(&data))
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_encode_decode_roundtrip() {
119        let data = vec![0x76, 0xa9, 0x14];
120        let encoded = encode_bip276("bitcoin-script", 1, 1, &data).unwrap();
121
122        let (prefix, version, network, decoded_data) = decode_bip276(&encoded).unwrap();
123        assert_eq!(prefix, "bitcoin-script");
124        assert_eq!(version, 1);
125        assert_eq!(network, 1);
126        assert_eq!(decoded_data, data);
127    }
128
129    #[test]
130    fn test_encode_format() {
131        let data = vec![0xab, 0xcd];
132        let encoded = encode_bip276("bitcoin-script", 1, 2, &data).unwrap();
133
134        // Should start with prefix:
135        assert!(encoded.starts_with("bitcoin-script:"));
136        // After colon: 01 (version) 02 (network) abcd (data) + 8 hex checksum
137        let rest = &encoded["bitcoin-script:".len()..];
138        assert!(rest.starts_with("0102abcd"));
139        // Total rest length: 4 (version+network) + 4 (data) + 8 (checksum) = 16
140        assert_eq!(rest.len(), 16);
141    }
142
143    #[test]
144    fn test_invalid_checksum() {
145        let data = vec![0x76, 0xa9];
146        let mut encoded = encode_bip276("bitcoin-script", 1, 1, &data).unwrap();
147
148        // Tamper with last character
149        let len = encoded.len();
150        let last = encoded.chars().last().unwrap();
151        encoded.truncate(len - 1);
152        encoded.push(if last == '0' { '1' } else { '0' });
153
154        let result = decode_bip276(&encoded);
155        assert!(result.is_err());
156    }
157
158    #[test]
159    fn test_invalid_version_zero() {
160        let result = encode_bip276("bitcoin-script", 0, 1, &[0x01]);
161        assert!(result.is_err());
162    }
163
164    #[test]
165    fn test_invalid_network_zero() {
166        let result = encode_bip276("bitcoin-script", 1, 0, &[0x01]);
167        assert!(result.is_err());
168    }
169
170    #[test]
171    fn test_script_bip276_roundtrip() {
172        // P2PKH-like script bytes
173        let script = Script::from_binary(&[0x76, 0xa9, 0x14, 0xab, 0xab]);
174        let encoded = encode_script_bip276(&script, 1).unwrap();
175        let decoded = decode_script_bip276(&encoded).unwrap();
176        assert_eq!(decoded.to_binary(), script.to_binary());
177    }
178
179    #[test]
180    fn test_decode_wrong_prefix() {
181        // Encode with a custom prefix, try to decode as script
182        let encoded = encode_bip276("custom-prefix", 1, 1, &[0x01]).unwrap();
183        let result = decode_script_bip276(&encoded);
184        assert!(result.is_err());
185    }
186
187    #[test]
188    fn test_empty_data() {
189        let encoded = encode_bip276("bitcoin-script", 1, 1, &[]).unwrap();
190        let (_, _, _, data) = decode_bip276(&encoded).unwrap();
191        assert!(data.is_empty());
192    }
193}