bsv_wallet_cli/gift/
covenant.rs1use super::template_data::{PREFIX_HEX, SUFFIX_HEX};
4
5fn script_num_unsigned(mut n: u64) -> Vec<u8> {
13 if n == 0 {
14 return Vec::new();
15 }
16 let mut out = Vec::new();
17 while n > 0 {
18 out.push((n & 0xff) as u8);
19 n >>= 8;
20 }
21 if out.last().unwrap() & 0x80 != 0 {
22 out.push(0x00);
23 }
24 out
25}
26
27fn push_data(data: &[u8]) -> Vec<u8> {
30 if data.is_empty() {
31 return vec![0x00]; }
33 if data.len() == 1 && (1..=16).contains(&data[0]) {
34 return vec![0x50 + data[0]]; }
36 if data.len() == 1 && data[0] == 0x81 {
37 return vec![0x4f]; }
39 if data.len() <= 75 {
40 let mut v = Vec::with_capacity(1 + data.len());
41 v.push(data.len() as u8);
42 v.extend_from_slice(data);
43 return v;
44 }
45 let mut v = Vec::with_capacity(2 + data.len());
47 v.push(0x4c);
48 v.push(data.len() as u8);
49 v.extend_from_slice(data);
50 v
51}
52
53fn push_num(n: u64) -> Vec<u8> {
54 push_data(&script_num_unsigned(n))
55}
56
57pub fn build_locking_script(recipient: &[u8], lock_until: u64, amount: u64) -> Result<Vec<u8>, String> {
62 if recipient.len() != 33 {
63 return Err(format!(
64 "recipient must be a 33-byte compressed pubkey, got {} bytes",
65 recipient.len()
66 ));
67 }
68 let prefix = hex::decode(PREFIX_HEX).map_err(|e| format!("bad PREFIX_HEX: {e}"))?;
69 let suffix = hex::decode(SUFFIX_HEX).map_err(|e| format!("bad SUFFIX_HEX: {e}"))?;
70
71 let mut s = Vec::with_capacity(prefix.len() + 34 + 12 + suffix.len());
72 s.extend_from_slice(&prefix);
73 s.push(0x21); s.extend_from_slice(recipient);
75 s.extend_from_slice(&push_num(lock_until));
76 s.extend_from_slice(&push_num(amount));
77 s.extend_from_slice(&suffix);
78 Ok(s)
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct CovenantParams {
84 pub recipient: Vec<u8>, pub lock_until: u64,
86 pub amount: u64,
87}
88
89fn read_push_num(script: &[u8], i: usize) -> Result<(u64, usize), String> {
92 let op = *script.get(i).ok_or("unexpected end of script reading push")?;
93 match op {
94 0x00 => Ok((0, i + 1)), 0x51..=0x60 => Ok(((op - 0x50) as u64, i + 1)), 0x01..=0x4b => {
97 let len = op as usize;
98 let bytes = script
99 .get(i + 1..i + 1 + len)
100 .ok_or("push data out of range")?;
101 Ok((decode_script_num(bytes)?, i + 1 + len))
102 }
103 0x4c => {
104 let len = *script.get(i + 1).ok_or("PUSHDATA1 len missing")? as usize;
105 let bytes = script
106 .get(i + 2..i + 2 + len)
107 .ok_or("PUSHDATA1 data out of range")?;
108 Ok((decode_script_num(bytes)?, i + 2 + len))
109 }
110 other => Err(format!("unexpected push opcode 0x{other:02x}")),
111 }
112}
113
114fn decode_script_num(bytes: &[u8]) -> Result<u64, String> {
116 if bytes.len() > 8 {
117 return Err("script number too large".into());
118 }
119 let mut v: u64 = 0;
121 for (k, b) in bytes.iter().enumerate() {
122 let mut byte = *b as u64;
123 if k == bytes.len() - 1 {
124 byte &= 0x7f; }
126 v |= byte << (8 * k);
127 }
128 Ok(v)
129}
130
131pub fn parse_locking_script(script: &[u8]) -> Result<CovenantParams, String> {
134 let prefix = hex::decode(PREFIX_HEX).map_err(|e| e.to_string())?;
135 let suffix = hex::decode(SUFFIX_HEX).map_err(|e| e.to_string())?;
136 if !script.starts_with(&prefix) {
137 return Err("not a TimeLockedGift covenant (prefix mismatch)".into());
138 }
139 if !script.ends_with(&suffix) {
140 return Err("not a TimeLockedGift covenant (suffix mismatch)".into());
141 }
142 let mut i = prefix.len();
143 if script.get(i) != Some(&0x21) {
144 return Err("expected 33-byte pubkey push after prefix".into());
145 }
146 i += 1;
147 let recipient = script
148 .get(i..i + 33)
149 .ok_or("pubkey out of range")?
150 .to_vec();
151 i += 33;
152 let (lock_until, ni) = read_push_num(script, i)?;
153 i = ni;
154 let (amount, ni) = read_push_num(script, i)?;
155 i = ni;
156 if script.get(i..) != Some(suffix.as_slice()) {
157 return Err("covenant param region malformed".into());
158 }
159 Ok(CovenantParams {
160 recipient,
161 lock_until,
162 amount,
163 })
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use serde_json::Value;
170
171 #[test]
172 fn scriptnum_and_push_rules() {
173 assert_eq!(script_num_unsigned(0), Vec::<u8>::new());
174 assert_eq!(script_num_unsigned(1), vec![0x01]);
175 assert_eq!(script_num_unsigned(0x7f), vec![0x7f]);
176 assert_eq!(script_num_unsigned(0x80), vec![0x80, 0x00]); assert_eq!(script_num_unsigned(0xff), vec![0xff, 0x00]);
178 assert_eq!(script_num_unsigned(0x1122_3344), vec![0x44, 0x33, 0x22, 0x11]);
179 assert_eq!(push_data(&script_num_unsigned(1)), vec![0x51]); assert_eq!(push_data(&script_num_unsigned(16)), vec![0x60]); assert_eq!(push_data(&script_num_unsigned(17)), vec![0x01, 0x11]); }
183
184 #[test]
185 fn rejects_bad_pubkey_len() {
186 assert!(build_locking_script(&[0x02; 32], 600_000_000, 2000).is_err());
187 }
188
189 #[test]
192 fn parity_with_scrypt_compiler() {
193 let fixtures: Value =
194 serde_json::from_str(include_str!("covenant_vectors.json")).expect("valid fixtures json");
195 let vectors = fixtures["vectors"].as_array().expect("vectors array");
196 let mut checked = 0usize;
197 for v in vectors {
198 let pub_hex = v["pub"].as_str().unwrap();
199 let lock: u64 = v["lockUntil"].as_str().unwrap().parse().unwrap();
200 let amt: u64 = v["amount"].as_str().unwrap().parse().unwrap();
201 let want = v["hex"].as_str().unwrap();
202 let pubkey = hex::decode(pub_hex).unwrap();
203 let got = hex::encode(build_locking_script(&pubkey, lock, amt).unwrap());
204 assert_eq!(
205 got, want,
206 "covenant script mismatch for lockUntil={lock} amount={amt} pub={pub_hex}"
207 );
208 checked += 1;
209 }
210 assert!(checked >= 100, "expected >=100 fixture vectors, got {checked}");
211 eprintln!("✅ Rust covenant byte-identical to sCrypt over {checked} vectors");
212 }
213
214 #[test]
217 fn parse_roundtrips_build() {
218 let fixtures: Value =
219 serde_json::from_str(include_str!("covenant_vectors.json")).unwrap();
220 for v in fixtures["vectors"].as_array().unwrap() {
221 let pub_hex = v["pub"].as_str().unwrap();
222 let lock: u64 = v["lockUntil"].as_str().unwrap().parse().unwrap();
223 let amt: u64 = v["amount"].as_str().unwrap().parse().unwrap();
224 let pubkey = hex::decode(pub_hex).unwrap();
225 let script = build_locking_script(&pubkey, lock, amt).unwrap();
226 let parsed = parse_locking_script(&script).unwrap();
227 assert_eq!(parsed.recipient, pubkey);
228 assert_eq!(parsed.lock_until, lock, "lockUntil roundtrip");
229 assert_eq!(parsed.amount, amt, "amount roundtrip");
230 }
231 }
232
233 #[test]
234 fn parse_rejects_non_covenant() {
235 assert!(parse_locking_script(&[0x76, 0xa9, 0x14]).is_err());
236 }
237}