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(
62 recipient: &[u8],
63 lock_until: u64,
64 amount: u64,
65) -> Result<Vec<u8>, String> {
66 if recipient.len() != 33 {
67 return Err(format!(
68 "recipient must be a 33-byte compressed pubkey, got {} bytes",
69 recipient.len()
70 ));
71 }
72 let prefix = hex::decode(PREFIX_HEX).map_err(|e| format!("bad PREFIX_HEX: {e}"))?;
73 let suffix = hex::decode(SUFFIX_HEX).map_err(|e| format!("bad SUFFIX_HEX: {e}"))?;
74
75 let mut s = Vec::with_capacity(prefix.len() + 34 + 12 + suffix.len());
76 s.extend_from_slice(&prefix);
77 s.push(0x21); s.extend_from_slice(recipient);
79 s.extend_from_slice(&push_num(lock_until));
80 s.extend_from_slice(&push_num(amount));
81 s.extend_from_slice(&suffix);
82 Ok(s)
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct CovenantParams {
88 pub recipient: Vec<u8>, pub lock_until: u64,
90 pub amount: u64,
91}
92
93fn read_push_num(script: &[u8], i: usize) -> Result<(u64, usize), String> {
96 let op = *script
97 .get(i)
98 .ok_or("unexpected end of script reading push")?;
99 match op {
100 0x00 => Ok((0, i + 1)), 0x51..=0x60 => Ok(((op - 0x50) as u64, i + 1)), 0x01..=0x4b => {
103 let len = op as usize;
104 let bytes = script
105 .get(i + 1..i + 1 + len)
106 .ok_or("push data out of range")?;
107 Ok((decode_script_num(bytes)?, i + 1 + len))
108 }
109 0x4c => {
110 let len = *script.get(i + 1).ok_or("PUSHDATA1 len missing")? as usize;
111 let bytes = script
112 .get(i + 2..i + 2 + len)
113 .ok_or("PUSHDATA1 data out of range")?;
114 Ok((decode_script_num(bytes)?, i + 2 + len))
115 }
116 other => Err(format!("unexpected push opcode 0x{other:02x}")),
117 }
118}
119
120fn decode_script_num(bytes: &[u8]) -> Result<u64, String> {
122 if bytes.len() > 8 {
123 return Err("script number too large".into());
124 }
125 let mut v: u64 = 0;
127 for (k, b) in bytes.iter().enumerate() {
128 let mut byte = *b as u64;
129 if k == bytes.len() - 1 {
130 byte &= 0x7f; }
132 v |= byte << (8 * k);
133 }
134 Ok(v)
135}
136
137pub fn parse_locking_script(script: &[u8]) -> Result<CovenantParams, String> {
140 let prefix = hex::decode(PREFIX_HEX).map_err(|e| e.to_string())?;
141 let suffix = hex::decode(SUFFIX_HEX).map_err(|e| e.to_string())?;
142 if !script.starts_with(&prefix) {
143 return Err("not a TimeLockedGift covenant (prefix mismatch)".into());
144 }
145 if !script.ends_with(&suffix) {
146 return Err("not a TimeLockedGift covenant (suffix mismatch)".into());
147 }
148 let mut i = prefix.len();
149 if script.get(i) != Some(&0x21) {
150 return Err("expected 33-byte pubkey push after prefix".into());
151 }
152 i += 1;
153 let recipient = script.get(i..i + 33).ok_or("pubkey out of range")?.to_vec();
154 i += 33;
155 let (lock_until, ni) = read_push_num(script, i)?;
156 i = ni;
157 let (amount, ni) = read_push_num(script, i)?;
158 i = ni;
159 if script.get(i..) != Some(suffix.as_slice()) {
160 return Err("covenant param region malformed".into());
161 }
162 Ok(CovenantParams {
163 recipient,
164 lock_until,
165 amount,
166 })
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use serde_json::Value;
173
174 #[test]
175 fn scriptnum_and_push_rules() {
176 assert_eq!(script_num_unsigned(0), Vec::<u8>::new());
177 assert_eq!(script_num_unsigned(1), vec![0x01]);
178 assert_eq!(script_num_unsigned(0x7f), vec![0x7f]);
179 assert_eq!(script_num_unsigned(0x80), vec![0x80, 0x00]); assert_eq!(script_num_unsigned(0xff), vec![0xff, 0x00]);
181 assert_eq!(
182 script_num_unsigned(0x1122_3344),
183 vec![0x44, 0x33, 0x22, 0x11]
184 );
185 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]); }
189
190 #[test]
191 fn rejects_bad_pubkey_len() {
192 assert!(build_locking_script(&[0x02; 32], 600_000_000, 2000).is_err());
193 }
194
195 #[test]
198 fn parity_with_scrypt_compiler() {
199 let fixtures: Value = serde_json::from_str(include_str!("covenant_vectors.json"))
200 .expect("valid fixtures json");
201 let vectors = fixtures["vectors"].as_array().expect("vectors array");
202 let mut checked = 0usize;
203 for v in vectors {
204 let pub_hex = v["pub"].as_str().unwrap();
205 let lock: u64 = v["lockUntil"].as_str().unwrap().parse().unwrap();
206 let amt: u64 = v["amount"].as_str().unwrap().parse().unwrap();
207 let want = v["hex"].as_str().unwrap();
208 let pubkey = hex::decode(pub_hex).unwrap();
209 let got = hex::encode(build_locking_script(&pubkey, lock, amt).unwrap());
210 assert_eq!(
211 got, want,
212 "covenant script mismatch for lockUntil={lock} amount={amt} pub={pub_hex}"
213 );
214 checked += 1;
215 }
216 assert!(
217 checked >= 100,
218 "expected >=100 fixture vectors, got {checked}"
219 );
220 eprintln!("✅ Rust covenant byte-identical to sCrypt over {checked} vectors");
221 }
222
223 #[test]
226 fn parse_roundtrips_build() {
227 let fixtures: Value = serde_json::from_str(include_str!("covenant_vectors.json")).unwrap();
228 for v in fixtures["vectors"].as_array().unwrap() {
229 let pub_hex = v["pub"].as_str().unwrap();
230 let lock: u64 = v["lockUntil"].as_str().unwrap().parse().unwrap();
231 let amt: u64 = v["amount"].as_str().unwrap().parse().unwrap();
232 let pubkey = hex::decode(pub_hex).unwrap();
233 let script = build_locking_script(&pubkey, lock, amt).unwrap();
234 let parsed = parse_locking_script(&script).unwrap();
235 assert_eq!(parsed.recipient, pubkey);
236 assert_eq!(parsed.lock_until, lock, "lockUntil roundtrip");
237 assert_eq!(parsed.amount, amt, "amount roundtrip");
238 }
239 }
240
241 #[test]
242 fn parse_rejects_non_covenant() {
243 assert!(parse_locking_script(&[0x76, 0xa9, 0x14]).is_err());
244 }
245}