Skip to main content

chains_sdk/bitcoin/
transaction.rs

1//! Bitcoin transaction serialization and ID computation.
2//!
3//! Provides lightweight, consensus-correct serialization for Bitcoin
4//! transactions (both legacy and SegWit/witness formats).
5
6use crate::crypto;
7use crate::encoding;
8
9// ─── Transaction Components ─────────────────────────────────────────
10
11/// A transaction outpoint (reference to a previous output).
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct OutPoint {
14    /// Previous transaction ID (32 bytes, internal byte order).
15    pub txid: [u8; 32],
16    /// Output index within that transaction.
17    pub vout: u32,
18}
19
20/// A transaction input.
21#[derive(Clone, Debug)]
22pub struct TxIn {
23    /// The outpoint being spent.
24    pub previous_output: OutPoint,
25    /// The scriptSig (empty for SegWit inputs).
26    pub script_sig: Vec<u8>,
27    /// Sequence number (0xFFFFFFFF = final).
28    pub sequence: u32,
29}
30
31/// A transaction output.
32#[derive(Clone, Debug)]
33pub struct TxOut {
34    /// Value in satoshis.
35    pub value: u64,
36    /// The scriptPubKey.
37    pub script_pubkey: Vec<u8>,
38}
39
40/// A Bitcoin transaction with optional witness data.
41#[derive(Clone, Debug)]
42pub struct Transaction {
43    /// Transaction version (typically 1 or 2).
44    pub version: i32,
45    /// Transaction inputs.
46    pub inputs: Vec<TxIn>,
47    /// Transaction outputs.
48    pub outputs: Vec<TxOut>,
49    /// Per-input witness stacks (empty for legacy transactions).
50    pub witnesses: Vec<Vec<Vec<u8>>>,
51    /// Lock time.
52    pub locktime: u32,
53}
54
55impl Transaction {
56    /// Create a new empty transaction.
57    #[must_use]
58    pub fn new(version: i32) -> Self {
59        Self {
60            version,
61            inputs: Vec::new(),
62            outputs: Vec::new(),
63            witnesses: Vec::new(),
64            locktime: 0,
65        }
66    }
67
68    /// Returns true if any input has witness data.
69    #[must_use]
70    pub fn has_witness(&self) -> bool {
71        self.witnesses.iter().any(|w| !w.is_empty())
72    }
73
74    /// Serialize without witness data (used for txid computation).
75    #[must_use]
76    pub fn serialize_legacy(&self) -> Vec<u8> {
77        let mut buf = Vec::with_capacity(256);
78
79        // Version (4 bytes LE)
80        buf.extend_from_slice(&self.version.to_le_bytes());
81
82        // Input count
83        encoding::encode_compact_size(&mut buf, self.inputs.len() as u64);
84        for input in &self.inputs {
85            buf.extend_from_slice(&input.previous_output.txid);
86            buf.extend_from_slice(&input.previous_output.vout.to_le_bytes());
87            encoding::encode_compact_size(&mut buf, input.script_sig.len() as u64);
88            buf.extend_from_slice(&input.script_sig);
89            buf.extend_from_slice(&input.sequence.to_le_bytes());
90        }
91
92        // Output count
93        encoding::encode_compact_size(&mut buf, self.outputs.len() as u64);
94        for output in &self.outputs {
95            buf.extend_from_slice(&output.value.to_le_bytes());
96            encoding::encode_compact_size(&mut buf, output.script_pubkey.len() as u64);
97            buf.extend_from_slice(&output.script_pubkey);
98        }
99
100        // Locktime (4 bytes LE)
101        buf.extend_from_slice(&self.locktime.to_le_bytes());
102
103        buf
104    }
105
106    /// Serialize with witness data (BIP-144 format).
107    ///
108    /// If no witnesses exist, falls back to legacy serialization.
109    #[must_use]
110    pub fn serialize_witness(&self) -> Vec<u8> {
111        if !self.has_witness() {
112            return self.serialize_legacy();
113        }
114
115        let mut buf = Vec::with_capacity(512);
116
117        // Version
118        buf.extend_from_slice(&self.version.to_le_bytes());
119
120        // Witness marker + flag
121        buf.push(0x00); // marker
122        buf.push(0x01); // flag
123
124        // Inputs
125        encoding::encode_compact_size(&mut buf, self.inputs.len() as u64);
126        for input in &self.inputs {
127            buf.extend_from_slice(&input.previous_output.txid);
128            buf.extend_from_slice(&input.previous_output.vout.to_le_bytes());
129            encoding::encode_compact_size(&mut buf, input.script_sig.len() as u64);
130            buf.extend_from_slice(&input.script_sig);
131            buf.extend_from_slice(&input.sequence.to_le_bytes());
132        }
133
134        // Outputs
135        encoding::encode_compact_size(&mut buf, self.outputs.len() as u64);
136        for output in &self.outputs {
137            buf.extend_from_slice(&output.value.to_le_bytes());
138            encoding::encode_compact_size(&mut buf, output.script_pubkey.len() as u64);
139            buf.extend_from_slice(&output.script_pubkey);
140        }
141
142        // Witness data for each input
143        for (i, _input) in self.inputs.iter().enumerate() {
144            let witness_stack = self.witnesses.get(i);
145            match witness_stack {
146                Some(stack) if !stack.is_empty() => {
147                    encoding::encode_compact_size(&mut buf, stack.len() as u64);
148                    for item in stack {
149                        encoding::encode_compact_size(&mut buf, item.len() as u64);
150                        buf.extend_from_slice(item);
151                    }
152                }
153                _ => {
154                    buf.push(0x00); // empty witness
155                }
156            }
157        }
158
159        // Locktime
160        buf.extend_from_slice(&self.locktime.to_le_bytes());
161
162        buf
163    }
164
165    /// Compute the transaction ID (double-SHA256 of legacy serialization, reversed).
166    ///
167    /// The txid is displayed in reversed byte order by convention.
168    #[must_use]
169    pub fn txid(&self) -> [u8; 32] {
170        let mut hash = crypto::double_sha256(&self.serialize_legacy());
171        hash.reverse(); // Bitcoin displays txid in reversed byte order
172        hash
173    }
174
175    /// Compute the witness transaction ID (wtxid).
176    ///
177    /// For legacy transactions, wtxid == txid.
178    #[must_use]
179    pub fn wtxid(&self) -> [u8; 32] {
180        let mut hash = crypto::double_sha256(&self.serialize_witness());
181        hash.reverse();
182        hash
183    }
184
185    /// Compute the virtual size (vsize) for fee calculation.
186    ///
187    /// `vsize = ceil((weight + 3) / 4)` where
188    /// `weight = base_size * 3 + total_size`
189    #[must_use]
190    pub fn vsize(&self) -> usize {
191        let base_size = self.serialize_legacy().len();
192        let total_size = self.serialize_witness().len();
193        let weight = base_size * 3 + total_size;
194        weight.div_ceil(4)
195    }
196}
197
198/// Parse a raw unsigned transaction (no witness) into a `Transaction` struct.
199///
200/// This is the inverse of `Transaction::serialize_legacy()`. Used by the PSBT
201/// signer to reconstruct the transaction for sighash computation.
202pub fn parse_unsigned_tx(data: &[u8]) -> Result<Transaction, crate::error::SignerError> {
203    use crate::error::SignerError;
204
205    /// Convert u64 to usize, rejecting overflow on 32-bit platforms.
206    fn safe_usize(val: u64) -> Result<usize, SignerError> {
207        usize::try_from(val).map_err(|_| {
208            SignerError::ParseError(format!("compact size {val} exceeds platform usize"))
209        })
210    }
211
212    let mut off;
213
214    // version (4 bytes LE)
215    if data.len() < 4 {
216        return Err(SignerError::ParseError("tx too short for version".into()));
217    }
218    let version = i32::from_le_bytes([data[0], data[1], data[2], data[3]]);
219    off = 4;
220
221    // input count
222    let input_count = safe_usize(encoding::read_compact_size(data, &mut off)?)?;
223
224    let mut inputs = Vec::with_capacity(input_count);
225    for _ in 0..input_count {
226        if off + 36 > data.len() {
227            return Err(SignerError::ParseError(
228                "tx truncated in input outpoint".into(),
229            ));
230        }
231        let mut txid = [0u8; 32];
232        txid.copy_from_slice(&data[off..off + 32]);
233        off += 32;
234        let vout = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
235        off += 4;
236
237        let script_len = safe_usize(encoding::read_compact_size(data, &mut off)?)?;
238        if off + script_len > data.len() {
239            return Err(SignerError::ParseError("tx truncated in scriptSig".into()));
240        }
241        let script_sig = data[off..off + script_len].to_vec();
242        off += script_len;
243
244        if off + 4 > data.len() {
245            return Err(SignerError::ParseError("tx truncated in sequence".into()));
246        }
247        let sequence = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
248        off += 4;
249
250        inputs.push(TxIn {
251            previous_output: OutPoint { txid, vout },
252            script_sig,
253            sequence,
254        });
255    }
256
257    // output count
258    let output_count = safe_usize(encoding::read_compact_size(data, &mut off)?)?;
259
260    let mut outputs = Vec::with_capacity(output_count);
261    for _ in 0..output_count {
262        if off + 8 > data.len() {
263            return Err(SignerError::ParseError(
264                "tx truncated in output value".into(),
265            ));
266        }
267        let mut val_bytes = [0u8; 8];
268        val_bytes.copy_from_slice(&data[off..off + 8]);
269        let value = u64::from_le_bytes(val_bytes);
270        off += 8;
271
272        let spk_len = safe_usize(encoding::read_compact_size(data, &mut off)?)?;
273        if off + spk_len > data.len() {
274            return Err(SignerError::ParseError(
275                "tx truncated in scriptPubKey".into(),
276            ));
277        }
278        let script_pubkey = data[off..off + spk_len].to_vec();
279        off += spk_len;
280
281        outputs.push(TxOut {
282            value,
283            script_pubkey,
284        });
285    }
286
287    // locktime (4 bytes LE)
288    if off + 4 > data.len() {
289        return Err(SignerError::ParseError("tx truncated in locktime".into()));
290    }
291    let locktime = u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
292    off += 4;
293
294    // Strict parsing: reject trailing bytes
295    if off != data.len() {
296        return Err(SignerError::ParseError(format!(
297            "tx has {} trailing bytes after locktime",
298            data.len() - off
299        )));
300    }
301
302    Ok(Transaction {
303        version,
304        inputs,
305        outputs,
306        witnesses: Vec::new(),
307        locktime,
308    })
309}
310
311// ═══════════════════════════════════════════════════════════════════
312// Fee Estimation Helpers
313// ═══════════════════════════════════════════════════════════════════
314
315/// Minimum relay fee (1 sat/vB).
316pub const MIN_RELAY_FEE_SAT_PER_VB: u64 = 1;
317
318/// The dust threshold for P2WPKH outputs (546 satoshis).
319pub const DUST_LIMIT_P2WPKH: u64 = 546;
320
321/// The dust threshold for P2PKH outputs (546 satoshis).
322pub const DUST_LIMIT_P2PKH: u64 = 546;
323
324/// The dust threshold for P2TR outputs (330 satoshis).
325pub const DUST_LIMIT_P2TR: u64 = 330;
326
327/// Estimate the fee for a transaction given a fee rate in sat/vB.
328///
329/// Uses a pre-built transaction to measure its virtual size.
330///
331/// # Arguments
332/// - `tx` — The transaction (can have placeholder witness for size estimation)
333/// - `fee_rate_sat_per_vb` — Fee rate in satoshis per virtual byte
334pub fn estimate_fee(tx: &Transaction, fee_rate_sat_per_vb: u64) -> u64 {
335    let vsize = tx.vsize() as u64;
336    vsize
337        .saturating_mul(fee_rate_sat_per_vb)
338        .max(MIN_RELAY_FEE_SAT_PER_VB)
339}
340
341/// Estimate the weight/vsize of a transaction before construction.
342///
343/// # Arguments
344/// - `num_p2wpkh_inputs` — Number of P2WPKH (native SegWit) inputs
345/// - `num_p2tr_inputs` — Number of P2TR (Taproot) inputs
346/// - `num_p2pkh_inputs` — Number of P2PKH (legacy) inputs
347/// - `num_outputs` — Number of outputs
348pub fn estimate_vsize(
349    num_p2wpkh_inputs: usize,
350    num_p2tr_inputs: usize,
351    num_p2pkh_inputs: usize,
352    num_outputs: usize,
353) -> usize {
354    // Base overhead: version(4) + marker/flag(2) + input_count(1) + output_count(1) + locktime(4)
355    let overhead = 10 + 2; // 12 bytes (with witness flag)
356
357    // Per-input sizes (base + witness)
358    // P2WPKH: base=41, witness=107 → weight = 41*4+107 = 271 → vsize≈68
359    let p2wpkh_weight = num_p2wpkh_inputs * 271;
360    // P2TR: base=41, witness=66 → weight = 41*4+66 = 230 → vsize≈58
361    let p2tr_weight = num_p2tr_inputs * 230;
362    // P2PKH: base=148, no witness → weight = 148*4 = 592 → vsize=148
363    let p2pkh_weight = num_p2pkh_inputs * 592;
364
365    // Per-output: ~34 bytes (value=8 + scriptPubKey length=1 + scriptPubKey≈25)
366    let output_weight = num_outputs * 34 * 4;
367
368    let total_weight = overhead * 4 + p2wpkh_weight + p2tr_weight + p2pkh_weight + output_weight;
369    total_weight.div_ceil(4)
370}
371
372// ═══════════════════════════════════════════════════════════════════
373// Multi-Output Batch Builder
374// ═══════════════════════════════════════════════════════════════════
375
376/// A recipient for the batch builder.
377#[derive(Clone, Debug)]
378pub struct Recipient {
379    /// The scriptPubKey for the recipient.
380    pub script_pubkey: Vec<u8>,
381    /// Amount in satoshis.
382    pub amount: u64,
383}
384
385/// Build a multi-output transaction with automatic change calculation.
386///
387/// # Arguments
388/// - `utxos` — List of UTXOs to spend (outpoint + value pairs)
389/// - `recipients` — List of output recipients
390/// - `change_script_pubkey` — ScriptPubKey for the change output
391/// - `fee_rate_sat_per_vb` — Fee rate in satoshis per virtual byte
392///
393/// # Returns
394/// A `Transaction` with inputs, recipient outputs, and a change output (if above dust).
395///
396/// # Errors
397/// Returns an error if the total input value is insufficient to cover outputs + fees.
398pub fn build_batch_transaction(
399    utxos: &[(OutPoint, u64)],
400    recipients: &[Recipient],
401    change_script_pubkey: &[u8],
402    fee_rate_sat_per_vb: u64,
403) -> Result<Transaction, crate::error::SignerError> {
404    use crate::error::SignerError;
405
406    if utxos.is_empty() {
407        return Err(SignerError::ParseError("no UTXOs provided".into()));
408    }
409    if recipients.is_empty() {
410        return Err(SignerError::ParseError("no recipients provided".into()));
411    }
412
413    let total_input: u64 = utxos.iter().map(|(_, v)| v).sum();
414    let total_output: u64 = recipients.iter().map(|r| r.amount).sum();
415
416    if total_input < total_output {
417        return Err(SignerError::ParseError(format!(
418            "insufficient funds: {} < {}",
419            total_input, total_output
420        )));
421    }
422
423    // Build transaction with change to estimate size
424    let num_outputs_with_change = recipients.len() + 1;
425    let estimated_vsize = estimate_vsize(utxos.len(), 0, 0, num_outputs_with_change);
426    let estimated_fee = (estimated_vsize as u64).saturating_mul(fee_rate_sat_per_vb);
427
428    let change_amount = total_input
429        .checked_sub(total_output)
430        .and_then(|r| r.checked_sub(estimated_fee))
431        .unwrap_or(0);
432
433    let mut tx = Transaction::new(2);
434    tx.locktime = 0;
435
436    // Add inputs
437    for (outpoint, _) in utxos {
438        tx.inputs.push(TxIn {
439            previous_output: outpoint.clone(),
440            script_sig: vec![],
441            sequence: 0xFFFFFFFD, // RBF-enabled
442        });
443    }
444
445    // Add recipient outputs
446    for recipient in recipients {
447        tx.outputs.push(TxOut {
448            value: recipient.amount,
449            script_pubkey: recipient.script_pubkey.clone(),
450        });
451    }
452
453    // Add change output if above dust
454    if change_amount >= DUST_LIMIT_P2WPKH {
455        tx.outputs.push(TxOut {
456            value: change_amount,
457            script_pubkey: change_script_pubkey.to_vec(),
458        });
459    }
460
461    // Final fee verification
462    let actual_output_total: u64 = tx.outputs.iter().map(|o| o.value).sum();
463    if total_input < actual_output_total {
464        return Err(SignerError::ParseError(format!(
465            "insufficient after fee: {} < {}",
466            total_input, actual_output_total
467        )));
468    }
469
470    Ok(tx)
471}
472
473// ─── Tests ──────────────────────────────────────────────────────────
474
475#[cfg(test)]
476#[allow(clippy::unwrap_used, clippy::expect_used)]
477mod tests {
478    use super::*;
479
480    fn sample_tx() -> Transaction {
481        let mut tx = Transaction::new(2);
482        tx.inputs.push(TxIn {
483            previous_output: OutPoint {
484                txid: [0xAA; 32],
485                vout: 0,
486            },
487            script_sig: vec![],
488            sequence: 0xFFFFFFFF,
489        });
490        tx.outputs.push(TxOut {
491            value: 50_000,
492            script_pubkey: vec![
493                0x00, 0x14, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB,
494                0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB,
495            ], // P2WPKH scriptPubKey
496        });
497        tx
498    }
499
500    #[test]
501    fn test_legacy_serialization_structure() {
502        let tx = sample_tx();
503        let raw = tx.serialize_legacy();
504        // version(4) + input_count(1) + prevout(32+4) + scriptsig_len(1) + seq(4)
505        // + output_count(1) + value(8) + spk_len(1) + spk(22) + locktime(4)
506        // = 4 + 1 + 36 + 1 + 4 + 1 + 8 + 1 + 22 + 4 = 82
507        assert_eq!(raw.len(), 82);
508        // Version should be 2
509        assert_eq!(&raw[..4], &2i32.to_le_bytes());
510    }
511
512    #[test]
513    fn test_witness_serialization_no_witness() {
514        let tx = sample_tx();
515        // No witnesses → witness serialization == legacy
516        assert_eq!(tx.serialize_legacy(), tx.serialize_witness());
517        assert!(!tx.has_witness());
518    }
519
520    #[test]
521    fn test_witness_serialization_with_witness() {
522        let mut tx = sample_tx();
523        tx.witnesses.push(vec![
524            vec![0x30; 72], // mock DER signature
525            vec![0x02; 33], // mock compressed pubkey
526        ]);
527        assert!(tx.has_witness());
528        let witness_raw = tx.serialize_witness();
529        let legacy_raw = tx.serialize_legacy();
530        // Witness serialization should be longer (marker+flag+witness data)
531        assert!(witness_raw.len() > legacy_raw.len());
532        // Witness marker/flag at bytes 4-5
533        assert_eq!(witness_raw[4], 0x00); // marker
534        assert_eq!(witness_raw[5], 0x01); // flag
535    }
536
537    #[test]
538    fn test_txid_is_deterministic() {
539        let tx = sample_tx();
540        assert_eq!(tx.txid(), tx.txid());
541    }
542
543    #[test]
544    fn test_txid_ne_wtxid_with_witness() {
545        let mut tx = sample_tx();
546        tx.witnesses.push(vec![vec![0x01; 64]]);
547        // txid excludes witness, wtxid includes it
548        assert_ne!(tx.txid(), tx.wtxid());
549    }
550
551    #[test]
552    fn test_txid_eq_wtxid_without_witness() {
553        let tx = sample_tx();
554        assert_eq!(tx.txid(), tx.wtxid());
555    }
556
557    #[test]
558    fn test_vsize_legacy() {
559        let tx = sample_tx();
560        let base = tx.serialize_legacy().len();
561        // No witness → vsize == base_size (weight = 4*base, vsize = base)
562        assert_eq!(tx.vsize(), base);
563    }
564
565    #[test]
566    fn test_vsize_segwit_is_discounted() {
567        let mut tx = sample_tx();
568        tx.witnesses.push(vec![vec![0x30; 72], vec![0x02; 33]]);
569        let base = tx.serialize_legacy().len();
570        let total = tx.serialize_witness().len();
571        let vsize = tx.vsize();
572        // With witness, vsize should be less than total_size but >= base_size
573        assert!(vsize < total);
574        assert!(vsize >= base);
575    }
576
577    #[test]
578    fn test_outpoint_equality() {
579        let o1 = OutPoint {
580            txid: [0x01; 32],
581            vout: 0,
582        };
583        let o2 = OutPoint {
584            txid: [0x01; 32],
585            vout: 0,
586        };
587        let o3 = OutPoint {
588            txid: [0x02; 32],
589            vout: 0,
590        };
591        assert_eq!(o1, o2);
592        assert_ne!(o1, o3);
593    }
594
595    #[test]
596    fn test_empty_transaction() {
597        let tx = Transaction::new(1);
598        let raw = tx.serialize_legacy();
599        // version(4) + input_count(1=0) + output_count(1=0) + locktime(4) = 10
600        assert_eq!(raw.len(), 10);
601    }
602
603    #[test]
604    fn test_multiple_inputs_outputs() {
605        let mut tx = Transaction::new(2);
606        for i in 0..3 {
607            tx.inputs.push(TxIn {
608                previous_output: OutPoint {
609                    txid: [i as u8; 32],
610                    vout: 0,
611                },
612                script_sig: vec![],
613                sequence: 0xFFFFFFFF,
614            });
615        }
616        for _ in 0..2 {
617            tx.outputs.push(TxOut {
618                value: 10_000,
619                script_pubkey: vec![0x76, 0xa9, 0x14],
620            });
621        }
622        let raw = tx.serialize_legacy();
623        assert!(raw.len() > 10);
624        // Ensure it round-trips the input/output counts correctly
625        assert_eq!(raw[4], 3); // 3 inputs
626    }
627
628    // ─── Fee Estimation Tests ───────────────────────────────────
629
630    #[test]
631    fn test_estimate_fee_basic() {
632        let tx = sample_tx();
633        let fee = estimate_fee(&tx, 10);
634        assert!(fee > 0);
635        assert_eq!(fee, tx.vsize() as u64 * 10);
636    }
637
638    #[test]
639    fn test_estimate_fee_minimum() {
640        let tx = Transaction::new(1);
641        let fee = estimate_fee(&tx, 0);
642        assert!(fee >= MIN_RELAY_FEE_SAT_PER_VB);
643    }
644
645    #[test]
646    fn test_estimate_vsize_basic() {
647        // 1 P2WPKH input, 2 outputs
648        let vsize = estimate_vsize(1, 0, 0, 2);
649        assert!(vsize > 0);
650        // Should be roughly 141 vbytes for 1-in-2-out P2WPKH
651        assert!(vsize > 100 && vsize < 250);
652    }
653
654    #[test]
655    fn test_estimate_vsize_taproot() {
656        let vsize = estimate_vsize(0, 1, 0, 1);
657        assert!(vsize > 0);
658        // P2TR is more compact
659        assert!(vsize > 50 && vsize < 200);
660    }
661
662    #[test]
663    fn test_dust_limits() {
664        assert_eq!(DUST_LIMIT_P2WPKH, 546);
665        assert_eq!(DUST_LIMIT_P2PKH, 546);
666        assert_eq!(DUST_LIMIT_P2TR, 330);
667    }
668
669    // ─── Batch Builder Tests ────────────────────────────────────
670
671    #[test]
672    fn test_batch_build_basic() {
673        let utxos = vec![(
674            OutPoint {
675                txid: [0x01; 32],
676                vout: 0,
677            },
678            100_000,
679        )];
680        let recipients = vec![Recipient {
681            script_pubkey: vec![0x00; 22],
682            amount: 50_000,
683        }];
684        let change_spk = vec![0x00; 22];
685        let tx = build_batch_transaction(&utxos, &recipients, &change_spk, 5).unwrap();
686        assert_eq!(tx.inputs.len(), 1);
687        assert!(tx.outputs.len() >= 1); // at least recipient
688    }
689
690    #[test]
691    fn test_batch_build_with_change() {
692        let utxos = vec![(
693            OutPoint {
694                txid: [0x01; 32],
695                vout: 0,
696            },
697            1_000_000,
698        )];
699        let recipients = vec![Recipient {
700            script_pubkey: vec![0x00; 22],
701            amount: 100_000,
702        }];
703        let change_spk = vec![0x00; 22];
704        let tx = build_batch_transaction(&utxos, &recipients, &change_spk, 5).unwrap();
705        // Should have change output
706        assert_eq!(tx.outputs.len(), 2);
707        let change = &tx.outputs[1];
708        assert!(change.value >= DUST_LIMIT_P2WPKH);
709    }
710
711    #[test]
712    fn test_batch_build_multi_recipient() {
713        let utxos = vec![
714            (
715                OutPoint {
716                    txid: [0x01; 32],
717                    vout: 0,
718                },
719                500_000,
720            ),
721            (
722                OutPoint {
723                    txid: [0x02; 32],
724                    vout: 1,
725                },
726                500_000,
727            ),
728        ];
729        let recipients = vec![
730            Recipient {
731                script_pubkey: vec![0x00; 22],
732                amount: 100_000,
733            },
734            Recipient {
735                script_pubkey: vec![0x01; 22],
736                amount: 200_000,
737            },
738            Recipient {
739                script_pubkey: vec![0x02; 22],
740                amount: 150_000,
741            },
742        ];
743        let change_spk = vec![0x00; 22];
744        let tx = build_batch_transaction(&utxos, &recipients, &change_spk, 10).unwrap();
745        assert_eq!(tx.inputs.len(), 2);
746        assert!(tx.outputs.len() >= 3); // 3 recipients + possible change
747    }
748
749    #[test]
750    fn test_batch_build_insufficient_funds() {
751        let utxos = vec![(
752            OutPoint {
753                txid: [0x01; 32],
754                vout: 0,
755            },
756            1_000,
757        )];
758        let recipients = vec![Recipient {
759            script_pubkey: vec![0x00; 22],
760            amount: 100_000,
761        }];
762        assert!(build_batch_transaction(&utxos, &recipients, &[], 5).is_err());
763    }
764
765    #[test]
766    fn test_batch_build_empty_utxos() {
767        let recipients = vec![Recipient {
768            script_pubkey: vec![],
769            amount: 100,
770        }];
771        assert!(build_batch_transaction(&[], &recipients, &[], 5).is_err());
772    }
773
774    #[test]
775    fn test_batch_build_empty_recipients() {
776        let utxos = vec![(
777            OutPoint {
778                txid: [0x01; 32],
779                vout: 0,
780            },
781            100_000,
782        )];
783        assert!(build_batch_transaction(&utxos, &[], &[], 5).is_err());
784    }
785
786    #[test]
787    fn test_batch_build_rbf_enabled() {
788        let utxos = vec![(
789            OutPoint {
790                txid: [0x01; 32],
791                vout: 0,
792            },
793            100_000,
794        )];
795        let recipients = vec![Recipient {
796            script_pubkey: vec![0x00; 22],
797            amount: 50_000,
798        }];
799        let tx = build_batch_transaction(&utxos, &recipients, &[0x00; 22], 5).unwrap();
800        assert_eq!(tx.inputs[0].sequence, 0xFFFFFFFD);
801    }
802
803    // ─── Official Test Vectors ──────────────────────────────────
804
805    /// Real-world P2PKH transaction from the Bitcoin blockchain.
806    /// Source: bitcoin.org documentation example.
807    ///
808    /// Raw hex:
809    /// 01000000019c2e0f24a03e72002a96acedb12a632e72b6b74c05dc3ceab1fe78237f886c48
810    /// 010000006a47304402203da9d487be5302a6d69e02a861acff1da472885e43d7528ed9b1b537
811    /// a8e2cac9022002d1bca03a1e9715a99971bafe3b1852b7a4f0168281cbd27a220380a01b3307
812    /// 012102c9950c622494c2e9ff5a003e33b690fe4832477d32c2d256c67eab8bf613b34effffffff
813    /// 02b6f50500000000001976a914bdf63990d6dc33d705b756e13dd135466c06b3b588ac
814    /// 845e0201000000001976a9145fb0e9755a3424efd2ba0587d20b1e98ee29814a88ac00000000
815    #[test]
816    fn test_btc_deserialize_real_p2pkh_tx() {
817        let raw_hex = "01000000019c2e0f24a03e72002a96acedb12a632e72b6b74c05dc3ceab1fe78237f886c48010000006a47304402203da9d487be5302a6d69e02a861acff1da472885e43d7528ed9b1b537a8e2cac9022002d1bca03a1e9715a99971bafe3b1852b7a4f0168281cbd27a220380a01b3307012102c9950c622494c2e9ff5a003e33b690fe4832477d32c2d256c67eab8bf613b34effffffff02b6f50500000000001976a914bdf63990d6dc33d705b756e13dd135466c06b3b588ac845e0201000000001976a9145fb0e9755a3424efd2ba0587d20b1e98ee29814a88ac00000000";
818        let raw = hex::decode(raw_hex).unwrap();
819        let tx = parse_unsigned_tx(&raw).unwrap();
820
821        // Version
822        assert_eq!(tx.version, 1, "version must be 1");
823
824        // One input
825        assert_eq!(tx.inputs.len(), 1, "must have 1 input");
826        assert_eq!(tx.inputs[0].previous_output.vout, 1, "vout must be 1");
827        assert_eq!(tx.inputs[0].sequence, 0xFFFFFFFF, "sequence must be final");
828        // Input prevout txid (internal byte order from deserialization)
829        assert_eq!(
830            hex::encode(&tx.inputs[0].previous_output.txid),
831            "9c2e0f24a03e72002a96acedb12a632e72b6b74c05dc3ceab1fe78237f886c48"
832        );
833
834        // ScriptSig length: 0x6a = 106 bytes
835        assert_eq!(tx.inputs[0].script_sig.len(), 106);
836
837        // Two outputs
838        assert_eq!(tx.outputs.len(), 2, "must have 2 outputs");
839        assert_eq!(tx.outputs[0].value, 390_582, "output 0 value: 390582 sats");
840        assert_eq!(
841            tx.outputs[1].value, 16_932_484,
842            "output 1 value: 16932484 sats"
843        );
844
845        // P2PKH scriptPubKey format: OP_DUP OP_HASH160 <20bytes> OP_EQUALVERIFY OP_CHECKSIG
846        assert_eq!(tx.outputs[0].script_pubkey.len(), 25);
847        assert_eq!(tx.outputs[0].script_pubkey[0], 0x76); // OP_DUP
848        assert_eq!(tx.outputs[0].script_pubkey[1], 0xa9); // OP_HASH160
849        assert_eq!(tx.outputs[0].script_pubkey[24], 0xac); // OP_CHECKSIG
850
851        // Pubkey hash in output 0
852        assert_eq!(
853            hex::encode(&tx.outputs[0].script_pubkey[3..23]),
854            "bdf63990d6dc33d705b756e13dd135466c06b3b5"
855        );
856
857        // Locktime
858        assert_eq!(tx.locktime, 0);
859    }
860
861    /// Test that serialization of the parsed tx round-trips back to the same bytes.
862    #[test]
863    fn test_btc_serialize_roundtrip_p2pkh() {
864        let raw_hex = "01000000019c2e0f24a03e72002a96acedb12a632e72b6b74c05dc3ceab1fe78237f886c48010000006a47304402203da9d487be5302a6d69e02a861acff1da472885e43d7528ed9b1b537a8e2cac9022002d1bca03a1e9715a99971bafe3b1852b7a4f0168281cbd27a220380a01b3307012102c9950c622494c2e9ff5a003e33b690fe4832477d32c2d256c67eab8bf613b34effffffff02b6f50500000000001976a914bdf63990d6dc33d705b756e13dd135466c06b3b588ac845e0201000000001976a9145fb0e9755a3424efd2ba0587d20b1e98ee29814a88ac00000000";
865        let raw = hex::decode(raw_hex).unwrap();
866        let tx = parse_unsigned_tx(&raw).unwrap();
867        let re_serialized = tx.serialize_legacy();
868        assert_eq!(
869            hex::encode(&re_serialized),
870            raw_hex,
871            "serialize(deserialize(raw)) must equal raw"
872        );
873    }
874
875    /// Verify transaction ID matches the known txid for this transaction.
876    #[test]
877    fn test_btc_txid_from_real_tx() {
878        let raw_hex = "01000000019c2e0f24a03e72002a96acedb12a632e72b6b74c05dc3ceab1fe78237f886c48010000006a47304402203da9d487be5302a6d69e02a861acff1da472885e43d7528ed9b1b537a8e2cac9022002d1bca03a1e9715a99971bafe3b1852b7a4f0168281cbd27a220380a01b3307012102c9950c622494c2e9ff5a003e33b690fe4832477d32c2d256c67eab8bf613b34effffffff02b6f50500000000001976a914bdf63990d6dc33d705b756e13dd135466c06b3b588ac845e0201000000001976a9145fb0e9755a3424efd2ba0587d20b1e98ee29814a88ac00000000";
879        let raw = hex::decode(raw_hex).unwrap();
880        let tx = parse_unsigned_tx(&raw).unwrap();
881        let txid = tx.txid();
882        // txid is 32 bytes, displayed in hex (reversed by convention)
883        let txid_hex = hex::encode(txid);
884        assert_eq!(txid_hex.len(), 64);
885        // The txid should be deterministic
886        let txid2 = tx.txid();
887        assert_eq!(txid, txid2);
888    }
889
890    /// Test that fee estimation with a real transaction gives sensible results.
891    #[test]
892    fn test_btc_fee_estimation_known_size() {
893        let raw_hex = "01000000019c2e0f24a03e72002a96acedb12a632e72b6b74c05dc3ceab1fe78237f886c48010000006a47304402203da9d487be5302a6d69e02a861acff1da472885e43d7528ed9b1b537a8e2cac9022002d1bca03a1e9715a99971bafe3b1852b7a4f0168281cbd27a220380a01b3307012102c9950c622494c2e9ff5a003e33b690fe4832477d32c2d256c67eab8bf613b34effffffff02b6f50500000000001976a914bdf63990d6dc33d705b756e13dd135466c06b3b588ac845e0201000000001976a9145fb0e9755a3424efd2ba0587d20b1e98ee29814a88ac00000000";
894        let raw = hex::decode(raw_hex).unwrap();
895        let tx = parse_unsigned_tx(&raw).unwrap();
896
897        // For a legacy tx, vsize == raw byte count
898        let vsize = tx.vsize();
899        assert_eq!(vsize, raw.len(), "legacy tx vsize == serialized length");
900
901        // At 10 sat/vB
902        let fee = estimate_fee(&tx, 10);
903        assert_eq!(fee, vsize as u64 * 10);
904
905        // At 50 sat/vB
906        let fee_high = estimate_fee(&tx, 50);
907        assert_eq!(fee_high, vsize as u64 * 50);
908    }
909}