Skip to main content

bsv_wallet_cli/gift/
claim.rs

1//! Build a fully-signed claim transaction for a TimeLockedGift covenant.
2//!
3//! The claim spends the covenant (deposit vout 0) + the recipient-owned fee UTXO
4//! (deposit vout 1), pays the pinned `amount` to the recipient's P2PKH, sends the
5//! leftover fee-utxo as change, and sets nLockTime = lockUntil + a non-final
6//! sequence (so it satisfies `timeLock` and can only confirm post-unlock).
7//!
8//! Covenant input unlock = `<sig> <preimage>` (ANYONECANPAY|SINGLE|FORKID), where
9//! `preimage` is the BIP-143 sighash preimage the covenant re-derives and checks.
10//! Fee input unlock = standard P2PKH `<sig> <pubkey>` (ALL|FORKID).
11//!
12//! Pure / no I/O — callers fetch the deposit and broadcast. Shared by gift-claim
13//! (broadcast) and gift-inspect (build to prove signability, then discard).
14
15use bsv_sdk::primitives::bsv::sighash::{
16    build_sighash_preimage, compute_sighash_for_signing, parse_transaction, SighashParams, TxInput,
17    TxOutput, SIGHASH_ALL, SIGHASH_ANYONECANPAY, SIGHASH_FORKID, SIGHASH_SINGLE,
18};
19use bsv_sdk::primitives::bsv::TransactionSignature;
20use bsv_sdk::primitives::{hash160, sha256d, to_hex, PrivateKey, Writer};
21
22use super::covenant::{parse_locking_script, CovenantParams};
23
24const TX_VERSION: u32 = 1;
25const COVENANT_VOUT: usize = 0;
26const FEE_VOUT: usize = 1;
27const NON_FINAL_SEQUENCE: u32 = 0xffff_fffe;
28/// Fee rate in sats per 1000 bytes. Matches the wallet/toolbox default
29/// (`DEFAULT_FEE_RATE_SAT_PER_KB = 101`) so the claim pays the same ~100 sat/KB
30/// network floor as every other tx instead of overpaying ~10x.
31const FEE_RATE_SAT_PER_KB: u64 = 101;
32
33pub struct ClaimPlan {
34    pub deposit_txid: String,
35    pub covenant: CovenantParams,
36    pub fee_utxo_sats: u64,
37    pub fee: u64,
38    pub change: u64,
39    pub claim_raw_hex: String,
40    pub claim_txid: String,
41}
42
43fn p2pkh_script(pkh: &[u8]) -> Vec<u8> {
44    let mut s = Vec::with_capacity(25);
45    s.extend_from_slice(&[0x76, 0xa9, 0x14]);
46    s.extend_from_slice(pkh);
47    s.extend_from_slice(&[0x88, 0xac]);
48    s
49}
50
51/// Minimal data push (direct / PUSHDATA1 / PUSHDATA2 / PUSHDATA4) for unlocking scripts.
52fn push_bytes(data: &[u8]) -> Vec<u8> {
53    let n = data.len();
54    let mut v = Vec::with_capacity(n + 4);
55    if n < 0x4c {
56        v.push(n as u8);
57    } else if n <= 0xff {
58        v.push(0x4c);
59        v.push(n as u8);
60    } else if n <= 0xffff {
61        v.push(0x4d);
62        v.extend_from_slice(&(n as u16).to_le_bytes());
63    } else {
64        v.push(0x4e);
65        v.extend_from_slice(&(n as u32).to_le_bytes());
66    }
67    v.extend_from_slice(data);
68    v
69}
70
71fn serialize_tx(version: u32, inputs: &[TxInput], outputs: &[TxOutput], locktime: u32) -> Vec<u8> {
72    let mut w = Writer::new();
73    w.write_u32_le(version);
74    w.write_var_int(inputs.len() as u64);
75    for inp in inputs {
76        w.write_bytes(&inp.txid);
77        w.write_u32_le(inp.output_index);
78        w.write_var_int(inp.script.len() as u64);
79        w.write_bytes(&inp.script);
80        w.write_u32_le(inp.sequence);
81    }
82    w.write_var_int(outputs.len() as u64);
83    for out in outputs {
84        w.write_u64_le(out.satoshis);
85        w.write_var_int(out.script.len() as u64);
86        w.write_bytes(&out.script);
87    }
88    w.write_u32_le(locktime);
89    w.into_bytes()
90}
91
92fn txid_display(raw: &[u8]) -> String {
93    let mut h = sha256d(raw);
94    h.reverse();
95    to_hex(&h)
96}
97
98/// Build a fully-signed claim. `key` MUST be the covenant recipient's key.
99/// `lock_time` is the nLockTime to set (use `covenant.lock_until` for a real claim).
100pub fn build_claim_tx(deposit_raw: &[u8], key: &PrivateKey, lock_time: u32) -> Result<ClaimPlan, String> {
101    let dep = parse_transaction(deposit_raw).map_err(|e| format!("parse deposit tx: {e}"))?;
102    let cov_out = dep.outputs.get(COVENANT_VOUT).ok_or("deposit has no vout 0")?;
103    let fee_out = dep
104        .outputs
105        .get(FEE_VOUT)
106        .ok_or("deposit has no vout 1 (recipient fee utxo)")?;
107
108    let params = parse_locking_script(&cov_out.script)?;
109    if cov_out.satoshis != params.amount {
110        return Err(format!(
111            "covenant output value {} != pinned amount {}",
112            cov_out.satoshis, params.amount
113        ));
114    }
115    let our_pub = key.public_key().to_compressed();
116    if our_pub.as_slice() != params.recipient.as_slice() {
117        return Err("this gift is not locked to your key".into());
118    }
119
120    let pay = p2pkh_script(&hash160(&params.recipient));
121    let dep_txid_internal = sha256d(deposit_raw);
122
123    let mut inputs = vec![
124        TxInput {
125            txid: dep_txid_internal,
126            output_index: COVENANT_VOUT as u32,
127            script: vec![],
128            sequence: NON_FINAL_SEQUENCE,
129        },
130        TxInput {
131            txid: dep_txid_internal,
132            output_index: FEE_VOUT as u32,
133            script: vec![],
134            sequence: NON_FINAL_SEQUENCE,
135        },
136    ];
137    let mut outputs = vec![
138        // output 0: PINNED — exactly `amount` to recipient (covenant enforces this)
139        TxOutput {
140            satoshis: params.amount,
141            script: pay.clone(),
142        },
143        // output 1: change from the fee utxo (value finalized after sizing)
144        TxOutput {
145            satoshis: fee_out.satoshis,
146            script: pay.clone(),
147        },
148    ];
149
150    // ---- unlock covenant input 0 (ANYONECANPAY | SINGLE | FORKID) ----
151    let cov_scope = SIGHASH_ANYONECANPAY | SIGHASH_SINGLE | SIGHASH_FORKID; // 0xc3
152    let (preimage, cov_sighash) = {
153        let p = SighashParams {
154            version: TX_VERSION as i32,
155            inputs: &inputs,
156            outputs: &outputs,
157            locktime: lock_time,
158            input_index: COVENANT_VOUT,
159            subscript: &cov_out.script,
160            satoshis: params.amount,
161            scope: cov_scope,
162        };
163        (build_sighash_preimage(&p), compute_sighash_for_signing(&p))
164    };
165    let cov_sig = key.sign(&cov_sighash).map_err(|e| format!("covenant sign: {e}"))?;
166    let cov_txsig = TransactionSignature::new(cov_sig, cov_scope).to_low_s();
167    let mut cov_unlock = push_bytes(&cov_txsig.to_checksig_format());
168    cov_unlock.extend_from_slice(&push_bytes(&preimage));
169    inputs[0].script = cov_unlock;
170
171    // ---- size + fee (covenant unlock attached; reserve ~108B for the fee unlock) ----
172    inputs[1].script = vec![0u8; 108];
173    let est = serialize_tx(TX_VERSION, &inputs, &outputs, lock_time).len() as u64;
174    inputs[1].script = vec![];
175    // fee = ceil(size_bytes * rate / 1000) at the network floor (101 sat/KB), with
176    // a 1-sat buffer. The 108B fee-unlock reservation slightly over-estimates the
177    // real ~107B unlock, so `est` (and thus the fee) is never under the true size.
178    let fee = (est * FEE_RATE_SAT_PER_KB).div_ceil(1000) + 1;
179    if fee_out.satoshis <= fee {
180        return Err(format!(
181            "fee utxo {} is too small for the ~{fee}-sat claim fee",
182            fee_out.satoshis
183        ));
184    }
185    let change = fee_out.satoshis - fee;
186    if change < 1 {
187        outputs.pop(); // no room for change; whole fee utxo is the fee
188    } else {
189        outputs[1].satoshis = change;
190    }
191
192    // ---- sign fee input 1 (ALL | FORKID) as standard P2PKH ----
193    let fee_scope = SIGHASH_ALL | SIGHASH_FORKID; // 0x41
194    let fee_sighash = {
195        let p = SighashParams {
196            version: TX_VERSION as i32,
197            inputs: &inputs,
198            outputs: &outputs,
199            locktime: lock_time,
200            input_index: FEE_VOUT,
201            subscript: &fee_out.script,
202            satoshis: fee_out.satoshis,
203            scope: fee_scope,
204        };
205        compute_sighash_for_signing(&p)
206    };
207    let fee_sig = key.sign(&fee_sighash).map_err(|e| format!("fee sign: {e}"))?;
208    let fee_txsig = TransactionSignature::new(fee_sig, fee_scope).to_low_s();
209    let mut fee_unlock = push_bytes(&fee_txsig.to_checksig_format());
210    fee_unlock.extend_from_slice(&push_bytes(&our_pub));
211    inputs[1].script = fee_unlock;
212
213    let raw = serialize_tx(TX_VERSION, &inputs, &outputs, lock_time);
214    let claim_txid = txid_display(&raw);
215
216    Ok(ClaimPlan {
217        deposit_txid: txid_display(deposit_raw),
218        fee_utxo_sats: fee_out.satoshis,
219        fee,
220        change: outputs.get(1).map(|o| o.satoshis).unwrap_or(0),
221        claim_raw_hex: to_hex(&raw),
222        claim_txid,
223        covenant: params,
224    })
225}