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(
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); // push 33 bytes
78    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/// The constructor params recovered from a covenant locking script.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct CovenantParams {
88    pub recipient: Vec<u8>, // 33-byte compressed pubkey
89    pub lock_until: u64,
90    pub amount: u64,
91}
92
93/// Decode a minimal-pushed non-negative integer at `script[i..]`, returning
94/// (value, next_index). Inverse of `push_num`.
95fn 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)),                         // OP_0
101        0x51..=0x60 => Ok(((op - 0x50) as u64, i + 1)), // OP_1 ..= OP_16
102        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
120/// Decode CScriptNum (minimal signed LE) as a non-negative integer.
121fn decode_script_num(bytes: &[u8]) -> Result<u64, String> {
122    if bytes.len() > 8 {
123        return Err("script number too large".into());
124    }
125    // top bit of the most-significant byte is the sign; our params are positive.
126    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; // strip sign bit (positive values only)
131        }
132        v |= byte << (8 * k);
133    }
134    Ok(v)
135}
136
137/// Parse a TimeLockedGift covenant locking script back into its params, verifying
138/// it really is this covenant (PREFIX/SUFFIX must match the compiled template).
139pub 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]); // sign byte appended
180        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]); // OP_1
186        assert_eq!(push_data(&script_num_unsigned(16)), vec![0x60]); // OP_16
187        assert_eq!(push_data(&script_num_unsigned(17)), vec![0x01, 0x11]); // direct push
188    }
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    /// THE correctness gate: the Rust builder must equal the real sCrypt compiler
196    /// output across every fixture vector.
197    #[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    /// build → parse must round-trip exactly (so gift-claim/inspect recover the
224    /// right recipient/lockUntil/amount straight from chain).
225    #[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}