use super::template_data::{PREFIX_HEX, SUFFIX_HEX};
fn script_num_unsigned(mut n: u64) -> Vec<u8> {
if n == 0 {
return Vec::new();
}
let mut out = Vec::new();
while n > 0 {
out.push((n & 0xff) as u8);
n >>= 8;
}
if out.last().unwrap() & 0x80 != 0 {
out.push(0x00);
}
out
}
fn push_data(data: &[u8]) -> Vec<u8> {
if data.is_empty() {
return vec![0x00]; }
if data.len() == 1 && (1..=16).contains(&data[0]) {
return vec![0x50 + data[0]]; }
if data.len() == 1 && data[0] == 0x81 {
return vec![0x4f]; }
if data.len() <= 75 {
let mut v = Vec::with_capacity(1 + data.len());
v.push(data.len() as u8);
v.extend_from_slice(data);
return v;
}
let mut v = Vec::with_capacity(2 + data.len());
v.push(0x4c);
v.push(data.len() as u8);
v.extend_from_slice(data);
v
}
fn push_num(n: u64) -> Vec<u8> {
push_data(&script_num_unsigned(n))
}
pub fn build_locking_script(recipient: &[u8], lock_until: u64, amount: u64) -> Result<Vec<u8>, String> {
if recipient.len() != 33 {
return Err(format!(
"recipient must be a 33-byte compressed pubkey, got {} bytes",
recipient.len()
));
}
let prefix = hex::decode(PREFIX_HEX).map_err(|e| format!("bad PREFIX_HEX: {e}"))?;
let suffix = hex::decode(SUFFIX_HEX).map_err(|e| format!("bad SUFFIX_HEX: {e}"))?;
let mut s = Vec::with_capacity(prefix.len() + 34 + 12 + suffix.len());
s.extend_from_slice(&prefix);
s.push(0x21); s.extend_from_slice(recipient);
s.extend_from_slice(&push_num(lock_until));
s.extend_from_slice(&push_num(amount));
s.extend_from_slice(&suffix);
Ok(s)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CovenantParams {
pub recipient: Vec<u8>, pub lock_until: u64,
pub amount: u64,
}
fn read_push_num(script: &[u8], i: usize) -> Result<(u64, usize), String> {
let op = *script.get(i).ok_or("unexpected end of script reading push")?;
match op {
0x00 => Ok((0, i + 1)), 0x51..=0x60 => Ok(((op - 0x50) as u64, i + 1)), 0x01..=0x4b => {
let len = op as usize;
let bytes = script
.get(i + 1..i + 1 + len)
.ok_or("push data out of range")?;
Ok((decode_script_num(bytes)?, i + 1 + len))
}
0x4c => {
let len = *script.get(i + 1).ok_or("PUSHDATA1 len missing")? as usize;
let bytes = script
.get(i + 2..i + 2 + len)
.ok_or("PUSHDATA1 data out of range")?;
Ok((decode_script_num(bytes)?, i + 2 + len))
}
other => Err(format!("unexpected push opcode 0x{other:02x}")),
}
}
fn decode_script_num(bytes: &[u8]) -> Result<u64, String> {
if bytes.len() > 8 {
return Err("script number too large".into());
}
let mut v: u64 = 0;
for (k, b) in bytes.iter().enumerate() {
let mut byte = *b as u64;
if k == bytes.len() - 1 {
byte &= 0x7f; }
v |= byte << (8 * k);
}
Ok(v)
}
pub fn parse_locking_script(script: &[u8]) -> Result<CovenantParams, String> {
let prefix = hex::decode(PREFIX_HEX).map_err(|e| e.to_string())?;
let suffix = hex::decode(SUFFIX_HEX).map_err(|e| e.to_string())?;
if !script.starts_with(&prefix) {
return Err("not a TimeLockedGift covenant (prefix mismatch)".into());
}
if !script.ends_with(&suffix) {
return Err("not a TimeLockedGift covenant (suffix mismatch)".into());
}
let mut i = prefix.len();
if script.get(i) != Some(&0x21) {
return Err("expected 33-byte pubkey push after prefix".into());
}
i += 1;
let recipient = script
.get(i..i + 33)
.ok_or("pubkey out of range")?
.to_vec();
i += 33;
let (lock_until, ni) = read_push_num(script, i)?;
i = ni;
let (amount, ni) = read_push_num(script, i)?;
i = ni;
if script.get(i..) != Some(suffix.as_slice()) {
return Err("covenant param region malformed".into());
}
Ok(CovenantParams {
recipient,
lock_until,
amount,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
#[test]
fn scriptnum_and_push_rules() {
assert_eq!(script_num_unsigned(0), Vec::<u8>::new());
assert_eq!(script_num_unsigned(1), vec![0x01]);
assert_eq!(script_num_unsigned(0x7f), vec![0x7f]);
assert_eq!(script_num_unsigned(0x80), vec![0x80, 0x00]); assert_eq!(script_num_unsigned(0xff), vec![0xff, 0x00]);
assert_eq!(script_num_unsigned(0x1122_3344), vec![0x44, 0x33, 0x22, 0x11]);
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]); }
#[test]
fn rejects_bad_pubkey_len() {
assert!(build_locking_script(&[0x02; 32], 600_000_000, 2000).is_err());
}
#[test]
fn parity_with_scrypt_compiler() {
let fixtures: Value =
serde_json::from_str(include_str!("covenant_vectors.json")).expect("valid fixtures json");
let vectors = fixtures["vectors"].as_array().expect("vectors array");
let mut checked = 0usize;
for v in vectors {
let pub_hex = v["pub"].as_str().unwrap();
let lock: u64 = v["lockUntil"].as_str().unwrap().parse().unwrap();
let amt: u64 = v["amount"].as_str().unwrap().parse().unwrap();
let want = v["hex"].as_str().unwrap();
let pubkey = hex::decode(pub_hex).unwrap();
let got = hex::encode(build_locking_script(&pubkey, lock, amt).unwrap());
assert_eq!(
got, want,
"covenant script mismatch for lockUntil={lock} amount={amt} pub={pub_hex}"
);
checked += 1;
}
assert!(checked >= 100, "expected >=100 fixture vectors, got {checked}");
eprintln!("✅ Rust covenant byte-identical to sCrypt over {checked} vectors");
}
#[test]
fn parse_roundtrips_build() {
let fixtures: Value =
serde_json::from_str(include_str!("covenant_vectors.json")).unwrap();
for v in fixtures["vectors"].as_array().unwrap() {
let pub_hex = v["pub"].as_str().unwrap();
let lock: u64 = v["lockUntil"].as_str().unwrap().parse().unwrap();
let amt: u64 = v["amount"].as_str().unwrap().parse().unwrap();
let pubkey = hex::decode(pub_hex).unwrap();
let script = build_locking_script(&pubkey, lock, amt).unwrap();
let parsed = parse_locking_script(&script).unwrap();
assert_eq!(parsed.recipient, pubkey);
assert_eq!(parsed.lock_until, lock, "lockUntil roundtrip");
assert_eq!(parsed.amount, amt, "amount roundtrip");
}
}
#[test]
fn parse_rejects_non_covenant() {
assert!(parse_locking_script(&[0x76, 0xa9, 0x14]).is_err());
}
}