Skip to main content

chains_sdk/bitcoin/
scripts.rs

1//! Bitcoin script helpers: HTLC, Timelock (CLTV/CSV), and Coin Selection.
2
3use crate::error::SignerError;
4
5// ═══════════════════════════════════════════════════════════════════
6// Timelock Scripts (CLTV / CSV)
7// ═══════════════════════════════════════════════════════════════════
8
9/// Build a CLTV (CheckLockTimeVerify, BIP-65) timelock script.
10///
11/// Script: `<locktime> OP_CLTV OP_DROP <pubkey_hash> OP_CHECKSIG`
12///
13/// The output can only be spent after the specified block height or time.
14///
15/// # Arguments
16/// - `locktime` — Block height (< 500_000_000) or Unix timestamp
17/// - `pubkey_hash` — 20-byte HASH160 of the recipient's public key
18pub fn cltv_script(locktime: u32, pubkey_hash: &[u8; 20]) -> Vec<u8> {
19    let mut script = Vec::with_capacity(30);
20
21    // Push locktime as minimal integer
22    push_script_number(&mut script, locktime as i64);
23
24    script.push(0xB1); // OP_CHECKLOCKTIMEVERIFY
25    script.push(0x75); // OP_DROP
26    script.push(0x76); // OP_DUP
27    script.push(0xA9); // OP_HASH160
28    script.push(0x14); // Push 20 bytes
29    script.extend_from_slice(pubkey_hash);
30    script.push(0x88); // OP_EQUALVERIFY
31    script.push(0xAC); // OP_CHECKSIG
32    script
33}
34
35/// Build a CSV (CheckSequenceVerify, BIP-112) relative timelock script.
36///
37/// Script: `<sequence> OP_CSV OP_DROP <pubkey_hash> OP_CHECKSIG`
38///
39/// The output can only be spent after a relative delay from confirmation.
40///
41/// # Arguments
42/// - `sequence` — Relative lock value (blocks or 512-second intervals)
43/// - `pubkey_hash` — 20-byte HASH160 of the recipient's public key
44pub fn csv_script(sequence: u32, pubkey_hash: &[u8; 20]) -> Vec<u8> {
45    let mut script = Vec::with_capacity(30);
46
47    push_script_number(&mut script, sequence as i64);
48
49    script.push(0xB2); // OP_CHECKSEQUENCEVERIFY
50    script.push(0x75); // OP_DROP
51    script.push(0x76); // OP_DUP
52    script.push(0xA9); // OP_HASH160
53    script.push(0x14); // Push 20 bytes
54    script.extend_from_slice(pubkey_hash);
55    script.push(0x88); // OP_EQUALVERIFY
56    script.push(0xAC); // OP_CHECKSIG
57    script
58}
59
60/// Check if a locktime value represents a block height or Unix timestamp.
61///
62/// Per BIP-65: values < 500,000,000 are block heights; >= are Unix timestamps.
63#[must_use]
64pub fn is_block_height_locktime(locktime: u32) -> bool {
65    locktime < 500_000_000
66}
67
68// ═══════════════════════════════════════════════════════════════════
69// HTLC (Hash Time-Locked Contract)
70// ═══════════════════════════════════════════════════════════════════
71
72/// Build an HTLC (Hash Time-Locked Contract) script.
73///
74/// Script:
75/// ```text
76/// OP_IF
77///   OP_SHA256 <hash> OP_EQUALVERIFY
78///   <receiver_pubkey_hash> OP_CHECKSIG          // hashlock path
79/// OP_ELSE
80///   <timeout> OP_CLTV OP_DROP
81///   <sender_pubkey_hash> OP_CHECKSIG            // timelock refund path
82/// OP_ENDIF
83/// ```
84///
85/// # Arguments
86/// - `payment_hash` — 32-byte SHA-256 hash of the preimage
87/// - `receiver_pubkey_hash` — 20-byte HASH160 of receiver
88/// - `sender_pubkey_hash` — 20-byte HASH160 of sender (refund)
89/// - `timeout` — CLTV timeout for the refund path
90pub fn htlc_script(
91    payment_hash: &[u8; 32],
92    receiver_pubkey_hash: &[u8; 20],
93    sender_pubkey_hash: &[u8; 20],
94    timeout: u32,
95) -> Vec<u8> {
96    let mut script = Vec::with_capacity(100);
97
98    // OP_IF (hashlock branch)
99    script.push(0x63); // OP_IF
100
101    script.push(0xA8); // OP_SHA256
102    script.push(0x20); // Push 32 bytes
103    script.extend_from_slice(payment_hash);
104    script.push(0x88); // OP_EQUALVERIFY
105
106    script.push(0x76); // OP_DUP
107    script.push(0xA9); // OP_HASH160
108    script.push(0x14); // Push 20 bytes
109    script.extend_from_slice(receiver_pubkey_hash);
110    script.push(0x88); // OP_EQUALVERIFY
111    script.push(0xAC); // OP_CHECKSIG
112
113    // OP_ELSE (timelock refund branch)
114    script.push(0x67); // OP_ELSE
115
116    push_script_number(&mut script, timeout as i64);
117    script.push(0xB1); // OP_CHECKLOCKTIMEVERIFY
118    script.push(0x75); // OP_DROP
119
120    script.push(0x76); // OP_DUP
121    script.push(0xA9); // OP_HASH160
122    script.push(0x14); // Push 20 bytes
123    script.extend_from_slice(sender_pubkey_hash);
124    script.push(0x88); // OP_EQUALVERIFY
125    script.push(0xAC); // OP_CHECKSIG
126
127    script.push(0x68); // OP_ENDIF
128
129    script
130}
131
132/// Compute the SHA-256 hash of a preimage for HTLC usage.
133pub fn htlc_payment_hash(preimage: &[u8]) -> [u8; 32] {
134    use sha2::{Digest, Sha256};
135    let mut hasher = Sha256::new();
136    hasher.update(preimage);
137    let result = hasher.finalize();
138    let mut out = [0u8; 32];
139    out.copy_from_slice(&result);
140    out
141}
142
143/// Build the witness for claiming an HTLC (hashlock path).
144///
145/// Witness: `<signature> <pubkey> <preimage> OP_TRUE`
146pub fn htlc_claim_witness(
147    signature: &[u8],
148    pubkey: &[u8],
149    preimage: &[u8],
150    htlc_script: &[u8],
151) -> Vec<Vec<u8>> {
152    vec![
153        signature.to_vec(),
154        pubkey.to_vec(),
155        preimage.to_vec(),
156        vec![0x01], // OP_TRUE (select IF branch)
157        htlc_script.to_vec(),
158    ]
159}
160
161/// Build the witness for refunding an HTLC (timelock path).
162///
163/// Witness: `<signature> <pubkey> OP_FALSE`
164pub fn htlc_refund_witness(signature: &[u8], pubkey: &[u8], htlc_script: &[u8]) -> Vec<Vec<u8>> {
165    vec![
166        signature.to_vec(),
167        pubkey.to_vec(),
168        vec![], // OP_FALSE (select ELSE branch)
169        htlc_script.to_vec(),
170    ]
171}
172
173// ═══════════════════════════════════════════════════════════════════
174// Coin Selection
175// ═══════════════════════════════════════════════════════════════════
176
177/// A UTXO available for spending.
178#[derive(Clone, Debug)]
179pub struct Utxo {
180    /// Transaction ID containing this UTXO.
181    pub txid: [u8; 32],
182    /// Output index within the transaction.
183    pub vout: u32,
184    /// Value in satoshis.
185    pub value: u64,
186    /// Estimated virtual size of the input when spent (in vbytes).
187    pub input_vsize: usize,
188}
189
190/// Result of coin selection.
191#[derive(Clone, Debug)]
192pub struct CoinSelectionResult {
193    /// Selected UTXOs.
194    pub selected: Vec<Utxo>,
195    /// Total value of selected UTXOs.
196    pub total_value: u64,
197    /// Estimated fee in satoshis.
198    pub estimated_fee: u64,
199    /// Change amount (0 if no change).
200    pub change: u64,
201}
202
203/// Select coins using Single Random Draw (SRD) algorithm.
204///
205/// Randomly selects UTXOs until the target is met. Simple and effective
206/// for most use cases.
207///
208/// # Arguments
209/// - `utxos` — Available UTXOs
210/// - `target` — Target amount in satoshis (excluding fees)
211/// - `fee_rate` — Fee rate in sat/vbyte
212/// - `base_tx_vsize` — Base transaction virtual size (overhead without inputs)
213/// - `change_output_vsize` — Virtual size of a change output
214pub fn select_coins_srd(
215    utxos: &[Utxo],
216    target: u64,
217    fee_rate: u64,
218    base_tx_vsize: usize,
219    change_output_vsize: usize,
220) -> Result<CoinSelectionResult, SignerError> {
221    if utxos.is_empty() {
222        return Err(SignerError::ParseError("no UTXOs available".into()));
223    }
224
225    // Sort by value descending (largest first for SRD)
226    let mut sorted: Vec<&Utxo> = utxos.iter().collect();
227    sorted.sort_by(|a, b| b.value.cmp(&a.value));
228
229    let mut selected = Vec::new();
230    let mut total_value = 0u64;
231    let mut total_input_vsize = 0usize;
232
233    for utxo in &sorted {
234        selected.push((*utxo).clone());
235        total_value += utxo.value;
236        total_input_vsize += utxo.input_vsize;
237
238        let tx_vsize = base_tx_vsize + total_input_vsize + change_output_vsize;
239        let fee = fee_rate * tx_vsize as u64;
240
241        if total_value >= target + fee {
242            let change = total_value - target - fee;
243            return Ok(CoinSelectionResult {
244                selected,
245                total_value,
246                estimated_fee: fee,
247                change,
248            });
249        }
250    }
251
252    Err(SignerError::ParseError(
253        "insufficient funds: not enough UTXOs to cover target + fees".into(),
254    ))
255}
256
257/// Select coins using Branch and Bound (BnB) algorithm.
258///
259/// Tries to find an exact match (no change) to reduce on-chain footprint.
260/// Falls back to SRD if no exact match is found within the search limit.
261///
262/// # Arguments
263/// - `utxos` — Available UTXOs
264/// - `target` — Target amount in satoshis
265/// - `fee_rate` — Fee rate in sat/vbyte
266/// - `base_tx_vsize` — Base transaction virtual size
267/// - `change_output_vsize` — Virtual size of a change output
268/// - `cost_of_change` — Minimum change worth creating (dust threshold)
269pub fn select_coins_bnb(
270    utxos: &[Utxo],
271    target: u64,
272    fee_rate: u64,
273    base_tx_vsize: usize,
274    change_output_vsize: usize,
275    cost_of_change: u64,
276) -> Result<CoinSelectionResult, SignerError> {
277    if utxos.is_empty() {
278        return Err(SignerError::ParseError("no UTXOs available".into()));
279    }
280
281    // Sort by effective value (value - cost to spend) descending
282    let mut sorted: Vec<&Utxo> = utxos.iter().collect();
283    sorted.sort_by(|a, b| {
284        let ev_a = a.value.saturating_sub(fee_rate * a.input_vsize as u64);
285        let ev_b = b.value.saturating_sub(fee_rate * b.input_vsize as u64);
286        ev_b.cmp(&ev_a)
287    });
288
289    let max_iterations = 100_000;
290    let mut best: Option<Vec<usize>> = None;
291    let mut best_waste = i64::MAX;
292
293    // DFS search for exact match
294    let mut stack: Vec<(usize, Vec<usize>, u64, usize)> = vec![(0, vec![], 0, 0)];
295    let mut iterations = 0;
296
297    while let Some((idx, selected_indices, total, total_vsize)) = stack.pop() {
298        iterations += 1;
299        if iterations > max_iterations {
300            break;
301        }
302
303        let fee = fee_rate * (base_tx_vsize + total_vsize) as u64;
304        let needed = target + fee;
305
306        if total >= needed && total <= needed + cost_of_change {
307            // Found an acceptable match (within dust threshold = no change needed)
308            let waste = (total as i64) - (needed as i64);
309            if waste < best_waste {
310                best_waste = waste;
311                best = Some(selected_indices.clone());
312            }
313            continue;
314        }
315
316        if idx >= sorted.len() || total > needed + cost_of_change {
317            continue;
318        }
319
320        // Branch: include current UTXO
321        let mut with = selected_indices.clone();
322        with.push(idx);
323        stack.push((
324            idx + 1,
325            with,
326            total + sorted[idx].value,
327            total_vsize + sorted[idx].input_vsize,
328        ));
329
330        // Branch: exclude current UTXO
331        stack.push((idx + 1, selected_indices, total, total_vsize));
332    }
333
334    if let Some(indices) = best {
335        let selected: Vec<Utxo> = indices.iter().map(|i| sorted[*i].clone()).collect();
336        let total_value: u64 = selected.iter().map(|u| u.value).sum();
337        let total_input_vsize: usize = selected.iter().map(|u| u.input_vsize).sum();
338        let fee = fee_rate * (base_tx_vsize + total_input_vsize) as u64;
339        let change = total_value.saturating_sub(target + fee);
340
341        Ok(CoinSelectionResult {
342            selected,
343            total_value,
344            estimated_fee: fee,
345            change,
346        })
347    } else {
348        // Fallback to SRD
349        select_coins_srd(utxos, target, fee_rate, base_tx_vsize, change_output_vsize)
350    }
351}
352
353// ─── Helpers ────────────────────────────────────────────────────────
354
355/// Push a number in Bitcoin's minimal script number encoding.
356fn push_script_number(script: &mut Vec<u8>, n: i64) {
357    if n == 0 {
358        script.push(0x00); // OP_0
359        return;
360    }
361    if (1..=16).contains(&n) {
362        script.push(0x50 + n as u8); // OP_1..OP_16
363        return;
364    }
365
366    // Encode as little-endian with sign bit
367    let negative = n < 0;
368    let mut abs_n = if negative { (-n) as u64 } else { n as u64 };
369    let mut bytes = Vec::new();
370
371    while abs_n > 0 {
372        bytes.push((abs_n & 0xFF) as u8);
373        abs_n >>= 8;
374    }
375
376    // Add sign bit if needed
377    if bytes.last().is_some_and(|b| b & 0x80 != 0) {
378        bytes.push(if negative { 0x80 } else { 0x00 });
379    } else if negative {
380        let last = bytes.len() - 1;
381        bytes[last] |= 0x80;
382    }
383
384    script.push(bytes.len() as u8); // push data length
385    script.extend_from_slice(&bytes);
386}
387
388// ═══════════════════════════════════════════════════════════════════
389// Tests
390// ═══════════════════════════════════════════════════════════════════
391
392#[cfg(test)]
393#[allow(clippy::unwrap_used, clippy::expect_used)]
394mod tests {
395    use super::*;
396
397    const PKH: [u8; 20] = [0xAB; 20];
398    const PKH2: [u8; 20] = [0xCD; 20];
399
400    // ─── Timelock Tests ─────────────────────────────────────────
401
402    #[test]
403    fn test_cltv_script_structure() {
404        let script = cltv_script(500_000, &PKH);
405        // Should contain OP_CHECKLOCKTIMEVERIFY and OP_CHECKSIG
406        assert!(script.contains(&0xB1)); // OP_CLTV
407        assert!(script.contains(&0xAC)); // OP_CHECKSIG
408        assert!(script.windows(20).any(|w| w == PKH));
409    }
410
411    #[test]
412    fn test_csv_script_structure() {
413        let script = csv_script(144, &PKH); // ~1 day in blocks
414        assert!(script.contains(&0xB2)); // OP_CSV
415        assert!(script.contains(&0xAC)); // OP_CHECKSIG
416    }
417
418    #[test]
419    fn test_is_block_height_locktime() {
420        assert!(is_block_height_locktime(500_000));
421        assert!(is_block_height_locktime(0));
422        assert!(!is_block_height_locktime(500_000_000));
423        assert!(!is_block_height_locktime(1_700_000_000));
424    }
425
426    // ─── HTLC Tests ─────────────────────────────────────────────
427
428    #[test]
429    fn test_htlc_script_structure() {
430        let preimage = b"secret preimage";
431        let hash = htlc_payment_hash(preimage);
432        let script = htlc_script(&hash, &PKH, &PKH2, 100_000);
433
434        assert!(script.contains(&0x63)); // OP_IF
435        assert!(script.contains(&0x67)); // OP_ELSE
436        assert!(script.contains(&0x68)); // OP_ENDIF
437        assert!(script.contains(&0xA8)); // OP_SHA256
438        assert!(script.contains(&0xB1)); // OP_CLTV
439                                         // Should contain both pubkey hashes
440        assert!(script.windows(20).any(|w| w == PKH));
441        assert!(script.windows(20).any(|w| w == PKH2));
442    }
443
444    #[test]
445    fn test_htlc_payment_hash_deterministic() {
446        let preimage = b"test preimage";
447        let h1 = htlc_payment_hash(preimage);
448        let h2 = htlc_payment_hash(preimage);
449        assert_eq!(h1, h2);
450        assert_ne!(h1, [0u8; 32]);
451    }
452
453    #[test]
454    fn test_htlc_payment_hash_known_vector() {
455        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
456        let hash = htlc_payment_hash(b"");
457        assert_eq!(
458            hex::encode(hash),
459            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
460        );
461    }
462
463    #[test]
464    fn test_htlc_claim_witness() {
465        let sig = vec![0x30, 0x44]; // dummy DER signature
466        let pk = vec![0x02; 33]; // dummy compressed key
467        let preimage = b"secret";
468        let script = vec![0x63; 10]; // dummy script
469        let witness = htlc_claim_witness(&sig, &pk, preimage, &script);
470        assert_eq!(witness.len(), 5);
471        assert_eq!(witness[3], vec![0x01]); // OP_TRUE for claim
472    }
473
474    #[test]
475    fn test_htlc_refund_witness() {
476        let sig = vec![0x30, 0x44];
477        let pk = vec![0x02; 33];
478        let script = vec![0x63; 10];
479        let witness = htlc_refund_witness(&sig, &pk, &script);
480        assert_eq!(witness.len(), 4);
481        assert!(witness[2].is_empty()); // OP_FALSE for refund
482    }
483
484    // ─── Coin Selection Tests ───────────────────────────────────
485
486    fn make_utxos() -> Vec<Utxo> {
487        vec![
488            Utxo {
489                txid: [1; 32],
490                vout: 0,
491                value: 100_000,
492                input_vsize: 68,
493            },
494            Utxo {
495                txid: [2; 32],
496                vout: 0,
497                value: 50_000,
498                input_vsize: 68,
499            },
500            Utxo {
501                txid: [3; 32],
502                vout: 0,
503                value: 200_000,
504                input_vsize: 68,
505            },
506            Utxo {
507                txid: [4; 32],
508                vout: 0,
509                value: 30_000,
510                input_vsize: 68,
511            },
512        ]
513    }
514
515    #[test]
516    fn test_srd_selects_enough() {
517        let utxos = make_utxos();
518        let result = select_coins_srd(&utxos, 150_000, 10, 10, 34).unwrap();
519        assert!(result.total_value >= 150_000 + result.estimated_fee);
520    }
521
522    #[test]
523    fn test_srd_insufficient_funds() {
524        let utxos = make_utxos();
525        let result = select_coins_srd(&utxos, 1_000_000, 10, 10, 34);
526        assert!(result.is_err());
527    }
528
529    #[test]
530    fn test_srd_empty_utxos() {
531        let result = select_coins_srd(&[], 100, 10, 10, 34);
532        assert!(result.is_err());
533    }
534
535    #[test]
536    fn test_bnb_finds_exact_match() {
537        let utxos = vec![
538            Utxo {
539                txid: [1; 32],
540                vout: 0,
541                value: 100_000,
542                input_vsize: 68,
543            },
544            Utxo {
545                txid: [2; 32],
546                vout: 0,
547                value: 50_000,
548                input_vsize: 68,
549            },
550            Utxo {
551                txid: [3; 32],
552                vout: 0,
553                value: 25_000,
554                input_vsize: 68,
555            },
556        ];
557        // Target + fee should be achievable with exact match
558        let result = select_coins_bnb(&utxos, 49_000, 1, 10, 34, 500).unwrap();
559        assert!(result.total_value >= 49_000);
560    }
561
562    #[test]
563    fn test_bnb_falls_back_to_srd() {
564        let utxos = make_utxos();
565        // This should find a solution (either exact or SRD fallback)
566        let result = select_coins_bnb(&utxos, 150_000, 10, 10, 34, 546).unwrap();
567        assert!(result.total_value >= 150_000 + result.estimated_fee);
568    }
569
570    // ─── Script Number Encoding ─────────────────────────────────
571
572    #[test]
573    fn test_push_script_number_small() {
574        let mut s = Vec::new();
575        push_script_number(&mut s, 0);
576        assert_eq!(s, vec![0x00]); // OP_0
577
578        let mut s = Vec::new();
579        push_script_number(&mut s, 1);
580        assert_eq!(s, vec![0x51]); // OP_1
581
582        let mut s = Vec::new();
583        push_script_number(&mut s, 16);
584        assert_eq!(s, vec![0x60]); // OP_16
585    }
586
587    #[test]
588    fn test_push_script_number_large() {
589        let mut s = Vec::new();
590        push_script_number(&mut s, 500_000);
591        // 500_000 = 0x07A120 in LE: [0x20, 0xA1, 0x07]
592        assert_eq!(s[0], 3); // length
593        assert_eq!(s[1..], [0x20, 0xA1, 0x07]);
594    }
595}