rustywallet_tx/
builder.rs

1//! Transaction builder.
2
3use crate::error::{TxError, Result};
4use crate::types::{Transaction, TxInput, TxOutput, Utxo};
5use crate::fee::{estimate_fee, is_dust, DUST_THRESHOLD_P2WPKH};
6use crate::coin_selection::select_coins;
7
8/// Transaction builder for creating unsigned transactions.
9#[derive(Debug, Clone)]
10pub struct TxBuilder {
11    inputs: Vec<(Utxo, Option<[u8; 33]>)>, // UTXO + optional pubkey for signing
12    outputs: Vec<TxOutput>,
13    change_address: Option<String>,
14    fee_rate: u64,
15    version: i32,
16    locktime: u32,
17}
18
19impl Default for TxBuilder {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl TxBuilder {
26    /// Create a new transaction builder.
27    pub fn new() -> Self {
28        Self {
29            inputs: Vec::new(),
30            outputs: Vec::new(),
31            change_address: None,
32            fee_rate: 1, // 1 sat/vB default
33            version: 2,
34            locktime: 0,
35        }
36    }
37
38    /// Add an input from a UTXO.
39    pub fn add_input(mut self, utxo: Utxo) -> Self {
40        self.inputs.push((utxo, None));
41        self
42    }
43
44    /// Add an input with the public key for signing.
45    pub fn add_input_with_key(mut self, utxo: Utxo, pubkey: [u8; 33]) -> Self {
46        self.inputs.push((utxo, Some(pubkey)));
47        self
48    }
49
50    /// Add an output to a P2PKH address.
51    pub fn add_output_p2pkh(mut self, address: &str, value: u64) -> Result<Self> {
52        let script = address_to_script(address)?;
53        self.outputs.push(TxOutput::new(value, script));
54        Ok(self)
55    }
56
57    /// Add an output with raw script.
58    pub fn add_output(mut self, value: u64, script_pubkey: Vec<u8>) -> Self {
59        self.outputs.push(TxOutput::new(value, script_pubkey));
60        self
61    }
62
63    /// Set the change address.
64    pub fn set_change_address(mut self, address: &str) -> Self {
65        self.change_address = Some(address.to_string());
66        self
67    }
68
69    /// Set the fee rate in satoshis per virtual byte.
70    pub fn set_fee_rate(mut self, sat_per_vb: u64) -> Self {
71        self.fee_rate = sat_per_vb;
72        self
73    }
74
75    /// Set transaction version.
76    pub fn set_version(mut self, version: i32) -> Self {
77        self.version = version;
78        self
79    }
80
81    /// Set locktime.
82    pub fn set_locktime(mut self, locktime: u32) -> Self {
83        self.locktime = locktime;
84        self
85    }
86
87    /// Build the unsigned transaction.
88    pub fn build(self) -> Result<UnsignedTx> {
89        if self.inputs.is_empty() {
90            return Err(TxError::NoInputs);
91        }
92        if self.outputs.is_empty() {
93            return Err(TxError::NoOutputs);
94        }
95
96        // Check for dust outputs
97        for output in &self.outputs {
98            if is_dust(output.value, true) {
99                return Err(TxError::DustOutput(output.value));
100            }
101        }
102
103        // Calculate totals
104        let input_total: u64 = self.inputs.iter().map(|(u, _)| u.value).sum();
105        let output_total: u64 = self.outputs.iter().map(|o| o.value).sum();
106
107        // Estimate fee
108        let num_outputs = if self.change_address.is_some() {
109            self.outputs.len() + 1
110        } else {
111            self.outputs.len()
112        };
113        let fee = estimate_fee(self.inputs.len(), num_outputs, self.fee_rate);
114
115        // Check if we have enough
116        let needed = output_total.saturating_add(fee);
117        if input_total < needed {
118            return Err(TxError::InsufficientFunds {
119                needed,
120                available: input_total,
121            });
122        }
123
124        // Build transaction
125        let mut tx = Transaction {
126            version: self.version,
127            inputs: Vec::new(),
128            outputs: self.outputs.clone(),
129            locktime: self.locktime,
130        };
131
132        // Add inputs
133        let mut input_info = Vec::new();
134        for (utxo, pubkey) in &self.inputs {
135            let input = TxInput::new(utxo.txid, utxo.vout);
136            tx.inputs.push(input);
137            input_info.push(InputInfo {
138                utxo: utxo.clone(),
139                pubkey: *pubkey,
140            });
141        }
142
143        // Add change output if needed
144        let change = input_total - output_total - fee;
145        if change > DUST_THRESHOLD_P2WPKH {
146            if let Some(addr) = &self.change_address {
147                let script = address_to_script(addr)?;
148                tx.outputs.push(TxOutput::new(change, script));
149            }
150        }
151
152        Ok(UnsignedTx {
153            tx,
154            input_info,
155            fee,
156        })
157    }
158
159    /// Build transaction with automatic coin selection.
160    pub fn build_with_coin_selection(
161        mut self,
162        utxos: &[Utxo],
163    ) -> Result<UnsignedTx> {
164        let output_total: u64 = self.outputs.iter().map(|o| o.value).sum();
165        
166        // Select coins
167        let (selected, _) = select_coins(utxos, output_total, self.fee_rate)?;
168        
169        // Add selected UTXOs as inputs
170        for utxo in selected {
171            self = self.add_input(utxo);
172        }
173        
174        self.build()
175    }
176}
177
178/// Information about an input for signing.
179#[derive(Debug, Clone)]
180pub struct InputInfo {
181    /// The UTXO being spent
182    pub utxo: Utxo,
183    /// Public key (if provided)
184    pub pubkey: Option<[u8; 33]>,
185}
186
187/// An unsigned transaction ready for signing.
188#[derive(Debug, Clone)]
189pub struct UnsignedTx {
190    /// The transaction
191    pub tx: Transaction,
192    /// Input information for signing
193    pub input_info: Vec<InputInfo>,
194    /// Calculated fee
195    pub fee: u64,
196}
197
198impl UnsignedTx {
199    /// Get the transaction.
200    pub fn transaction(&self) -> &Transaction {
201        &self.tx
202    }
203
204    /// Get the fee.
205    pub fn fee(&self) -> u64 {
206        self.fee
207    }
208}
209
210/// Convert an address string to scriptPubKey.
211fn address_to_script(address: &str) -> Result<Vec<u8>> {
212    // Try P2PKH (starts with 1 or m/n)
213    if address.starts_with('1') || address.starts_with('m') || address.starts_with('n') {
214        // Decode base58check
215        let decoded = bs58::decode(address)
216            .into_vec()
217            .map_err(|e| TxError::InvalidAddress(e.to_string()))?;
218        
219        if decoded.len() != 25 {
220            return Err(TxError::InvalidAddress("Invalid P2PKH address length".to_string()));
221        }
222        
223        let mut pubkey_hash = [0u8; 20];
224        pubkey_hash.copy_from_slice(&decoded[1..21]);
225        
226        Ok(crate::script::build_p2pkh_script(&pubkey_hash))
227    }
228    // Try P2WPKH (starts with bc1q or tb1q)
229    else if address.starts_with("bc1q") || address.starts_with("tb1q") {
230        // Decode bech32
231        let (_, data) = bech32_decode(address)
232            .map_err(TxError::InvalidAddress)?;
233        
234        if data.len() != 20 {
235            return Err(TxError::InvalidAddress("Invalid P2WPKH address".to_string()));
236        }
237        
238        let mut pubkey_hash = [0u8; 20];
239        pubkey_hash.copy_from_slice(&data);
240        
241        Ok(crate::script::build_p2wpkh_script(&pubkey_hash))
242    }
243    else {
244        Err(TxError::InvalidAddress(format!("Unsupported address format: {}", address)))
245    }
246}
247
248/// Simple bech32 decoder (for P2WPKH only).
249fn bech32_decode(address: &str) -> std::result::Result<(String, Vec<u8>), String> {
250    let address = address.to_lowercase();
251    let pos = address.rfind('1').ok_or("No separator found")?;
252    let hrp = &address[..pos];
253    let data_part = &address[pos + 1..];
254    
255    // Decode base32
256    const CHARSET: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
257    let mut values = Vec::new();
258    for c in data_part.chars() {
259        let idx = CHARSET.find(c).ok_or("Invalid character")?;
260        values.push(idx as u8);
261    }
262    
263    // Skip checksum (last 6 values) and version (first value)
264    if values.len() < 7 {
265        return Err("Too short".to_string());
266    }
267    let data_values = &values[1..values.len() - 6];
268    
269    // Convert from 5-bit to 8-bit
270    let mut result = Vec::new();
271    let mut acc = 0u32;
272    let mut bits = 0u32;
273    for &v in data_values {
274        acc = (acc << 5) | (v as u32);
275        bits += 5;
276        while bits >= 8 {
277            bits -= 8;
278            result.push((acc >> bits) as u8);
279            acc &= (1 << bits) - 1;
280        }
281    }
282    
283    Ok((hrp.to_string(), result))
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn make_utxo(value: u64) -> Utxo {
291        Utxo {
292            txid: [0u8; 32],
293            vout: 0,
294            value,
295            script_pubkey: vec![0x76, 0xa9, 0x14], // Partial P2PKH
296            address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa".to_string(),
297        }
298    }
299
300    #[test]
301    fn test_builder_no_inputs() {
302        let result = TxBuilder::new()
303            .add_output(50_000, vec![0x00])
304            .build();
305        assert!(matches!(result, Err(TxError::NoInputs)));
306    }
307
308    #[test]
309    fn test_builder_no_outputs() {
310        let result = TxBuilder::new()
311            .add_input(make_utxo(100_000))
312            .build();
313        assert!(matches!(result, Err(TxError::NoOutputs)));
314    }
315
316    #[test]
317    fn test_builder_dust_output() {
318        let result = TxBuilder::new()
319            .add_input(make_utxo(100_000))
320            .add_output(100, vec![0x00, 0x14]) // Dust
321            .build();
322        assert!(matches!(result, Err(TxError::DustOutput(_))));
323    }
324
325    #[test]
326    fn test_builder_success() {
327        let result = TxBuilder::new()
328            .add_input(make_utxo(100_000))
329            .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])
330            .set_fee_rate(1)
331            .build();
332        
333        assert!(result.is_ok());
334        let unsigned = result.unwrap();
335        assert_eq!(unsigned.tx.inputs.len(), 1);
336        assert_eq!(unsigned.tx.outputs.len(), 1);
337    }
338}