#[allow(unused_imports)]
use bitcoin::hashes::Hash as _;
use bitcoin::{
absolute::LockTime, consensus::encode::serialize as tx_serialize, Address, Amount, ScriptBuf,
Sequence, TxIn, TxOut, Txid,
};
use crate::tapret::TapretCommitment;
use crate::wallet::{Bip86Path, SealWallet, WalletUtxo};
const P2TR_DUST_SAT: u64 = 330;
const RBF_SEQUENCE: Sequence = Sequence::ENABLE_RBF_NO_LOCKTIME;
pub struct CommitmentTxBuilder {
pub fee_rate_sat_per_vb: u64,
pub protocol_id: [u8; 32],
pub max_fee_rate_sat_per_vb: u64,
pub dust_threshold_sat: u64,
}
impl CommitmentTxBuilder {
pub fn new(protocol_id: [u8; 32], fee_rate_sat_per_vb: u64) -> Self {
Self {
fee_rate_sat_per_vb,
protocol_id,
max_fee_rate_sat_per_vb: fee_rate_sat_per_vb * 10,
dust_threshold_sat: P2TR_DUST_SAT,
}
}
pub fn with_fee_rate(mut self, fee_rate: u64) -> Self {
self.fee_rate_sat_per_vb = fee_rate;
self
}
pub fn with_max_fee_rate(mut self, max_fee: u64) -> Self {
self.max_fee_rate_sat_per_vb = max_fee;
self
}
pub fn estimate_vbytes(input_count: usize, output_count: usize) -> usize {
let base = 10;
let per_input = 58;
let per_output = 43;
base + input_count * per_input + output_count * per_output
}
pub fn calculate_fee(&self, input_count: usize, output_count: usize) -> u64 {
let vbytes = Self::estimate_vbytes(input_count, output_count);
let fee = vbytes as u64 * self.fee_rate_sat_per_vb;
let max_fee = (vbytes as u64) * self.max_fee_rate_sat_per_vb;
fee.min(max_fee)
}
pub fn is_above_dust(&self, value_sat: u64) -> bool {
value_sat >= self.dust_threshold_sat
}
pub fn build_commitment_tx(
&self,
wallet: &SealWallet,
seal_utxo: &WalletUtxo,
commitment_hash: [u8; 32],
_change_path: Option<&Bip86Path>,
) -> Result<CommitmentTxResult, TxBuilderError> {
let secp = wallet.secp();
let seal_key = wallet.derive_key(&seal_utxo.path)?;
let fee = self.calculate_fee(1, 1);
let commitment_value_sat = seal_utxo.amount_sat.saturating_sub(fee);
if !self.is_above_dust(commitment_value_sat) {
return Err(TxBuilderError::OutputBelowDust {
value: commitment_value_sat,
dust: self.dust_threshold_sat,
});
}
let tapret = TapretCommitment::new(
self.protocol_id,
csv_adapter_core::hash::Hash::new(commitment_hash),
);
let leaf_script = tapret.leaf_script();
let internal_xonly = seal_key.internal_xonly;
let builder = bitcoin::taproot::TaprootBuilder::new();
let builder = builder
.add_leaf(0, leaf_script.clone())
.map_err(|e| TxBuilderError::TaprootBuildFailed(format!("{:?}", e)))?;
let taproot_spend_info = builder
.finalize(secp, internal_xonly)
.map_err(|e| TxBuilderError::TaprootBuildFailed(format!("{:?}", e)))?;
let output_key = taproot_spend_info.output_key();
let address = Address::p2tr_tweaked(output_key, wallet.network());
let input = TxIn {
previous_output: seal_utxo.outpoint,
script_sig: ScriptBuf::new(),
sequence: RBF_SEQUENCE,
witness: bitcoin::Witness::new(),
};
let outputs = vec![TxOut {
value: commitment_value_sat,
script_pubkey: address.script_pubkey(),
}];
let unsigned_tx = bitcoin::Transaction {
version: 2,
lock_time: LockTime::ZERO,
input: vec![input],
output: outputs,
};
let sighash = bitcoin::sighash::SighashCache::new(&unsigned_tx)
.taproot_key_spend_signature_hash(
0,
&bitcoin::sighash::Prevouts::All(&[&bitcoin::TxOut {
value: seal_utxo.amount_sat,
script_pubkey: seal_key.address.script_pubkey(),
}]),
bitcoin::sighash::TapSighashType::Default,
)
.map_err(|e| TxBuilderError::SighashFailed(format!("{}", e)))?;
let mut sighash_bytes = [0u8; 32];
sighash_bytes.copy_from_slice(sighash.as_ref());
let schnorr_sig = wallet
.sign_taproot_keypath(&seal_utxo.path, &sighash_bytes)
.map_err(|e| TxBuilderError::WalletError(e.to_string()))?;
let witness = bitcoin::Witness::from_slice(&[schnorr_sig.as_slice()]);
let mut signed_tx = unsigned_tx.clone();
signed_tx.input[0].witness = witness;
let raw_tx = tx_serialize(&signed_tx);
let txid = signed_tx.txid();
let script_pubkey = address.script_pubkey();
Ok(CommitmentTxResult {
tx: signed_tx,
txid,
raw_tx,
tapret_output: TapretOutput {
address,
script_pubkey,
value: Amount::from_sat(commitment_value_sat),
taproot_spend_info,
leaf_script,
amount_sat: commitment_value_sat,
},
change_output: None,
fee_sat: fee,
input_value_sat: seal_utxo.amount_sat,
commitment_output_index: 0,
})
}
pub fn build_commitment_data(
&self,
commitment: csv_adapter_core::hash::Hash,
) -> CommitmentData {
let tapret = TapretCommitment::new(self.protocol_id, commitment);
CommitmentData::Tapret {
script: tapret.leaf_script(),
payload: tapret.payload(),
}
}
}
#[derive(Clone, Debug)]
pub struct TapretOutput {
pub address: Address,
pub script_pubkey: ScriptBuf,
pub value: Amount,
pub taproot_spend_info: bitcoin::taproot::TaprootSpendInfo,
pub leaf_script: ScriptBuf,
pub amount_sat: u64,
}
#[derive(Clone, Debug)]
pub struct ChangeOutput {
pub address: Address,
pub value: Amount,
pub derivation_path: Bip86Path,
}
#[derive(Clone, Debug)]
pub struct CommitmentTxResult {
pub tx: bitcoin::Transaction,
pub txid: Txid,
pub raw_tx: Vec<u8>,
pub tapret_output: TapretOutput,
pub change_output: Option<ChangeOutput>,
pub fee_sat: u64,
pub input_value_sat: u64,
pub commitment_output_index: u32,
}
impl CommitmentTxResult {
pub fn commitment_output_index(&self) -> u32 {
self.commitment_output_index
}
}
pub enum CommitmentData {
Tapret {
script: ScriptBuf,
payload: [u8; 64],
},
Opret {
script: ScriptBuf,
},
}
impl CommitmentData {
pub fn script(&self) -> &ScriptBuf {
match self {
CommitmentData::Tapret { script, .. } => script,
CommitmentData::Opret { script } => script,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum TxBuilderError {
#[error("Taproot build failed: {0}")]
TaprootBuildFailed(String),
#[error("Output value {value} sat is below dust threshold {dust} sat")]
OutputBelowDust { value: u64, dust: u64 },
#[error("Sighash computation failed: {0}")]
SighashFailed(String),
#[error("Wallet error: {0}")]
WalletError(String),
#[error("Insufficient funds: available {available} sat, required {required} sat")]
InsufficientFunds { available: u64, required: u64 },
}
impl From<crate::wallet::WalletError> for TxBuilderError {
fn from(e: crate::wallet::WalletError) -> Self {
TxBuilderError::WalletError(e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::{Network, OutPoint};
fn make_utxo(path: Bip86Path, amount: u64) -> WalletUtxo {
let txid = Txid::from_raw_hash(bitcoin::hashes::sha256d::Hash::from_byte_array([0xAB; 32]));
WalletUtxo {
outpoint: OutPoint::new(txid, 0),
amount_sat: amount,
path,
reserved: false,
reserved_for: None,
}
}
#[test]
fn test_builder_creation() {
let builder = CommitmentTxBuilder::new([1u8; 32], 10);
assert_eq!(builder.fee_rate_sat_per_vb, 10);
assert_eq!(builder.protocol_id, [1u8; 32]);
}
#[test]
fn test_builder_with_fee_rate() {
let builder = CommitmentTxBuilder::new([1u8; 32], 5).with_fee_rate(20);
assert_eq!(builder.fee_rate_sat_per_vb, 20);
}
#[test]
fn test_vbyte_estimation() {
let vbytes = CommitmentTxBuilder::estimate_vbytes(1, 1);
assert!(vbytes > 50);
assert!(vbytes < 300);
}
#[test]
fn test_fee_calculation() {
let builder = CommitmentTxBuilder::new([1u8; 32], 10);
let fee = builder.calculate_fee(1, 1);
let expected_vbytes = CommitmentTxBuilder::estimate_vbytes(1, 1);
assert_eq!(fee, expected_vbytes as u64 * 10);
}
#[test]
fn test_max_fee_rate_cap() {
let builder = CommitmentTxBuilder::new([1u8; 32], 1000).with_max_fee_rate(10);
let fee = builder.calculate_fee(1, 1);
let vbytes = CommitmentTxBuilder::estimate_vbytes(1, 1);
assert_eq!(fee, vbytes as u64 * 10);
}
#[test]
fn test_dust_check() {
let builder = CommitmentTxBuilder::new([1u8; 32], 10);
assert!(builder.is_above_dust(P2TR_DUST_SAT));
assert!(builder.is_above_dust(2000));
assert!(!builder.is_above_dust(100));
}
#[test]
fn test_build_commitment_data() {
let builder = CommitmentTxBuilder::new([1u8; 32], 10);
let data = builder.build_commitment_data(csv_adapter_core::hash::Hash::new([2u8; 32]));
match data {
CommitmentData::Tapret { script, payload } => {
assert_eq!(payload[..32], [1u8; 32]);
assert!(script.is_op_return());
}
_ => panic!("Expected Tapret"),
}
}
#[test]
fn test_build_commitment_tx() {
let wallet = SealWallet::generate_random(Network::Regtest);
let path = Bip86Path::external(0, 0);
let seal_utxo = make_utxo(path.clone(), 1_000_000);
wallet.add_utxo(seal_utxo.outpoint, seal_utxo.amount_sat, path);
let builder = CommitmentTxBuilder::new([0xAB; 32], 10);
let result = builder
.build_commitment_tx(&wallet, &seal_utxo, [0xCD; 32], None)
.expect("tx build should succeed");
assert!(result.fee_sat > 0);
assert_eq!(result.input_value_sat, 1_000_000);
assert_eq!(result.raw_tx.len(), result.tx.size());
assert_eq!(
result.tapret_output.amount_sat,
result.input_value_sat - result.fee_sat
);
}
#[test]
fn test_tx_has_witness() {
let wallet = SealWallet::generate_random(Network::Regtest);
let path = Bip86Path::external(0, 0);
let seal_utxo = make_utxo(path.clone(), 500_000);
wallet.add_utxo(seal_utxo.outpoint, seal_utxo.amount_sat, path);
let builder = CommitmentTxBuilder::new([0xAB; 32], 10);
let result = builder
.build_commitment_tx(&wallet, &seal_utxo, [0xCD; 32], None)
.expect("tx build should succeed");
assert!(!result.tx.input[0].witness.is_empty());
assert!(result.raw_tx.len() > 0);
}
#[test]
fn test_dust_prevention() {
let wallet = SealWallet::generate_random(Network::Regtest);
let path = Bip86Path::external(0, 0);
let seal_utxo = make_utxo(path.clone(), 500);
wallet.add_utxo(seal_utxo.outpoint, seal_utxo.amount_sat, path);
let builder = CommitmentTxBuilder::new([0xAB; 32], 10);
let result = builder.build_commitment_tx(&wallet, &seal_utxo, [0xCD; 32], None);
assert!(result.is_err());
}
}