use crate::error::{TxError, Result};
use crate::types::{Transaction, TxInput, TxOutput, Utxo};
use crate::fee::{estimate_fee, is_dust, DUST_THRESHOLD_P2WPKH};
use crate::coin_selection::select_coins;
#[derive(Debug, Clone)]
pub struct TxBuilder {
inputs: Vec<(Utxo, Option<[u8; 33]>)>, outputs: Vec<TxOutput>,
change_address: Option<String>,
fee_rate: u64,
version: i32,
locktime: u32,
}
impl Default for TxBuilder {
fn default() -> Self {
Self::new()
}
}
impl TxBuilder {
pub fn new() -> Self {
Self {
inputs: Vec::new(),
outputs: Vec::new(),
change_address: None,
fee_rate: 1, version: 2,
locktime: 0,
}
}
pub fn add_input(mut self, utxo: Utxo) -> Self {
self.inputs.push((utxo, None));
self
}
pub fn add_input_with_key(mut self, utxo: Utxo, pubkey: [u8; 33]) -> Self {
self.inputs.push((utxo, Some(pubkey)));
self
}
pub fn add_output_p2pkh(mut self, address: &str, value: u64) -> Result<Self> {
let script = address_to_script(address)?;
self.outputs.push(TxOutput::new(value, script));
Ok(self)
}
pub fn add_output(mut self, value: u64, script_pubkey: Vec<u8>) -> Self {
self.outputs.push(TxOutput::new(value, script_pubkey));
self
}
pub fn set_change_address(mut self, address: &str) -> Self {
self.change_address = Some(address.to_string());
self
}
pub fn set_fee_rate(mut self, sat_per_vb: u64) -> Self {
self.fee_rate = sat_per_vb;
self
}
pub fn set_version(mut self, version: i32) -> Self {
self.version = version;
self
}
pub fn set_locktime(mut self, locktime: u32) -> Self {
self.locktime = locktime;
self
}
pub fn build(self) -> Result<UnsignedTx> {
if self.inputs.is_empty() {
return Err(TxError::NoInputs);
}
if self.outputs.is_empty() {
return Err(TxError::NoOutputs);
}
for output in &self.outputs {
if is_dust(output.value, true) {
return Err(TxError::DustOutput(output.value));
}
}
let input_total: u64 = self.inputs.iter().map(|(u, _)| u.value).sum();
let output_total: u64 = self.outputs.iter().map(|o| o.value).sum();
let num_outputs = if self.change_address.is_some() {
self.outputs.len() + 1
} else {
self.outputs.len()
};
let fee = estimate_fee(self.inputs.len(), num_outputs, self.fee_rate);
let needed = output_total.saturating_add(fee);
if input_total < needed {
return Err(TxError::InsufficientFunds {
needed,
available: input_total,
});
}
let mut tx = Transaction {
version: self.version,
inputs: Vec::new(),
outputs: self.outputs.clone(),
locktime: self.locktime,
};
let mut input_info = Vec::new();
for (utxo, pubkey) in &self.inputs {
let input = TxInput::new(utxo.txid, utxo.vout);
tx.inputs.push(input);
input_info.push(InputInfo {
utxo: utxo.clone(),
pubkey: *pubkey,
});
}
let change = input_total - output_total - fee;
if change > DUST_THRESHOLD_P2WPKH {
if let Some(addr) = &self.change_address {
let script = address_to_script(addr)?;
tx.outputs.push(TxOutput::new(change, script));
}
}
Ok(UnsignedTx {
tx,
input_info,
fee,
})
}
pub fn build_with_coin_selection(
mut self,
utxos: &[Utxo],
) -> Result<UnsignedTx> {
let output_total: u64 = self.outputs.iter().map(|o| o.value).sum();
let (selected, _) = select_coins(utxos, output_total, self.fee_rate)?;
for utxo in selected {
self = self.add_input(utxo);
}
self.build()
}
}
#[derive(Debug, Clone)]
pub struct InputInfo {
pub utxo: Utxo,
pub pubkey: Option<[u8; 33]>,
}
#[derive(Debug, Clone)]
pub struct UnsignedTx {
pub tx: Transaction,
pub input_info: Vec<InputInfo>,
pub fee: u64,
}
impl UnsignedTx {
pub fn transaction(&self) -> &Transaction {
&self.tx
}
pub fn fee(&self) -> u64 {
self.fee
}
}
fn address_to_script(address: &str) -> Result<Vec<u8>> {
if address.starts_with('1') || address.starts_with('m') || address.starts_with('n') {
let decoded = bs58::decode(address)
.into_vec()
.map_err(|e| TxError::InvalidAddress(e.to_string()))?;
if decoded.len() != 25 {
return Err(TxError::InvalidAddress("Invalid P2PKH address length".to_string()));
}
let mut pubkey_hash = [0u8; 20];
pubkey_hash.copy_from_slice(&decoded[1..21]);
Ok(crate::script::build_p2pkh_script(&pubkey_hash))
}
else if address.starts_with("bc1q") || address.starts_with("tb1q") {
let (_, data) = bech32_decode(address)
.map_err(TxError::InvalidAddress)?;
if data.len() != 20 {
return Err(TxError::InvalidAddress("Invalid P2WPKH address".to_string()));
}
let mut pubkey_hash = [0u8; 20];
pubkey_hash.copy_from_slice(&data);
Ok(crate::script::build_p2wpkh_script(&pubkey_hash))
}
else {
Err(TxError::InvalidAddress(format!("Unsupported address format: {}", address)))
}
}
fn bech32_decode(address: &str) -> std::result::Result<(String, Vec<u8>), String> {
let address = address.to_lowercase();
let pos = address.rfind('1').ok_or("No separator found")?;
let hrp = &address[..pos];
let data_part = &address[pos + 1..];
const CHARSET: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let mut values = Vec::new();
for c in data_part.chars() {
let idx = CHARSET.find(c).ok_or("Invalid character")?;
values.push(idx as u8);
}
if values.len() < 7 {
return Err("Too short".to_string());
}
let data_values = &values[1..values.len() - 6];
let mut result = Vec::new();
let mut acc = 0u32;
let mut bits = 0u32;
for &v in data_values {
acc = (acc << 5) | (v as u32);
bits += 5;
while bits >= 8 {
bits -= 8;
result.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Ok((hrp.to_string(), result))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_utxo(value: u64) -> Utxo {
Utxo {
txid: [0u8; 32],
vout: 0,
value,
script_pubkey: vec![0x76, 0xa9, 0x14], address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa".to_string(),
}
}
#[test]
fn test_builder_no_inputs() {
let result = TxBuilder::new()
.add_output(50_000, vec![0x00])
.build();
assert!(matches!(result, Err(TxError::NoInputs)));
}
#[test]
fn test_builder_no_outputs() {
let result = TxBuilder::new()
.add_input(make_utxo(100_000))
.build();
assert!(matches!(result, Err(TxError::NoOutputs)));
}
#[test]
fn test_builder_dust_output() {
let result = TxBuilder::new()
.add_input(make_utxo(100_000))
.add_output(100, vec![0x00, 0x14]) .build();
assert!(matches!(result, Err(TxError::DustOutput(_))));
}
#[test]
fn test_builder_success() {
let result = TxBuilder::new()
.add_input(make_utxo(100_000))
.add_output(50_000, vec![0x76, 0xa9, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0xac])
.set_fee_rate(1)
.build();
assert!(result.is_ok());
let unsigned = result.unwrap();
assert_eq!(unsigned.tx.inputs.len(), 1);
assert_eq!(unsigned.tx.outputs.len(), 1);
}
}