Skip to main content

csv_adapter_bitcoin/
tx_builder.rs

1//! Commitment transaction builder
2//!
3//! Builds transactions for CSV commitment with:
4//! - UTXO coin selection and Tapret/Opret commitment construction
5//! - Real Taproot tree building with proper nonce positioning
6//! - Fee estimation and dust protection
7//! - Proper handling of plain P2TR (key-path) vs Tapret (script-path) inputs
8
9#[allow(unused_imports)]
10use bitcoin::hashes::Hash as _;
11use bitcoin::{
12    absolute::LockTime, consensus::encode::serialize as tx_serialize, Address, Amount, ScriptBuf,
13    Sequence, TxIn, TxOut, Txid,
14};
15
16use crate::tapret::TapretCommitment;
17use crate::wallet::{Bip86Path, SealWallet, WalletUtxo};
18
19/// Dust threshold for P2TR outputs (BIP-0448: 330 sat for P2TR)
20const P2TR_DUST_SAT: u64 = 330;
21
22/// RBF sequence number (BIP-0125)
23const RBF_SEQUENCE: Sequence = Sequence::ENABLE_RBF_NO_LOCKTIME;
24
25/// Configuration for building commitment transactions
26pub struct CommitmentTxBuilder {
27    /// Fee rate in sat/vB
28    pub fee_rate_sat_per_vb: u64,
29    /// Protocol ID for this commitment
30    pub protocol_id: [u8; 32],
31    /// Maximum fee rate to prevent overpaying
32    pub max_fee_rate_sat_per_vb: u64,
33    /// Dust threshold (satoshi)
34    pub dust_threshold_sat: u64,
35}
36
37impl CommitmentTxBuilder {
38    /// Create a new transaction builder
39    pub fn new(protocol_id: [u8; 32], fee_rate_sat_per_vb: u64) -> Self {
40        Self {
41            fee_rate_sat_per_vb,
42            protocol_id,
43            max_fee_rate_sat_per_vb: fee_rate_sat_per_vb * 10,
44            dust_threshold_sat: P2TR_DUST_SAT,
45        }
46    }
47
48    /// Set the fee rate
49    pub fn with_fee_rate(mut self, fee_rate: u64) -> Self {
50        self.fee_rate_sat_per_vb = fee_rate;
51        self
52    }
53
54    /// Set maximum fee rate (prevents overpaying during fee spikes)
55    pub fn with_max_fee_rate(mut self, max_fee: u64) -> Self {
56        self.max_fee_rate_sat_per_vb = max_fee;
57        self
58    }
59
60    /// Estimate virtual bytes for a commitment transaction
61    pub fn estimate_vbytes(input_count: usize, output_count: usize) -> usize {
62        let base = 10;
63        let per_input = 58;
64        let per_output = 43;
65        base + input_count * per_input + output_count * per_output
66    }
67
68    /// Calculate required fee
69    pub fn calculate_fee(&self, input_count: usize, output_count: usize) -> u64 {
70        let vbytes = Self::estimate_vbytes(input_count, output_count);
71        let fee = vbytes as u64 * self.fee_rate_sat_per_vb;
72        let max_fee = (vbytes as u64) * self.max_fee_rate_sat_per_vb;
73        fee.min(max_fee)
74    }
75
76    /// Check if an output amount is above the dust threshold
77    pub fn is_above_dust(&self, value_sat: u64) -> bool {
78        value_sat >= self.dust_threshold_sat
79    }
80
81    /// Build a complete commitment transaction
82    ///
83    /// This handles two cases:
84    /// 1. **Plain P2TR input** (funded externally to a simple P2TR address):
85    ///    The input is spent via key-path using the tweaked keypair.
86    ///    The output uses a Tapret commitment with the same internal key.
87    ///
88    /// 2. **Tapret input** (previously committed):
89    ///    The input is spent via script-path using the tapret leaf.
90    ///
91    /// For freshly funded UTXOs (case 1), this is the standard flow.
92    pub fn build_commitment_tx(
93        &self,
94        wallet: &SealWallet,
95        seal_utxo: &WalletUtxo,
96        commitment_hash: [u8; 32],
97        _change_path: Option<&Bip86Path>,
98    ) -> Result<CommitmentTxResult, TxBuilderError> {
99        let secp = wallet.secp();
100        let seal_key = wallet.derive_key(&seal_utxo.path)?;
101
102        // Calculate fee (1 input, 1 output)
103        let fee = self.calculate_fee(1, 1);
104        let commitment_value_sat = seal_utxo.amount_sat.saturating_sub(fee);
105
106        if !self.is_above_dust(commitment_value_sat) {
107            return Err(TxBuilderError::OutputBelowDust {
108                value: commitment_value_sat,
109                dust: self.dust_threshold_sat,
110            });
111        }
112
113        // Build Tapret commitment output
114        let tapret = TapretCommitment::new(
115            self.protocol_id,
116            csv_adapter_core::hash::Hash::new(commitment_hash),
117        );
118        let leaf_script = tapret.leaf_script();
119
120        // Build Taproot tree with single tapret leaf at depth 0
121        // Use the internal key (before tweaking) from the derived seal key
122        let internal_xonly = seal_key.internal_xonly;
123        let builder = bitcoin::taproot::TaprootBuilder::new();
124        let builder = builder
125            .add_leaf(0, leaf_script.clone())
126            .map_err(|e| TxBuilderError::TaprootBuildFailed(format!("{:?}", e)))?;
127
128        let taproot_spend_info = builder
129            .finalize(secp, internal_xonly)
130            .map_err(|e| TxBuilderError::TaprootBuildFailed(format!("{:?}", e)))?;
131
132        let output_key = taproot_spend_info.output_key();
133        let address = Address::p2tr_tweaked(output_key, wallet.network());
134
135        // Build unsigned transaction
136        let input = TxIn {
137            previous_output: seal_utxo.outpoint,
138            script_sig: ScriptBuf::new(),
139            sequence: RBF_SEQUENCE,
140            witness: bitcoin::Witness::new(),
141        };
142
143        let outputs = vec![TxOut {
144            value: commitment_value_sat,
145            script_pubkey: address.script_pubkey(),
146        }];
147
148        let unsigned_tx = bitcoin::Transaction {
149            version: 2,
150            lock_time: LockTime::ZERO,
151            input: vec![input],
152            output: outputs,
153        };
154
155        // Sign via key-path spending: the input UTXO was sent to seal_key.address
156        // which is a simple P2TR with no script tree.
157        // Sign with the tweaked keypair matching the input's scriptPubKey.
158        let sighash = bitcoin::sighash::SighashCache::new(&unsigned_tx)
159            .taproot_key_spend_signature_hash(
160                0,
161                &bitcoin::sighash::Prevouts::All(&[&bitcoin::TxOut {
162                    value: seal_utxo.amount_sat,
163                    script_pubkey: seal_key.address.script_pubkey(),
164                }]),
165                bitcoin::sighash::TapSighashType::Default,
166            )
167            .map_err(|e| TxBuilderError::SighashFailed(format!("{}", e)))?;
168
169        let mut sighash_bytes = [0u8; 32];
170        sighash_bytes.copy_from_slice(sighash.as_ref());
171
172        // Sign with the tweaked keypair for key-path spending
173        let schnorr_sig = wallet
174            .sign_taproot_keypath(&seal_utxo.path, &sighash_bytes)
175            .map_err(|e| TxBuilderError::WalletError(e.to_string()))?;
176
177        // Build the witness: [64-byte Schnorr signature]
178        let witness = bitcoin::Witness::from_slice(&[schnorr_sig.as_slice()]);
179
180        // Create signed transaction
181        let mut signed_tx = unsigned_tx.clone();
182        signed_tx.input[0].witness = witness;
183
184        let raw_tx = tx_serialize(&signed_tx);
185        let txid = signed_tx.txid();
186
187        let script_pubkey = address.script_pubkey();
188        Ok(CommitmentTxResult {
189            tx: signed_tx,
190            txid,
191            raw_tx,
192            tapret_output: TapretOutput {
193                address,
194                script_pubkey,
195                value: Amount::from_sat(commitment_value_sat),
196                taproot_spend_info,
197                leaf_script,
198                amount_sat: commitment_value_sat,
199            },
200            change_output: None,
201            fee_sat: fee,
202            input_value_sat: seal_utxo.amount_sat,
203            commitment_output_index: 0,
204        })
205    }
206
207    /// Build legacy commitment data (for backward compatibility)
208    pub fn build_commitment_data(
209        &self,
210        commitment: csv_adapter_core::hash::Hash,
211    ) -> CommitmentData {
212        let tapret = TapretCommitment::new(self.protocol_id, commitment);
213        CommitmentData::Tapret {
214            script: tapret.leaf_script(),
215            payload: tapret.payload(),
216        }
217    }
218}
219
220/// Tapret commitment output
221#[derive(Clone, Debug)]
222pub struct TapretOutput {
223    pub address: Address,
224    pub script_pubkey: ScriptBuf,
225    pub value: Amount,
226    pub taproot_spend_info: bitcoin::taproot::TaprootSpendInfo,
227    pub leaf_script: ScriptBuf,
228    pub amount_sat: u64,
229}
230
231/// Change output
232#[derive(Clone, Debug)]
233pub struct ChangeOutput {
234    pub address: Address,
235    pub value: Amount,
236    pub derivation_path: Bip86Path,
237}
238
239/// Transaction builder output
240#[derive(Clone, Debug)]
241pub struct CommitmentTxResult {
242    pub tx: bitcoin::Transaction,
243    pub txid: Txid,
244    pub raw_tx: Vec<u8>,
245    pub tapret_output: TapretOutput,
246    pub change_output: Option<ChangeOutput>,
247    pub fee_sat: u64,
248    pub input_value_sat: u64,
249    pub commitment_output_index: u32,
250}
251
252impl CommitmentTxResult {
253    pub fn commitment_output_index(&self) -> u32 {
254        self.commitment_output_index
255    }
256}
257
258/// Commitment data output (for backward compatibility)
259pub enum CommitmentData {
260    Tapret {
261        script: ScriptBuf,
262        payload: [u8; 64],
263    },
264    Opret {
265        script: ScriptBuf,
266    },
267}
268
269impl CommitmentData {
270    pub fn script(&self) -> &ScriptBuf {
271        match self {
272            CommitmentData::Tapret { script, .. } => script,
273            CommitmentData::Opret { script } => script,
274        }
275    }
276}
277
278/// Transaction builder errors
279#[derive(Debug, thiserror::Error)]
280pub enum TxBuilderError {
281    #[error("Taproot build failed: {0}")]
282    TaprootBuildFailed(String),
283
284    #[error("Output value {value} sat is below dust threshold {dust} sat")]
285    OutputBelowDust { value: u64, dust: u64 },
286
287    #[error("Sighash computation failed: {0}")]
288    SighashFailed(String),
289
290    #[error("Wallet error: {0}")]
291    WalletError(String),
292
293    #[error("Insufficient funds: available {available} sat, required {required} sat")]
294    InsufficientFunds { available: u64, required: u64 },
295}
296
297impl From<crate::wallet::WalletError> for TxBuilderError {
298    fn from(e: crate::wallet::WalletError) -> Self {
299        TxBuilderError::WalletError(e.to_string())
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use bitcoin::{Network, OutPoint};
307
308    fn make_utxo(path: Bip86Path, amount: u64) -> WalletUtxo {
309        let txid = Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_byte_array([0xAB; 32]));
310        WalletUtxo {
311            outpoint: OutPoint::new(txid, 0),
312            amount_sat: amount,
313            path,
314            reserved: false,
315            reserved_for: None,
316        }
317    }
318
319    #[test]
320    fn test_builder_creation() {
321        let builder = CommitmentTxBuilder::new([1u8; 32], 10);
322        assert_eq!(builder.fee_rate_sat_per_vb, 10);
323        assert_eq!(builder.protocol_id, [1u8; 32]);
324    }
325
326    #[test]
327    fn test_builder_with_fee_rate() {
328        let builder = CommitmentTxBuilder::new([1u8; 32], 5).with_fee_rate(20);
329        assert_eq!(builder.fee_rate_sat_per_vb, 20);
330    }
331
332    #[test]
333    fn test_vbyte_estimation() {
334        let vbytes = CommitmentTxBuilder::estimate_vbytes(1, 1);
335        assert!(vbytes > 50);
336        assert!(vbytes < 300);
337    }
338
339    #[test]
340    fn test_fee_calculation() {
341        let builder = CommitmentTxBuilder::new([1u8; 32], 10);
342        let fee = builder.calculate_fee(1, 1);
343        let expected_vbytes = CommitmentTxBuilder::estimate_vbytes(1, 1);
344        assert_eq!(fee, expected_vbytes as u64 * 10);
345    }
346
347    #[test]
348    fn test_max_fee_rate_cap() {
349        let builder = CommitmentTxBuilder::new([1u8; 32], 1000).with_max_fee_rate(10);
350        let fee = builder.calculate_fee(1, 1);
351        let vbytes = CommitmentTxBuilder::estimate_vbytes(1, 1);
352        assert_eq!(fee, vbytes as u64 * 10);
353    }
354
355    #[test]
356    fn test_dust_check() {
357        let builder = CommitmentTxBuilder::new([1u8; 32], 10);
358        assert!(builder.is_above_dust(P2TR_DUST_SAT));
359        assert!(builder.is_above_dust(2000));
360        assert!(!builder.is_above_dust(100));
361    }
362
363    #[test]
364    fn test_build_commitment_data() {
365        let builder = CommitmentTxBuilder::new([1u8; 32], 10);
366        let data = builder.build_commitment_data(csv_adapter_core::hash::Hash::new([2u8; 32]));
367        match data {
368            CommitmentData::Tapret { script, payload } => {
369                assert_eq!(payload[..32], [1u8; 32]);
370                assert!(script.is_op_return());
371            }
372            _ => panic!("Expected Tapret"),
373        }
374    }
375
376    #[test]
377    fn test_build_commitment_tx() {
378        let wallet = SealWallet::generate_random(Network::Regtest);
379        let path = Bip86Path::external(0, 0);
380        let seal_utxo = make_utxo(path.clone(), 1_000_000);
381        wallet.add_utxo(seal_utxo.outpoint, seal_utxo.amount_sat, path);
382
383        let builder = CommitmentTxBuilder::new([0xAB; 32], 10);
384        let result = builder
385            .build_commitment_tx(&wallet, &seal_utxo, [0xCD; 32], None)
386            .expect("tx build should succeed");
387
388        assert!(result.fee_sat > 0);
389        assert_eq!(result.input_value_sat, 1_000_000);
390        assert_eq!(result.raw_tx.len(), result.tx.size());
391        assert_eq!(
392            result.tapret_output.amount_sat,
393            result.input_value_sat - result.fee_sat
394        );
395    }
396
397    #[test]
398    fn test_tx_has_witness() {
399        let wallet = SealWallet::generate_random(Network::Regtest);
400        let path = Bip86Path::external(0, 0);
401        let seal_utxo = make_utxo(path.clone(), 500_000);
402        wallet.add_utxo(seal_utxo.outpoint, seal_utxo.amount_sat, path);
403
404        let builder = CommitmentTxBuilder::new([0xAB; 32], 10);
405        let result = builder
406            .build_commitment_tx(&wallet, &seal_utxo, [0xCD; 32], None)
407            .expect("tx build should succeed");
408
409        // Transaction should have valid witness data
410        assert!(!result.tx.input[0].witness.is_empty());
411        assert!(result.raw_tx.len() > 0);
412    }
413
414    #[test]
415    fn test_dust_prevention() {
416        let wallet = SealWallet::generate_random(Network::Regtest);
417        let path = Bip86Path::external(0, 0);
418        let seal_utxo = make_utxo(path.clone(), 500);
419        wallet.add_utxo(seal_utxo.outpoint, seal_utxo.amount_sat, path);
420
421        let builder = CommitmentTxBuilder::new([0xAB; 32], 10);
422        let result = builder.build_commitment_tx(&wallet, &seal_utxo, [0xCD; 32], None);
423
424        // Should fail due to dust or insufficient funds
425        assert!(result.is_err());
426    }
427}