Skip to main content

bsv_wallet_cli/gift/
covenant.rs

1//! Byte-exact builder for the TimeLockedGift covenant locking script.
2
3use super::template_data::{PREFIX_HEX, SUFFIX_HEX};
4
5/// CScriptNum minimal encoding of a non-negative integer.
6///
7/// `lockUntil` and `amount` are always positive, so we only need the positive
8/// branch: minimal little-endian magnitude bytes, with a `0x00` high byte
9/// appended when the top bit of the most-significant byte is set (otherwise the
10/// value would read as negative under Bitcoin's signed-magnitude rule). Matches
11/// sCrypt / scryptlib exactly.
12fn 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
27/// Minimal push of `data` (OP_0 / OP_1..OP_16 / OP_1NEGATE / direct / PUSHDATA1),
28/// matching how scryptlib encodes constructor params.
29fn push_data(data: &[u8]) -> Vec<u8> {
30    if data.is_empty() {
31        return vec![0x00]; // OP_0
32    }
33    if data.len() == 1 && (1..=16).contains(&data[0]) {
34        return vec![0x50 + data[0]]; // OP_1 ..= OP_16
35    }
36    if data.len() == 1 && data[0] == 0x81 {
37        return vec![0x4f]; // OP_1NEGATE (unused for positive params, kept for fidelity)
38    }
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    // PUSHDATA1 (only reachable for absurdly large amounts; included for completeness)
46    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
57/// Build the TimeLockedGift covenant locking script, byte-identical to the sCrypt
58/// compiler. `recipient` must be a 33-byte compressed public key. `lock_until` is
59/// a unix timestamp (>= 500_000_000); `amount` is the satoshis paid to the
60/// recipient on claim.
61pub 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); // push 33 bytes
74    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/// The constructor params recovered from a covenant locking script.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct CovenantParams {
84    pub recipient: Vec<u8>, // 33-byte compressed pubkey
85    pub lock_until: u64,
86    pub amount: u64,
87}
88
89/// Decode a minimal-pushed non-negative integer at `script[i..]`, returning
90/// (value, next_index). Inverse of `push_num`.
91fn 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)),                       // OP_0
95        0x51..=0x60 => Ok(((op - 0x50) as u64, i + 1)), // OP_1 ..= OP_16
96        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
114/// Decode CScriptNum (minimal signed LE) as a non-negative integer.
115fn decode_script_num(bytes: &[u8]) -> Result<u64, String> {
116    if bytes.len() > 8 {
117        return Err("script number too large".into());
118    }
119    // top bit of the most-significant byte is the sign; our params are positive.
120    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; // strip sign bit (positive values only)
125        }
126        v |= byte << (8 * k);
127    }
128    Ok(v)
129}
130
131/// Parse a TimeLockedGift covenant locking script back into its params, verifying
132/// it really is this covenant (PREFIX/SUFFIX must match the compiled template).
133pub 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]); // sign byte appended
177        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]); // OP_1
180        assert_eq!(push_data(&script_num_unsigned(16)), vec![0x60]); // OP_16
181        assert_eq!(push_data(&script_num_unsigned(17)), vec![0x01, 0x11]); // direct push
182    }
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    /// THE correctness gate: the Rust builder must equal the real sCrypt compiler
190    /// output across every fixture vector.
191    #[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    /// build → parse must round-trip exactly (so gift-claim/inspect recover the
215    /// right recipient/lockUntil/amount straight from chain).
216    #[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}