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(
101    deposit_raw: &[u8],
102    key: &PrivateKey,
103    lock_time: u32,
104) -> Result<ClaimPlan, String> {
105    let dep = parse_transaction(deposit_raw).map_err(|e| format!("parse deposit tx: {e}"))?;
106    let cov_out = dep
107        .outputs
108        .get(COVENANT_VOUT)
109        .ok_or("deposit has no vout 0")?;
110    let fee_out = dep
111        .outputs
112        .get(FEE_VOUT)
113        .ok_or("deposit has no vout 1 (recipient fee utxo)")?;
114
115    let params = parse_locking_script(&cov_out.script)?;
116    if cov_out.satoshis != params.amount {
117        return Err(format!(
118            "covenant output value {} != pinned amount {}",
119            cov_out.satoshis, params.amount
120        ));
121    }
122    let our_pub = key.public_key().to_compressed();
123    if our_pub.as_slice() != params.recipient.as_slice() {
124        return Err("this gift is not locked to your key".into());
125    }
126
127    let pay = p2pkh_script(&hash160(&params.recipient));
128    let dep_txid_internal = sha256d(deposit_raw);
129
130    let mut inputs = vec![
131        TxInput {
132            txid: dep_txid_internal,
133            output_index: COVENANT_VOUT as u32,
134            script: vec![],
135            sequence: NON_FINAL_SEQUENCE,
136        },
137        TxInput {
138            txid: dep_txid_internal,
139            output_index: FEE_VOUT as u32,
140            script: vec![],
141            sequence: NON_FINAL_SEQUENCE,
142        },
143    ];
144    let mut outputs = vec![
145        // output 0: PINNED — exactly `amount` to recipient (covenant enforces this)
146        TxOutput {
147            satoshis: params.amount,
148            script: pay.clone(),
149        },
150        // output 1: change from the fee utxo (value finalized after sizing)
151        TxOutput {
152            satoshis: fee_out.satoshis,
153            script: pay.clone(),
154        },
155    ];
156
157    // ---- unlock covenant input 0 (ANYONECANPAY | SINGLE | FORKID) ----
158    let cov_scope = SIGHASH_ANYONECANPAY | SIGHASH_SINGLE | SIGHASH_FORKID; // 0xc3
159    let (preimage, cov_sighash) = {
160        let p = SighashParams {
161            version: TX_VERSION as i32,
162            inputs: &inputs,
163            outputs: &outputs,
164            locktime: lock_time,
165            input_index: COVENANT_VOUT,
166            subscript: &cov_out.script,
167            satoshis: params.amount,
168            scope: cov_scope,
169        };
170        (build_sighash_preimage(&p), compute_sighash_for_signing(&p))
171    };
172    let cov_sig = key
173        .sign(&cov_sighash)
174        .map_err(|e| format!("covenant sign: {e}"))?;
175    let cov_txsig = TransactionSignature::new(cov_sig, cov_scope).to_low_s();
176    let mut cov_unlock = push_bytes(&cov_txsig.to_checksig_format());
177    cov_unlock.extend_from_slice(&push_bytes(&preimage));
178    inputs[0].script = cov_unlock;
179
180    // ---- size + fee (covenant unlock attached; reserve ~108B for the fee unlock) ----
181    inputs[1].script = vec![0u8; 108];
182    let est = serialize_tx(TX_VERSION, &inputs, &outputs, lock_time).len() as u64;
183    inputs[1].script = vec![];
184    // fee = ceil(size_bytes * rate / 1000) at the network floor (101 sat/KB), with
185    // a 1-sat buffer. The 108B fee-unlock reservation slightly over-estimates the
186    // real ~107B unlock, so `est` (and thus the fee) is never under the true size.
187    let fee = (est * FEE_RATE_SAT_PER_KB).div_ceil(1000) + 1;
188    if fee_out.satoshis <= fee {
189        return Err(format!(
190            "fee utxo {} is too small for the ~{fee}-sat claim fee",
191            fee_out.satoshis
192        ));
193    }
194    let change = fee_out.satoshis - fee;
195    if change < 1 {
196        outputs.pop(); // no room for change; whole fee utxo is the fee
197    } else {
198        outputs[1].satoshis = change;
199    }
200
201    // ---- sign fee input 1 (ALL | FORKID) as standard P2PKH ----
202    let fee_scope = SIGHASH_ALL | SIGHASH_FORKID; // 0x41
203    let fee_sighash = {
204        let p = SighashParams {
205            version: TX_VERSION as i32,
206            inputs: &inputs,
207            outputs: &outputs,
208            locktime: lock_time,
209            input_index: FEE_VOUT,
210            subscript: &fee_out.script,
211            satoshis: fee_out.satoshis,
212            scope: fee_scope,
213        };
214        compute_sighash_for_signing(&p)
215    };
216    let fee_sig = key
217        .sign(&fee_sighash)
218        .map_err(|e| format!("fee sign: {e}"))?;
219    let fee_txsig = TransactionSignature::new(fee_sig, fee_scope).to_low_s();
220    let mut fee_unlock = push_bytes(&fee_txsig.to_checksig_format());
221    fee_unlock.extend_from_slice(&push_bytes(&our_pub));
222    inputs[1].script = fee_unlock;
223
224    let raw = serialize_tx(TX_VERSION, &inputs, &outputs, lock_time);
225    let claim_txid = txid_display(&raw);
226
227    Ok(ClaimPlan {
228        deposit_txid: txid_display(deposit_raw),
229        fee_utxo_sats: fee_out.satoshis,
230        fee,
231        change: outputs.get(1).map(|o| o.satoshis).unwrap_or(0),
232        claim_raw_hex: to_hex(&raw),
233        claim_txid,
234        covenant: params,
235    })
236}