1#[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
19const P2TR_DUST_SAT: u64 = 330;
21
22const RBF_SEQUENCE: Sequence = Sequence::ENABLE_RBF_NO_LOCKTIME;
24
25pub struct CommitmentTxBuilder {
27 pub fee_rate_sat_per_vb: u64,
29 pub protocol_id: [u8; 32],
31 pub max_fee_rate_sat_per_vb: u64,
33 pub dust_threshold_sat: u64,
35}
36
37impl CommitmentTxBuilder {
38 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 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 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 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 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 pub fn is_above_dust(&self, value_sat: u64) -> bool {
78 value_sat >= self.dust_threshold_sat
79 }
80
81 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 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 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 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 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 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 let schnorr_sig = wallet
174 .sign_taproot_keypath(&seal_utxo.path, &sighash_bytes)
175 .map_err(|e| TxBuilderError::WalletError(e.to_string()))?;
176
177 let witness = bitcoin::Witness::from_slice(&[schnorr_sig.as_slice()]);
179
180 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 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#[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#[derive(Clone, Debug)]
233pub struct ChangeOutput {
234 pub address: Address,
235 pub value: Amount,
236 pub derivation_path: Bip86Path,
237}
238
239#[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
258pub 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#[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 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 assert!(result.is_err());
426 }
427}