satellite_bitcoin_transactions/lib.rs
1//! Arch Bitcoin transaction helpers.
2//!
3//! This crate offers zero-heap utilities and a strongly-typed builder – [`TransactionBuilder`] –
4//! for composing, fee–tuning and finalising Bitcoin transactions that will be forwarded through
5//! the **Arch** runtime. All helpers are `no_std` friendly (apart from the unavoidable `alloc`
6//! use inside the `bitcoin` dependency) and can therefore be called from on-chain programs that
7//! run inside the Solana BPF VM.
8//!
9//! ## Key Features
10//!
11//! - **Zero-heap allocation**: Uses fixed-size collections for constrained environments
12//! - **Type-safe builder pattern**: Compile-time bounds prevent common mistakes
13//! - **Fee calculation**: Automatic fee estimation and adjustment with mempool ancestry tracking
14//! - **Rune support**: Optional rune transaction support when compiled with `runes` feature
15//! - **UTXO consolidation**: Optional consolidation features for managing fragmented UTXOs
16//! - **BPF compatibility**: Suitable for on-chain programs running in Solana BPF VM
17//!
18//! ## Quick Start
19//!
20//! ```rust,no_run
21//! use satellite_bitcoin_transactions::TransactionBuilder;
22//! use satellite_bitcoin_transactions::fee_rate::FeeRate;
23//!
24//! // Create a builder that can handle up to 8 modified accounts and 4 inputs to sign
25//! let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
26//!
27//! // Add inputs, outputs, and state transitions...
28//! // (See TransactionBuilder documentation for detailed examples)
29//!
30//! // Adjust fees and finalize
31//! let fee_rate = FeeRate::try_from(10.0).unwrap(); // 10 sat/vB
32//! builder.adjust_transaction_to_pay_fees(&fee_rate, None)?;
33//! builder.finalize()?;
34//! # Ok::<(), Box<dyn std::error::Error>>(())
35//! ```
36//!
37//! ## Architecture
38//!
39//! The crate is built around the [`TransactionBuilder`] which maintains:
40//! - A Bitcoin transaction under construction
41//! - Metadata about which program accounts have been modified
42//! - Information about which transaction inputs need signatures
43//! - Running totals for fee calculation and validation
44//!
45//! All data structures use fixed-size collections to ensure zero-heap allocation,
46//! making them suitable for constrained environments like the Solana BPF VM.
47use std::fmt::Debug;
48#[cfg(test)]
49use std::str::FromStr;
50
51use arch_program::rune::RuneAmount;
52use arch_program::{
53 account::AccountInfo, helper::add_state_transition, input_to_sign::InputToSign,
54 program::set_transaction_to_sign, program_error::ProgramError, pubkey::Pubkey, utxo::UtxoMeta,
55};
56use bitcoin::{
57 absolute::LockTime, transaction::Version, OutPoint, ScriptBuf, Sequence, Transaction, TxIn,
58 TxOut, Witness,
59};
60pub use mempool::{AccountMempoolInfo, MempoolData, MempoolDataView, MempoolInfo, TxStatus};
61
62#[cfg(feature = "runes")]
63use ordinals::{Artifact, Runestone};
64
65use satellite_collections::generic::fixed_list_unchecked::FixedRefList;
66use satellite_collections::generic::{fixed_list::FixedList, fixed_set::FixedCapacitySet};
67
68use crate::btc_utxo_holder::BtcUtxoHolder;
69use crate::bytes::txid_to_bytes_big_endian;
70use crate::{
71 calc_fee::{
72 adjust_transaction_to_pay_fees, estimate_final_tx_vsize,
73 estimate_tx_size_with_additional_inputs_outputs,
74 estimate_tx_vsize_with_additional_inputs_outputs,
75 },
76 constants::DUST_LIMIT,
77 error::BitcoinTxError,
78 fee_rate::FeeRate,
79 mempool::generate_mempool_info,
80 utxo_info::UtxoInfo,
81};
82
83#[cfg(feature = "utxo-consolidation")]
84use crate::{consolidation::add_consolidation_utxos, input_calc::ARCH_INPUT_SIZE};
85
86mod arch;
87pub mod btc_utxo_holder;
88pub mod bytes;
89mod calc_fee;
90mod consolidation;
91pub mod constants;
92pub mod error;
93pub mod fee_rate;
94mod find_btc;
95pub mod input_calc;
96mod mempool;
97#[cfg(feature = "serde")]
98mod serde;
99pub mod util;
100pub mod utxo_info;
101#[cfg(feature = "serde")]
102pub mod utxo_info_json;
103
104#[derive(Clone, Debug, Default)]
105/// A zero-copy wrapper for tracking modified program accounts.
106///
107/// `ModifiedAccount` is a lightweight wrapper around [`AccountInfo`] that enables
108/// [`TransactionBuilder`] to track which program accounts have been modified during
109/// transaction construction, without requiring heap allocation.
110///
111/// ## Design
112///
113/// The wrapper stores a borrowed reference to the actual account data, avoiding
114/// copies or allocations. This makes it suitable for use in constrained environments
115/// like the Solana BPF VM where heap allocation is expensive or unavailable.
116///
117/// ## Lifetime Management
118///
119/// The `'a` lifetime parameter ensures that the wrapped account reference remains
120/// valid for the duration of the transaction building process. This is typically
121/// the lifetime of the instruction execution context.
122///
123/// ## Usage
124///
125/// You typically don't create `ModifiedAccount` instances directly. Instead, they
126/// are created automatically by [`TransactionBuilder`] methods like
127/// [`TransactionBuilder::add_state_transition`] and
128/// related helpers.
129///
130/// ## Memory Safety
131///
132/// The default value (created with `Default::default()`) contains `None` and will
133/// panic if accessed via `as_ref()`. This is by design since such instances should
134/// never be exposed outside of internal testing.
135struct ModifiedAccount<'info>(Option<AccountInfo<'info>>);
136
137impl<'info> ModifiedAccount<'info> {
138 #[inline]
139 /// Creates a new [`ModifiedAccount`] from a borrowed [`AccountInfo`].
140 ///
141 /// This is a zero-cost helper used by
142 /// `TransactionBuilder` internals.
143 pub fn new(account: AccountInfo<'info>) -> Self {
144 Self(Some(account))
145 }
146}
147
148impl<'info> AsRef<AccountInfo<'info>> for ModifiedAccount<'info> {
149 fn as_ref(&self) -> &AccountInfo<'info> {
150 self.0.as_ref().expect("ModifiedAccount is None")
151 }
152}
153
154/// Represents potential transaction inputs for size estimation.
155///
156/// This struct is used in "what-if" scenarios where you need to estimate the size
157/// of a transaction before actually adding inputs. It's particularly useful for
158/// fee calculation and UTXO consolidation planning.
159///
160/// ## Fields
161///
162/// - `count`: Number of inputs of this type to add
163/// - `item`: Template input to use for size calculation
164/// - `signer`: Whether these inputs will be signed by a program account
165///
166/// ## Examples
167///
168/// ```rust
169/// # use satellite_bitcoin_transactions::NewPotentialInputAmount;
170/// # use bitcoin::{TxIn, OutPoint, ScriptBuf, Sequence, Witness};
171/// // Estimate adding 3 similar inputs
172/// let potential_inputs = NewPotentialInputAmount {
173/// count: 3,
174/// item: TxIn {
175/// previous_output: OutPoint::null(),
176/// script_sig: ScriptBuf::new(),
177/// sequence: Sequence::MAX,
178/// witness: Witness::new(),
179/// },
180/// signer: true,
181/// };
182/// ```
183pub struct NewPotentialInputAmount {
184 pub count: usize,
185 pub item: TxIn,
186 pub signer: bool,
187}
188
189/// Represents potential transaction outputs for size estimation.
190///
191/// Used in conjunction with [`NewPotentialInputAmount`] to estimate transaction
192/// sizes before actually constructing the outputs. This is essential for accurate
193/// fee calculation and transaction planning.
194///
195/// ## Fields
196///
197/// - `count`: Number of outputs of this type to add
198/// - `item`: Template output to use for size calculation
199///
200/// ## Examples
201///
202/// ```rust
203/// # use satellite_bitcoin_transactions::NewPotentialOutputAmount;
204/// # use bitcoin::{TxOut, Amount, ScriptBuf};
205/// // Estimate adding 2 similar outputs
206/// let potential_outputs = NewPotentialOutputAmount {
207/// count: 2,
208/// item: TxOut {
209/// value: Amount::from_sat(50000),
210/// script_pubkey: ScriptBuf::new(),
211/// },
212/// };
213/// ```
214pub struct NewPotentialOutputAmount {
215 pub count: usize,
216 pub item: TxOut,
217}
218
219/// Container for potential inputs and outputs used in size estimation.
220///
221/// This struct aggregates potential inputs and outputs into a single parameter
222/// for methods like [`TransactionBuilder::estimate_tx_size_with_additional_inputs_outputs`].
223/// It enables comprehensive "what-if" analysis for transaction planning.
224///
225/// ## Usage Patterns
226///
227/// ```rust
228/// # use satellite_bitcoin_transactions::{NewPotentialInputsAndOutputs, NewPotentialInputAmount, NewPotentialOutputAmount};
229/// # use bitcoin::{TxIn, TxOut, OutPoint, ScriptBuf, Sequence, Witness, Amount};
230/// // Planning a transaction with multiple potential changes
231/// let potential_changes = NewPotentialInputsAndOutputs {
232/// inputs: Some(NewPotentialInputAmount {
233/// count: 2,
234/// item: TxIn {
235/// previous_output: OutPoint::null(),
236/// script_sig: ScriptBuf::new(),
237/// sequence: Sequence::MAX,
238/// witness: Witness::new(),
239/// },
240/// signer: true,
241/// }),
242/// outputs: vec![
243/// NewPotentialOutputAmount {
244/// count: 1,
245/// item: TxOut {
246/// value: Amount::from_sat(50000),
247/// script_pubkey: ScriptBuf::new(),
248/// },
249/// },
250/// NewPotentialOutputAmount {
251/// count: 1,
252/// item: TxOut {
253/// value: Amount::from_sat(25000),
254/// script_pubkey: ScriptBuf::new(),
255/// },
256/// },
257/// ],
258/// };
259/// ```
260///
261/// ## See Also
262///
263/// - [`TransactionBuilder::estimate_tx_size_with_additional_inputs_outputs`]
264/// - [`TransactionBuilder::estimate_tx_vsize_with_additional_inputs_outputs`]
265pub struct NewPotentialInputsAndOutputs {
266 pub inputs: Option<NewPotentialInputAmount>,
267 pub outputs: Vec<NewPotentialOutputAmount>,
268}
269
270#[derive(Debug)]
271/// A zero-heap Bitcoin transaction builder for the Arch runtime.
272///
273/// `TransactionBuilder` provides a type-safe, heap-free way to construct Bitcoin transactions
274/// that interact with the Arch runtime. It manages transaction inputs, outputs, state transitions,
275/// and fee calculations while maintaining compatibility with constrained environments like the
276/// Solana BPF VM.
277///
278/// ## Design Philosophy
279///
280/// The builder is designed around three core principles:
281/// - **Zero-heap allocation**: All collections use fixed-size arrays determined at compile time
282/// - **Type safety**: Generic parameters prevent runtime errors by enforcing limits at compile time
283/// - **Arch integration**: Built-in support for Arch-specific concepts like state transitions and account modifications
284///
285/// ## Generic Parameters
286///
287/// - `MAX_MODIFIED_ACCOUNTS`: Maximum number of program accounts that can be modified in a single transaction
288/// - `MAX_INPUTS_TO_SIGN`: Maximum number of transaction inputs that require signatures
289/// - `RuneSet`: Collection type for tracking rune inputs (only used with `runes` feature)
290///
291/// These bounds are enforced at compile time to ensure the builder remains heap-free.
292///
293/// ## Feature Flags
294///
295/// - `runes`: Enables rune transaction support with automatic rune input/output tracking
296/// - `utxo-consolidation`: Enables UTXO consolidation features for managing fragmented UTXOs
297/// - `serde`: Enables serialization support for transaction data
298///
299/// ## Basic Usage
300///
301/// ```rust
302/// use satellite_bitcoin_transactions::TransactionBuilder;
303/// use satellite_bitcoin_transactions::fee_rate::FeeRate;
304///
305/// // Create a builder with capacity for 8 modified accounts and 4 inputs to sign
306/// let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
307///
308/// // The builder starts with an empty version 2 transaction
309/// assert_eq!(builder.transaction.input.len(), 0);
310/// assert_eq!(builder.transaction.output.len(), 0);
311/// assert_eq!(builder.total_btc_input, 0);
312///
313/// // Add inputs and outputs using the builder methods...
314/// # Ok::<(), Box<dyn std::error::Error>>(())
315/// ```
316///
317/// ## Working with State Transitions
318///
319/// State transitions are a core concept in Arch. Use these methods to manage program account updates:
320///
321/// ```rust,no_run
322/// # use satellite_bitcoin_transactions::{TransactionBuilder, SignPolicy};
323/// # use arch_program::account::AccountInfo;
324/// # use arch_program::pubkey::Pubkey;
325/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
326/// # let account: AccountInfo<'static> = unsafe { std::mem::zeroed() };
327/// // Add a state transition for an existing account
328/// builder.add_state_transition(&account, SignPolicy::Managed)?;
329///
330/// // The builder automatically:
331/// // 1. Adds the account to modified_accounts list
332/// // 2. Creates an InputToSign entry
333/// // 3. Updates total_btc_input with DUST_LIMIT
334/// // 4. Adds the state transition to the transaction
335/// # Ok::<(), satellite_bitcoin_transactions::error::BitcoinTxError>(())
336/// ```
337///
338/// ## Adding User Inputs
339///
340/// Add user-controlled UTXOs to the transaction:
341///
342/// ```rust
343/// # use satellite_bitcoin_transactions::TransactionBuilder;
344/// # use satellite_bitcoin_transactions::utxo_info::{UtxoInfo, SingleRuneSet};
345/// # use satellite_bitcoin_transactions::TxStatus;
346/// # use arch_program::pubkey::Pubkey;
347/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
348/// # let utxo: UtxoInfo<SingleRuneSet> = unsafe { std::mem::zeroed() };
349/// # let status = TxStatus::Confirmed;
350/// # let signer = Pubkey::system_program();
351/// // Add a regular input that requires signing
352/// builder.add_tx_input(&utxo, &status, Some(&signer))?;
353///
354/// // For precise control over input order:
355/// builder.insert_tx_input(0, &utxo, &status, Some(&signer))?;
356/// # Ok::<(), satellite_bitcoin_transactions::error::BitcoinTxError>(())
357/// ```
358///
359/// ## Fee Management
360///
361/// The builder provides sophisticated fee management with mempool ancestry tracking:
362///
363/// ```rust,no_run
364/// # use satellite_bitcoin_transactions::TransactionBuilder;
365/// # use satellite_bitcoin_transactions::fee_rate::FeeRate;
366/// # use bitcoin::ScriptBuf;
367/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
368/// # let change_address = ScriptBuf::new();
369/// // Set target fee rate
370/// let fee_rate = FeeRate::try_from(25.0)?; // 25 sat/vB
371///
372/// // Automatically adjust transaction to meet fee requirements
373/// builder.adjust_transaction_to_pay_fees(&fee_rate, Some(change_address))?;
374///
375/// // Validate the effective fee rate (including ancestors)
376/// builder.is_fee_rate_valid(&fee_rate)?;
377///
378/// // Get fee breakdown
379/// let user_fee = builder.get_fee_paid_by_user(&fee_rate);
380/// let total_fee = builder.get_fee_paid()?;
381/// # Ok::<(), Box<dyn std::error::Error>>(())
382/// ```
383///
384/// ## UTXO Selection
385///
386/// Automatically select UTXOs to meet funding requirements:
387///
388/// ```rust,no_run
389/// # use satellite_bitcoin_transactions::TransactionBuilder;
390/// # use satellite_bitcoin_transactions::utxo_info::UtxoInfo;
391/// # use arch_program::pubkey::Pubkey;
392/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
393/// # let utxos: Vec<UtxoInfo<_>> = vec![];
394/// # let program_pubkey = Pubkey::system_program();
395/// // Find UTXOs to cover a specific amount
396/// let amount_needed = 100_000; // 100k sats
397/// let (selected_indices, total_found) = builder.find_btc_in_utxos(
398/// &utxos,
399/// &program_pubkey,
400/// amount_needed
401/// )?;
402///
403/// // The builder automatically selects the most efficient UTXOs
404/// // and adds them to the transaction
405/// # Ok::<(), satellite_bitcoin_transactions::error::BitcoinTxError>(())
406/// ```
407///
408/// ## Rune Support (with `runes` feature)
409///
410/// When compiled with the `runes` feature, the builder automatically tracks rune inputs and outputs:
411///
412/// ```rust
413/// # #[cfg(feature = "runes")]
414/// # {
415/// # use satellite_bitcoin_transactions::TransactionBuilder;
416/// # use satellite_bitcoin_transactions::utxo_info::UtxoInfo;
417/// # use arch_program::rune::RuneAmount;
418/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
419/// # let rune_utxo: UtxoInfo<_> = unsafe { std::mem::zeroed() };
420/// // Rune inputs are automatically tracked when adding UTXOs
421/// // The builder maintains total_rune_inputs and runestone data
422///
423/// // Access rune information
424/// let rune_count = builder.total_rune_inputs.len();
425/// let runestone = &builder.runestone;
426/// # }
427/// ```
428///
429/// ## UTXO Consolidation (with `utxo-consolidation` feature)
430///
431/// Automatically consolidate fragmented UTXOs to reduce transaction sizes:
432///
433/// ```rust,no_run
434/// # #[cfg(feature = "utxo-consolidation")]
435/// # {
436/// # use satellite_bitcoin_transactions::TransactionBuilder;
437/// # use satellite_bitcoin_transactions::fee_rate::FeeRate;
438/// # use satellite_bitcoin_transactions::NewPotentialInputsAndOutputs;
439/// # use satellite_bitcoin_transactions::utxo_info::UtxoInfo;
440/// # use arch_program::pubkey::Pubkey;
441/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
442/// # let pool_pubkey = Pubkey::system_program();
443/// # let fee_rate = FeeRate::try_from(10.0).unwrap();
444/// # let utxos: Vec<UtxoInfo> = vec![];
445/// # let potential_changes = NewPotentialInputsAndOutputs { inputs: None, outputs: vec![] };
446/// // Add consolidation UTXOs to reduce fragmentation
447/// builder.add_consolidation_utxos(
448/// &pool_pubkey,
449/// &fee_rate,
450/// &utxos,
451/// &potential_changes
452/// );
453///
454/// // Get fee breakdown
455/// let program_fee = builder.get_fee_paid_by_program(&fee_rate);
456/// let user_fee = builder.get_fee_paid_by_user(&fee_rate);
457/// # }
458/// ```
459///
460/// ## Size Estimation
461///
462/// Estimate transaction sizes for fee calculation:
463///
464/// ```rust
465/// # use satellite_bitcoin_transactions::TransactionBuilder;
466/// # use satellite_bitcoin_transactions::NewPotentialInputsAndOutputs;
467/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
468/// # let potential_changes = NewPotentialInputsAndOutputs { inputs: None, outputs: vec![] };
469/// // Estimate current transaction size
470/// let current_vsize = builder.estimate_final_tx_vsize();
471///
472/// // Estimate size with additional inputs/outputs
473/// let estimated_vsize = builder.estimate_tx_vsize_with_additional_inputs_outputs(
474/// &potential_changes
475/// );
476/// ```
477///
478/// ## Error Handling
479///
480/// The builder provides detailed error information:
481///
482/// ```rust
483/// # use satellite_bitcoin_transactions::TransactionBuilder;
484/// # use satellite_bitcoin_transactions::error::BitcoinTxError;
485/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
486/// // Capacity limits are enforced at runtime
487/// match builder.inputs_to_sign.len() {
488/// len if len >= 4 => {
489/// // Handle InputToSignListFull error
490/// }
491/// _ => {
492/// // Safe to add more inputs
493/// }
494/// }
495///
496/// // Fee validation errors
497/// match builder.get_fee_paid() {
498/// Ok(fee) => println!("Fee: {} sats", fee),
499/// Err(BitcoinTxError::InsufficientInputAmount) => {
500/// // Handle insufficient input funds
501/// }
502/// Err(e) => {
503/// // Handle other errors
504/// }
505/// }
506/// ```
507///
508/// ## Finalization
509///
510/// Complete the transaction and prepare it for signing:
511///
512/// ```rust
513/// # use satellite_bitcoin_transactions::TransactionBuilder;
514/// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
515/// // After adding all inputs, outputs, and adjusting fees
516/// builder.finalize()?;
517///
518/// // The transaction is now ready for the Arch runtime to collect signatures
519/// // and broadcast to the Bitcoin network
520/// # Ok::<(), arch_program::program_error::ProgramError>(())
521/// ```
522///
523/// ## Performance Considerations
524///
525/// - All operations are O(1) or O(n) where n is bounded by the generic parameters
526/// - No heap allocations occur during normal operation
527/// - Memory usage is deterministic and known at compile time
528/// - Suitable for use in constrained environments like the Solana BPF VM
529///
530/// ## Thread Safety
531///
532/// `TransactionBuilder` is not thread-safe and should not be shared between threads.
533/// Create separate builders for concurrent transaction construction.
534pub struct TransactionBuilder<
535 'info,
536 const MAX_MODIFIED_ACCOUNTS: usize,
537 const MAX_INPUTS_TO_SIGN: usize,
538 RuneSet: FixedCapacitySet<Item = RuneAmount> + Default,
539> {
540 /// This transaction will be broadcast through Arch to indicate a state
541 /// transition in the program
542 pub transaction: Transaction,
543 pub tx_statuses: MempoolInfo,
544
545 /// This tells Arch which accounts have been modified, and thus required
546 /// their data to be saved
547 modified_accounts: FixedList<ModifiedAccount<'info>, MAX_MODIFIED_ACCOUNTS>,
548
549 /// This tells Arch which inputs in [InstructionContext::transaction] still
550 /// need to be signed, along with which key needs to sign each of them
551 pub inputs_to_sign: FixedRefList<InputToSign, MAX_INPUTS_TO_SIGN>,
552
553 pub total_btc_input: u64,
554
555 /// Tracks whether any **non–state-transition** inputs or *any* outputs
556 /// have been added to the transaction via builder helpers.
557 ///
558 /// Once this becomes `true`, no further state transitions may be added
559 /// through [`TransactionBuilder::add_state_transition`] or
560 /// [`TransactionBuilder::insert_state_transition_input`].
561 has_seen_non_state_io_or_output: bool,
562
563 _phantom: std::marker::PhantomData<RuneSet>,
564
565 #[cfg(feature = "runes")]
566 pub total_rune_inputs: RuneSet,
567
568 #[cfg(feature = "runes")]
569 pub runestone: Runestone,
570
571 #[cfg(feature = "utxo-consolidation")]
572 pub total_btc_consolidation_input: u64,
573
574 #[cfg(feature = "utxo-consolidation")]
575 pub extra_tx_size_for_consolidation: usize,
576}
577
578/// Specifies how an added state-transition input should be signed.
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub enum SignPolicy {
581 /// The program (via Arch) manages signing responsibility for this input.
582 ///
583 /// This applies when the signer has either:
584 /// - Already signed the transaction (e.g., fee payer or any other account that has signed)
585 /// - Will be signed by the program itself (e.g., program PDAs)
586 ///
587 /// An `InputToSign` entry is recorded for Arch to handle the signature.
588 Managed,
589 /// A third-party will provide the signature for this input.
590 ///
591 /// This applies to accounts owned by other programs (e.g., via CPI) or signers
592 /// which have not signed this transaction. No `InputToSign` entry is recorded.
593 External,
594}
595
596impl<
597 'info,
598 const MAX_MODIFIED_ACCOUNTS: usize,
599 const MAX_INPUTS_TO_SIGN: usize,
600 RuneSet: FixedCapacitySet<Item = RuneAmount> + Default + Debug,
601 > TransactionBuilder<'info, MAX_MODIFIED_ACCOUNTS, MAX_INPUTS_TO_SIGN, RuneSet>
602{
603 /// Creates a new empty transaction builder.
604 ///
605 /// Initializes a blank builder containing an empty **version 2** Bitcoin transaction with `lock_time = 0`.
606 /// All internal counters and collections start empty, ready for you to populate through the various
607 /// `add_*` and `insert_*` methods.
608 ///
609 /// ## Initial State
610 ///
611 /// - `transaction`: Empty version 2 transaction
612 /// - `total_btc_input`: 0 satoshis
613 /// - `modified_accounts`: Empty fixed-size list
614 /// - `inputs_to_sign`: Empty fixed-size list
615 /// - `tx_statuses`: Default mempool info
616 ///
617 /// ## Typical Workflow
618 ///
619 /// 1. Create builder with `new()`
620 /// 2. Add inputs with `add_tx_input()` or `add_state_transition()`
621 /// 3. Add outputs directly to `builder.transaction.output`
622 /// 4. Adjust fees with `adjust_transaction_to_pay_fees()`
623 /// 5. Finalize with `finalize()`
624 ///
625 /// ## Examples
626 ///
627 /// ```rust
628 /// use satellite_bitcoin_transactions::TransactionBuilder;
629 /// use bitcoin::Transaction;
630 ///
631 /// // Create a builder that can handle up to 8 modified accounts and 4 inputs to sign
632 /// let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
633 ///
634 /// // Verify initial state
635 /// assert_eq!(builder.transaction.input.len(), 0);
636 /// assert_eq!(builder.transaction.output.len(), 0);
637 /// assert_eq!(builder.total_btc_input, 0);
638 /// assert_eq!(builder.inputs_to_sign.len(), 0);
639 /// ```
640 ///
641 /// ## Generic Parameters
642 ///
643 /// Choose your bounds based on your use case:
644 /// - **Small transactions**: `TransactionBuilder<4, 2>` for simple operations
645 /// - **Medium transactions**: `TransactionBuilder<8, 4>` for typical use cases
646 /// - **Large transactions**: `TransactionBuilder<16, 8>` for complex operations
647 ///
648 /// Remember that larger bounds use more stack space but provide more flexibility.
649 #[cfg(not(feature = "runes"))]
650 pub fn new() -> Self {
651 let transaction = Transaction {
652 version: Version::TWO,
653 lock_time: LockTime::ZERO,
654 input: vec![],
655 output: vec![],
656 };
657
658 Self {
659 transaction,
660 tx_statuses: MempoolInfo::default(),
661 modified_accounts: FixedList::new(),
662 inputs_to_sign: FixedRefList::new(),
663 total_btc_input: 0,
664 has_seen_non_state_io_or_output: false,
665
666 #[cfg(feature = "utxo-consolidation")]
667 total_btc_consolidation_input: 0,
668 #[cfg(feature = "utxo-consolidation")]
669 extra_tx_size_for_consolidation: 0,
670 _phantom: std::marker::PhantomData::<RuneSet>,
671 }
672 }
673
674 #[cfg(not(feature = "runes"))]
675 pub fn new_with_transaction<const MAX_UTXOS: usize, const MAX_ACCOUNTS: usize>(
676 transaction: Transaction,
677 mempool_data: &MempoolData<MAX_UTXOS, MAX_ACCOUNTS>,
678 user_utxos: &[UtxoInfo],
679 ) -> Result<Self, BitcoinTxError> {
680 assert_eq!(transaction.input.len(), user_utxos.len(), "TransactionBuilder::replace_transaction: Transaction input length must match user UTXOs length");
681
682 for input in &transaction.input {
683 let previous_output = &input.previous_output;
684 let utxo_meta = UtxoMeta::from_outpoint(previous_output.txid, previous_output.vout);
685 let utxo = user_utxos.iter().find(|utxo| utxo.meta == utxo_meta);
686 if utxo.is_none() {
687 return Err(BitcoinTxError::UtxoNotFoundInUserUtxos);
688 }
689 }
690
691 let tx_statuses = generate_mempool_info(user_utxos, mempool_data);
692 let total_btc_input = user_utxos.iter().map(|u| u.value).sum::<u64>();
693
694 Ok(Self {
695 transaction,
696 tx_statuses,
697 modified_accounts: FixedList::new(),
698 inputs_to_sign: FixedRefList::new(),
699 total_btc_input,
700 has_seen_non_state_io_or_output: false,
701
702 #[cfg(feature = "utxo-consolidation")]
703 total_btc_consolidation_input: 0,
704 #[cfg(feature = "utxo-consolidation")]
705 extra_tx_size_for_consolidation: 0,
706 _phantom: std::marker::PhantomData::<RuneSet>,
707 })
708 }
709
710 /// Creates a new empty transaction builder with rune support.
711 ///
712 /// Initializes a blank builder containing an empty **version 2** Bitcoin transaction with `lock_time = 0`.
713 /// All internal counters and collections start empty, including rune-specific tracking.
714 ///
715 /// ## Initial State
716 ///
717 /// - `transaction`: Empty version 2 transaction
718 /// - `total_btc_input`: 0 satoshis
719 /// - `total_rune_inputs`: Empty rune set
720 /// - `runestone`: Default runestone
721 /// - `modified_accounts`: Empty fixed-size list
722 /// - `inputs_to_sign`: Empty fixed-size list
723 /// - `tx_statuses`: Default mempool info
724 ///
725 /// ## Rune Features
726 ///
727 /// With the `runes` feature enabled, the builder automatically:
728 /// - Tracks rune inputs when adding UTXOs
729 /// - Maintains runestone data for rune operations
730 /// - Handles rune arithmetic and validation
731 ///
732 /// ## Examples
733 ///
734 /// ```rust
735 /// # #[cfg(feature = "runes")]
736 /// # {
737 /// use satellite_bitcoin_transactions::TransactionBuilder;
738 /// use arch_program::rune::RuneAmount;
739 ///
740 /// // Create a builder that can handle up to 8 modified accounts and 4 inputs to sign
741 /// let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
742 ///
743 /// // Verify initial state
744 /// assert_eq!(builder.transaction.input.len(), 0);
745 /// assert_eq!(builder.transaction.output.len(), 0);
746 /// assert_eq!(builder.total_btc_input, 0);
747 /// assert_eq!(builder.total_rune_inputs.len(), 0);
748 /// assert_eq!(builder.inputs_to_sign.len(), 0);
749 /// # }
750 /// ```
751 ///
752 /// ## Generic Parameters
753 ///
754 /// Choose your bounds based on your use case:
755 /// - **Small transactions**: `TransactionBuilder<4, 2, SmallRuneSet>` for simple operations
756 /// - **Medium transactions**: `TransactionBuilder<8, 4, MediumRuneSet>` for typical use cases
757 /// - **Large transactions**: `TransactionBuilder<16, 8, LargeRuneSet>` for complex operations
758 ///
759 /// The `RuneSet` parameter determines how many different rune types can be tracked simultaneously.
760 #[cfg(feature = "runes")]
761 pub fn new() -> Self {
762 let transaction = Transaction {
763 version: Version::TWO,
764 lock_time: LockTime::ZERO,
765 input: vec![],
766 output: vec![],
767 };
768
769 Self {
770 transaction,
771 tx_statuses: MempoolInfo::default(),
772 modified_accounts: FixedList::new(),
773 inputs_to_sign: FixedRefList::new(),
774 total_btc_input: 0,
775 has_seen_non_state_io_or_output: false,
776
777 total_rune_inputs: RuneSet::default(),
778 runestone: Runestone::default(),
779
780 #[cfg(feature = "utxo-consolidation")]
781 total_btc_consolidation_input: 0,
782 #[cfg(feature = "utxo-consolidation")]
783 extra_tx_size_for_consolidation: 0,
784 _phantom: std::marker::PhantomData::<RuneSet>,
785 }
786 }
787
788 #[cfg(feature = "runes")]
789 pub fn new_with_transaction(
790 transaction: Transaction,
791 mempool_data: &impl MempoolDataView,
792 user_utxos: &[UtxoInfo<RuneSet>],
793 ) -> Result<Self, BitcoinTxError> {
794 if transaction.input.len() != user_utxos.len() {
795 return Err(BitcoinTxError::TransactionInputLengthMustMatchUserUtxosLength);
796 }
797
798 let mut total_rune_inputs = RuneSet::default();
799 for input in &transaction.input {
800 let previous_output = &input.previous_output;
801 let utxo_meta = UtxoMeta::from_outpoint(previous_output.txid, previous_output.vout);
802 let utxo = user_utxos.iter().find(|utxo| utxo.meta == utxo_meta);
803 if let Some(utxo) = utxo {
804 for rune in utxo.runes.as_slice() {
805 add_rune_input(&mut total_rune_inputs, *rune)?;
806 }
807 } else {
808 return Err(BitcoinTxError::UtxoNotFoundInUserUtxos);
809 }
810 }
811
812 let tx_statuses = generate_mempool_info(user_utxos, mempool_data);
813 let total_btc_input = user_utxos.iter().map(|u| u.value).sum::<u64>();
814
815 let runestone = match Runestone::decipher(&transaction) {
816 Some(artifact) => match artifact {
817 Artifact::Runestone(runestone) => Ok(runestone),
818 _ => Err(BitcoinTxError::RunestoneDecipherError),
819 },
820 None => Ok(Runestone::default()),
821 }?;
822
823 Ok(Self {
824 transaction,
825 tx_statuses,
826 modified_accounts: FixedList::new(),
827 inputs_to_sign: FixedRefList::new(),
828 total_btc_input,
829 has_seen_non_state_io_or_output: false,
830
831 total_rune_inputs,
832 runestone,
833
834 #[cfg(feature = "utxo-consolidation")]
835 total_btc_consolidation_input: 0,
836 #[cfg(feature = "utxo-consolidation")]
837 extra_tx_size_for_consolidation: 0,
838 _phantom: std::marker::PhantomData::<RuneSet>,
839 })
840 }
841
842 /// Adds a state transition for an existing program account.
843 ///
844 /// This method handles the complete process of adding a state transition to the transaction,
845 /// which is required when updating any program-derived account (PDA) or state account on Arch.
846 ///
847 /// ## What it does
848 ///
849 /// The method performs these operations atomically:
850 /// 1. **Adds signing requirement**: Creates an [`InputToSign`] entry so Arch knows which key must sign the input
851 /// 2. **Adds meta-instruction**: Appends the state transition meta-instruction to the transaction
852 /// 3. **Tracks modification**: Adds the account to the `modified_accounts` list for Arch's state saving
853 /// 4. **Updates input total**: Increments `total_btc_input` by [`constants::DUST_LIMIT`] (546 sats)
854 ///
855 /// ## When to use
856 ///
857 /// Use this method when you need to:
858 /// - Update an existing program account
859 /// - Modify state stored in a PDA
860 /// - Perform any operation that changes account data
861 ///
862 /// ## Account Requirements
863 ///
864 /// The account must:
865 /// - Have a valid UTXO backing it on-chain
866 /// - Be owned by a program that you have authority to modify
867 /// - Have exactly [`constants::DUST_LIMIT`] satoshis in its UTXO
868 ///
869 /// ## Examples
870 ///
871 /// ```rust,no_run
872 /// # use satellite_bitcoin_transactions::{TransactionBuilder, SignPolicy};
873 /// # use arch_program::account::AccountInfo;
874 /// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
875 /// # let account: AccountInfo<'static> = unsafe { std::mem::zeroed() };
876 /// // Add a state transition for an existing liquidity pool account
877 /// builder.add_state_transition(&account, SignPolicy::Managed)?;
878 ///
879 /// // The builder now knows:
880 /// // - This account will be modified
881 /// // - The account's key must sign the transaction
882 /// // - 546 sats are consumed from the account's UTXO
883 /// # Ok::<(), satellite_bitcoin_transactions::error::BitcoinTxError>(())
884 /// ```
885 ///
886 /// ## Error Handling
887 ///
888 /// Returns [`BitcoinTxError::InputToSignListFull`] if the builder has reached its
889 /// `MAX_INPUTS_TO_SIGN` limit, or [`BitcoinTxError::ModifiedAccountListFull`] if
890 /// the `MAX_MODIFIED_ACCOUNTS` limit is exceeded.
891 ///
892 /// ## See Also
893 ///
894 /// - [`Self::insert_state_transition_input`] for position-specific insertions
895 pub fn add_state_transition(
896 &mut self,
897 account: &AccountInfo<'info>,
898 policy: SignPolicy,
899 ) -> Result<u32, BitcoinTxError> {
900 if self.has_seen_non_state_io_or_output {
901 return Err(BitcoinTxError::InvalidStateTransitionOrdering);
902 }
903
904 let new_input_index = self.transaction.input.len() as u32;
905 if let SignPolicy::Managed = policy {
906 self.inputs_to_sign
907 .push(InputToSign {
908 index: new_input_index,
909 signer: account.key.clone(),
910 })
911 .map_err(|_| BitcoinTxError::InputToSignListFull)?;
912 }
913
914 self.modified_accounts
915 .push(ModifiedAccount::new(account.clone()))
916 .map_err(|_| BitcoinTxError::ModifiedAccountListFull)?;
917
918 let utxo_value = add_state_transition(&mut self.transaction, account)
919 .map_err(|_| BitcoinTxError::FailedStateTransition)?;
920 self.total_btc_input += utxo_value;
921
922 Ok(new_input_index)
923 }
924
925 /// Inserts an **existing state‐transition input** at the given `tx_index` keeping all
926 /// internal bookkeeping consistent.
927 ///
928 /// Use this when the input *order matters* and you need a state-transition (program
929 /// account) input to appear in a specific position. The function updates
930 /// [`TransactionBuilder::inputs_to_sign`] indices, tracks the modified account and bumps
931 /// [`TransactionBuilder::total_btc_input`].
932 pub fn insert_state_transition_input(
933 &mut self,
934 tx_index: usize,
935 account: &AccountInfo<'info>,
936 policy: SignPolicy,
937 ) -> Result<(), BitcoinTxError> {
938 if self.has_seen_non_state_io_or_output {
939 return Err(BitcoinTxError::InvalidStateTransitionOrdering);
940 }
941
942 let txid = account.utxo.to_txid();
943 let utxo_outpoint = OutPoint {
944 txid,
945 vout: account.utxo.vout(),
946 };
947
948 self.transaction.input.insert(
949 tx_index,
950 TxIn {
951 previous_output: utxo_outpoint,
952 script_sig: ScriptBuf::new(),
953 sequence: Sequence::MAX,
954 witness: Witness::new(),
955 },
956 );
957
958 let tx_index_u32 = tx_index as u32;
959 for input in self.inputs_to_sign.iter_mut() {
960 if input.index >= tx_index_u32 {
961 input.index += 1;
962 }
963 }
964
965 if let SignPolicy::Managed = policy {
966 self.inputs_to_sign
967 .push(InputToSign {
968 index: tx_index_u32,
969 signer: account.key.clone(),
970 })
971 .map_err(|_| BitcoinTxError::InputToSignListFull)?;
972 }
973
974 self.modified_accounts
975 .push(ModifiedAccount::new(account.clone()))
976 .map_err(|_| BitcoinTxError::ModifiedAccountListFull)?;
977
978 // UTXO accounts always have dust limit amount.
979 self.total_btc_input += DUST_LIMIT;
980
981 Ok(())
982 }
983
984 /// Adds a regular input owned by `signer`.
985 ///
986 /// Besides pushing the `TxIn` into the underlying `transaction`, this helper:
987 /// * Records mempool ancestry via [`TransactionBuilder::add_tx_status`].
988 /// * Adds an [`InputToSign`].
989 /// * Updates `total_btc_input` (and `total_rune_input` when compiled with the `runes` feature).
990 pub fn add_tx_input<RS>(
991 &mut self,
992 utxo: &UtxoInfo<RS>,
993 status: &TxStatus,
994 signer: Option<&Pubkey>,
995 ) -> Result<(), BitcoinTxError>
996 where
997 RS: FixedCapacitySet<Item = RuneAmount>,
998 {
999 self.has_seen_non_state_io_or_output = true;
1000
1001 if let Some(signer) = signer {
1002 self.inputs_to_sign
1003 .push(InputToSign {
1004 index: self.transaction.input.len() as u32,
1005 signer: *signer,
1006 })
1007 .map_err(|_| BitcoinTxError::InputToSignListFull)?;
1008 }
1009
1010 let outpoint = utxo.meta.to_outpoint();
1011
1012 self.add_tx_status(utxo, &status);
1013
1014 self.transaction.input.push(TxIn {
1015 previous_output: outpoint,
1016 script_sig: ScriptBuf::new(),
1017 sequence: Sequence::MAX,
1018 witness: Witness::new(),
1019 });
1020
1021 self.total_btc_input += utxo.value;
1022
1023 #[cfg(feature = "runes")]
1024 {
1025 for rune in utxo.runes.as_slice() {
1026 self.add_rune_input(*rune)?;
1027 }
1028 }
1029
1030 Ok(())
1031 }
1032
1033 /// Appends a **user-supplied** [`TxIn`] (already built elsewhere) while still tracking the
1034 /// UTXO ancestry for fee-rate purposes.
1035 pub fn add_user_tx_input<RS>(
1036 &mut self,
1037 utxo: &UtxoInfo<RS>,
1038 status: &TxStatus,
1039 tx_in: TxIn,
1040 ) -> Result<(), BitcoinTxError>
1041 where
1042 RS: FixedCapacitySet<Item = RuneAmount>,
1043 {
1044 self.has_seen_non_state_io_or_output = true;
1045
1046 self.add_tx_status(utxo, status);
1047
1048 self.transaction.input.push(tx_in);
1049
1050 self.total_btc_input += utxo.value;
1051
1052 #[cfg(feature = "runes")]
1053 {
1054 for rune in utxo.runes.as_slice() {
1055 self.add_rune_input(*rune)?;
1056 }
1057 }
1058
1059 Ok(())
1060 }
1061
1062 /// Inserts a **regular** (non-state–account) [`TxIn`] at the given position `tx_index`.
1063 ///
1064 /// Besides pushing the new input into [`TransactionBuilder::transaction`], this helper keeps
1065 /// all *internal bookkeeping* consistent:
1066 ///
1067 /// 1. Records the mempool ancestry for fee-rate calculations via [`Self::add_tx_status`].
1068 /// 2. Shifts the `index` of every existing [`arch_program::input_to_sign::InputToSign`] that
1069 /// appears **at or after** `tx_index` so their indices continue to match the underlying
1070 /// transaction after the insertion.
1071 /// 3. Pushes a fresh [`InputToSign`] for `signer` so Arch knows which key must later provide
1072 /// a witness for the inserted input.
1073 /// 4. Bumps [`Self::total_btc_input`] (and `total_rune_input` when compiled with the `runes`
1074 /// feature) by the value of `utxo`.
1075 ///
1076 /// Use this when the *order* of inputs matters – for example when signing with PSBTs that
1077 /// expect user inputs to appear before program-generated ones.
1078 ///
1079 /// # Parameters
1080 /// * `tx_index` – zero-based index where the input should be inserted.
1081 /// * `utxo` – metadata of the UTXO being spent.
1082 /// * `status` – mempool status of `utxo`; contributes to ancestor fee/size tracking.
1083 /// * `signer` – optional public key that will sign the input.
1084 pub fn insert_tx_input<RS>(
1085 &mut self,
1086 tx_index: usize,
1087 utxo: &UtxoInfo<RS>,
1088 status: &TxStatus,
1089 signer: Option<&Pubkey>,
1090 ) -> Result<(), BitcoinTxError>
1091 where
1092 RS: FixedCapacitySet<Item = RuneAmount>,
1093 {
1094 self.has_seen_non_state_io_or_output = true;
1095
1096 let outpoint = utxo.meta.to_outpoint();
1097
1098 self.add_tx_status(utxo, status);
1099
1100 self.transaction.input.insert(
1101 tx_index,
1102 TxIn {
1103 previous_output: outpoint,
1104 script_sig: ScriptBuf::new(),
1105 sequence: Sequence::MAX,
1106 witness: Witness::new(),
1107 },
1108 );
1109
1110 // More efficient update of indices
1111 let tx_index_u32 = tx_index as u32;
1112 for input in self.inputs_to_sign.iter_mut() {
1113 if input.index >= tx_index_u32 {
1114 input.index += 1;
1115 }
1116 }
1117
1118 if let Some(signer) = signer {
1119 self.inputs_to_sign
1120 .push(InputToSign {
1121 index: tx_index_u32,
1122 signer: *signer,
1123 })
1124 .map_err(|_| BitcoinTxError::InputToSignListFull)?;
1125 }
1126
1127 self.total_btc_input += utxo.value;
1128
1129 #[cfg(feature = "runes")]
1130 {
1131 for rune in utxo.runes.as_slice() {
1132 self.add_rune_input(*rune)?;
1133 }
1134 }
1135
1136 Ok(())
1137 }
1138
1139 /// Inserts a **pre-constructed** [`TxIn`] – built elsewhere – at the specified `tx_index`.
1140 ///
1141 /// The function behaves similarly to [`Self::insert_tx_input`] but **does not** create a new
1142 /// [`InputToSign`], as the caller may already have handled signature tracking. It still:
1143 ///
1144 /// * Accounts for the input's mempool ancestry using [`Self::add_tx_status`].
1145 /// * Shifts the indices of all existing [`InputToSign`] that come after `tx_index` so they
1146 /// remain correct.
1147 /// * Updates BTC / rune running totals.
1148 ///
1149 /// This is handy when you have a non-standard script or any other reason to fully craft the
1150 /// `TxIn` outside of the builder but still need to place it at a precise position inside the
1151 /// transaction.
1152 ///
1153 /// # Parameters
1154 /// * `tx_index` – position where `tx_in` should be inserted.
1155 /// * `utxo` – the UTXO consumed by `tx_in`.
1156 /// * `status` – mempool status of `utxo`.
1157 /// * `tx_in` – ready-made transaction input (will be cloned).
1158 pub fn insert_user_tx_input<RS>(
1159 &mut self,
1160 tx_index: usize,
1161 utxo: &UtxoInfo<RS>,
1162 status: &TxStatus,
1163 tx_in: &TxIn,
1164 ) -> Result<(), BitcoinTxError>
1165 where
1166 RS: FixedCapacitySet<Item = RuneAmount>,
1167 {
1168 self.has_seen_non_state_io_or_output = true;
1169
1170 self.add_tx_status(utxo, status);
1171
1172 self.transaction.input.insert(tx_index, tx_in.clone());
1173
1174 // More efficient update of indices
1175 let tx_index_u32 = tx_index as u32;
1176 for input in self.inputs_to_sign.iter_mut() {
1177 if input.index >= tx_index_u32 {
1178 input.index += 1;
1179 }
1180 }
1181
1182 self.total_btc_input += utxo.value;
1183
1184 #[cfg(feature = "runes")]
1185 {
1186 for rune in utxo.runes.as_slice() {
1187 self.add_rune_input(*rune)?;
1188 }
1189 }
1190
1191 Ok(())
1192 }
1193
1194 /// Greedily selects UTXOs until at least `amount` satoshis are gathered.
1195 ///
1196 /// Selection strategy:
1197 /// * With the `utxo-consolidation` feature **enabled**: prefer UTXOs **without** the
1198 /// `needs_consolidation` flag, then sort by descending value.
1199 /// * Without the feature: simply sort by descending value.
1200 ///
1201 /// Returns the **indices** of the chosen items inside the original slice plus the total value
1202 /// selected.
1203 ///
1204 /// find_btc_in_utxos has been moved to the `find_btc` module for clarity.
1205
1206 #[cfg(feature = "utxo-consolidation")]
1207 fn set_consolidation_tracking(&mut self, consolidation_amount: u64, extra_tx_size: usize) {
1208 self.total_btc_consolidation_input += consolidation_amount;
1209 self.extra_tx_size_for_consolidation += extra_tx_size;
1210 }
1211
1212 #[cfg(not(feature = "utxo-consolidation"))]
1213 fn set_consolidation_tracking(&mut self, _consolidation_amount: u64, _extra_tx_size: usize) {}
1214
1215 // btc selection helpers moved into `find_btc` module
1216
1217 /// Greedily selects UTXOs until at least `amount` satoshis are gathered.
1218 ///
1219 /// Selection strategy:
1220 /// * With the `utxo-consolidation` feature **enabled**: prefer UTXOs **without** the
1221 /// `needs_consolidation` flag, then sort by descending value.
1222 /// * Without the feature: simply sort by descending value.
1223 ///
1224 /// Returns the **indices** of the chosen items inside the original slice plus the total value
1225 /// selected.
1226 ///
1227 /// # Errors
1228 /// * [`BitcoinTxError::NotEnoughBtcInPool`] – not enough value in `utxos` to satisfy `amount`.
1229 /// find_btc_in_utxos_from_holder has been moved to the `find_btc` module for clarity.
1230
1231 /// Automatically adjusts the transaction to meet the target fee rate.
1232 ///
1233 /// This method optimizes the transaction's fee structure by analyzing the current input/output
1234 /// balance and adjusting outputs to achieve the desired fee rate. It handles both overpayment
1235 /// (creating change) and underpayment (reducing outputs) scenarios.
1236 ///
1237 /// ## How it works
1238 ///
1239 /// The method evaluates the current transaction and:
1240 /// 1. **Calculates required fee**: Based on transaction size and target fee rate
1241 /// 2. **Handles excess funds**: Creates or increases change output if inputs exceed requirements
1242 /// 3. **Handles insufficient funds**: Reduces change output or returns error if impossible
1243 /// 4. **Considers ancestors**: Accounts for mempool ancestry when calculating effective fee rate
1244 ///
1245 /// ## Change Output Behavior
1246 ///
1247 /// - **`address_to_send_remaining_btc = Some(address)`**: Creates new change output or increases existing one
1248 /// - **`address_to_send_remaining_btc = None`**: Only adjusts existing outputs, never creates new ones
1249 ///
1250 /// ## Examples
1251 ///
1252 /// ```rust,no_run
1253 /// # use satellite_bitcoin_transactions::TransactionBuilder;
1254 /// # use satellite_bitcoin_transactions::fee_rate::FeeRate;
1255 /// # use bitcoin::ScriptBuf;
1256 /// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
1257 /// // Set target fee rate (25 sat/vB)
1258 /// let fee_rate = FeeRate::try_from(25.0)?;
1259 ///
1260 /// // Create change output for excess funds
1261 /// let change_address = ScriptBuf::new(); // Your change address
1262 /// builder.adjust_transaction_to_pay_fees(&fee_rate, Some(change_address))?;
1263 ///
1264 /// // Or adjust without creating change (only reduce existing outputs)
1265 /// builder.adjust_transaction_to_pay_fees(&fee_rate, None)?;
1266 /// # Ok::<(), Box<dyn std::error::Error>>(())
1267 /// ```
1268 ///
1269 /// ## Fee Calculation Details
1270 ///
1271 /// The method considers:
1272 /// - **Transaction size**: Estimated final size including witness data
1273 /// - **Input signatures**: Size overhead for each required signature
1274 /// - **Mempool ancestry**: Fees and sizes of unconfirmed parent transactions
1275 /// - **Consolidation**: Extra size from UTXO consolidation (if enabled)
1276 ///
1277 /// ## Error Handling
1278 ///
1279 /// Returns an error if:
1280 /// - Insufficient funds to cover minimum fee requirements
1281 /// - Cannot reduce outputs enough to meet fee rate
1282 /// - Transaction would exceed size limits
1283 /// - Fee rate calculation fails
1284 ///
1285 /// ## Best Practices
1286 ///
1287 /// ```rust,no_run
1288 /// # use satellite_bitcoin_transactions::TransactionBuilder;
1289 /// # use satellite_bitcoin_transactions::fee_rate::FeeRate;
1290 /// # use bitcoin::ScriptBuf;
1291 /// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
1292 /// # let change_address = ScriptBuf::new();
1293 /// // Always validate fee rate after adjustment
1294 /// let fee_rate = FeeRate::try_from(15.0)?;
1295 /// builder.adjust_transaction_to_pay_fees(&fee_rate, Some(change_address))?;
1296 ///
1297 /// // Verify the final fee rate meets requirements
1298 /// builder.is_fee_rate_valid(&fee_rate)?;
1299 ///
1300 /// // Check final fee amount
1301 /// let final_fee = builder.get_fee_paid()?;
1302 /// println!("Final fee: {} sats", final_fee);
1303 /// # Ok::<(), Box<dyn std::error::Error>>(())
1304 /// ```
1305 ///
1306 /// ## See Also
1307 ///
1308 /// - [`Self::is_fee_rate_valid`] for validating the resulting fee rate
1309 /// - [`Self::get_fee_paid`] for checking the final fee amount
1310 /// - [`Self::get_fee_paid_by_user`] for user-specific fee calculation
1311 pub fn adjust_transaction_to_pay_fees(
1312 &mut self,
1313 fee_rate: &FeeRate,
1314 address_to_send_remaining_btc: Option<ScriptBuf>,
1315 ) -> Result<(), BitcoinTxError> {
1316 // This helper may create or adjust outputs; once called we are
1317 // definitively past the state-transition-only phase.
1318 self.has_seen_non_state_io_or_output = true;
1319
1320 adjust_transaction_to_pay_fees(
1321 &mut self.transaction,
1322 &self.tx_statuses,
1323 self.total_btc_input,
1324 address_to_send_remaining_btc,
1325 fee_rate,
1326 )
1327 }
1328
1329 /// Attempts to **sweep** pool-owned UTXOs marked for consolidation into the current
1330 /// transaction.
1331 ///
1332 /// This helper is only available when the `utxo-consolidation` feature is enabled. It acts as
1333 /// a thin wrapper around [`crate::consolidation::add_consolidation_utxos`], forwarding the
1334 /// relevant context from the builder and then updating the builder's running totals so that
1335 /// fee-calculation logic is aware of the extra inputs.
1336 ///
1337 /// The consolidated inputs are signed by `pool_pubkey`. Only UTXOs whose
1338 /// `needs_consolidation` value is **greater than or equal to** `fee_rate` are considered. The
1339 /// function stops adding inputs as soon as the draft transaction would exceed
1340 /// [`arch_program::MAX_BTC_TX_SIZE`].
1341 ///
1342 /// After execution the following builder fields are updated:
1343 /// * [`Self::total_btc_input`]
1344 /// * [`Self::total_btc_consolidation_input`]
1345 /// * [`Self::extra_tx_size_for_consolidation`]
1346 ///
1347 /// # Parameters
1348 /// * `pool_pubkey` – public key of the liquidity-pool program (signer of consolidation inputs).
1349 /// * `fee_rate` – current mempool fee-rate used to decide which UTXOs are worth consolidating.
1350 /// * `pool_shard_btc_utxos` – slice with the candidate pool UTXOs.
1351 /// * `new_potential_inputs_and_outputs` – hypothetical inputs/outputs the caller *may* add
1352 /// later; needed to keep size estimations accurate.
1353 #[cfg(feature = "utxo-consolidation")]
1354 pub fn add_consolidation_utxos<BtcHolder: BtcUtxoHolder>(
1355 &mut self,
1356 pool_pubkey: &Pubkey,
1357 fee_rate: &FeeRate,
1358 pool_shard_btc_utxos: &[BtcHolder],
1359 new_potential_inputs_and_outputs: &NewPotentialInputsAndOutputs,
1360 ) {
1361 // Consolidation always introduces additional non-state inputs.
1362 self.has_seen_non_state_io_or_output = true;
1363
1364 let (total_consolidation_input_amount, extra_tx_size) = add_consolidation_utxos(
1365 &mut self.transaction,
1366 &mut self.tx_statuses,
1367 &mut self.inputs_to_sign,
1368 pool_pubkey,
1369 pool_shard_btc_utxos,
1370 fee_rate,
1371 new_potential_inputs_and_outputs,
1372 ARCH_INPUT_SIZE,
1373 );
1374
1375 self.total_btc_input += total_consolidation_input_amount;
1376 self.set_consolidation_tracking(total_consolidation_input_amount, extra_tx_size);
1377 }
1378
1379 #[cfg(feature = "utxo-consolidation")]
1380 pub fn get_fee_paid_by_program(&self, fee_rate: &FeeRate) -> u64 {
1381 fee_rate.fee(self.extra_tx_size_for_consolidation).to_sat()
1382 }
1383
1384 pub fn get_fee_paid_by_user(&mut self, fee_rate: &FeeRate) -> Result<u64, BitcoinTxError> {
1385 let tx_size = self.estimate_final_tx_vsize()?;
1386
1387 let tx_size_to_be_paid_by_user = {
1388 #[cfg(feature = "utxo-consolidation")]
1389 {
1390 tx_size - self.extra_tx_size_for_consolidation
1391 }
1392 #[cfg(not(feature = "utxo-consolidation"))]
1393 {
1394 tx_size
1395 }
1396 };
1397
1398 Ok(fee_rate.fee(tx_size_to_be_paid_by_user).to_sat())
1399 }
1400
1401 pub fn estimate_final_tx_vsize(&mut self) -> Result<usize, BitcoinTxError> {
1402 estimate_final_tx_vsize(&mut self.transaction)
1403 }
1404
1405 /// Returns the *weight* (in bytes) the transaction would have **if** the draft
1406 /// `new_potential_inputs_and_outputs` were added.
1407 ///
1408 /// Helpful during fee-bumping logic when you need to know "how much bigger will the TX get
1409 /// if I add N more inputs/outputs?".
1410 pub fn estimate_tx_size_with_additional_inputs_outputs(
1411 &mut self,
1412 new_potential_inputs_and_outputs: &NewPotentialInputsAndOutputs,
1413 ) -> Result<usize, BitcoinTxError> {
1414 Ok(estimate_tx_size_with_additional_inputs_outputs(
1415 &mut self.transaction,
1416 &self.inputs_to_sign,
1417 new_potential_inputs_and_outputs,
1418 )?)
1419 }
1420
1421 /// Same as [`Self::estimate_tx_size_with_additional_inputs_outputs`] but returns **vsize**
1422 /// instead of raw size.
1423 pub fn estimate_tx_vsize_with_additional_inputs_outputs(
1424 &mut self,
1425 new_potential_inputs_and_outputs: &NewPotentialInputsAndOutputs,
1426 ) -> Result<usize, BitcoinTxError> {
1427 Ok(estimate_tx_vsize_with_additional_inputs_outputs(
1428 &mut self.transaction,
1429 &mut self.inputs_to_sign,
1430 new_potential_inputs_and_outputs,
1431 )?)
1432 }
1433
1434 /// Returns the **aggregate mempool size (bytes) and fees (sats)** of all ancestor
1435 /// transactions referenced by *pending* inputs.
1436 pub fn get_ancestors_totals(&self) -> Result<(usize, u64), BitcoinTxError> {
1437 Ok((
1438 self.tx_statuses.total_size as usize,
1439 self.tx_statuses.total_fee,
1440 ))
1441 }
1442
1443 /// Calculates the fee currently paid by the partially-built transaction (`inputs − outputs`).
1444 ///
1445 /// Fails with [`BitcoinTxError::InsufficientInputAmount`] if outputs exceed inputs.
1446 pub fn get_fee_paid(&self) -> Result<u64, BitcoinTxError> {
1447 let output_amount = self
1448 .transaction
1449 .output
1450 .iter()
1451 .map(|output| output.value.to_sat())
1452 .sum::<u64>();
1453
1454 let fee_paid = self
1455 .total_btc_input
1456 .checked_sub(output_amount)
1457 .ok_or(BitcoinTxError::InsufficientInputAmount)?;
1458
1459 Ok(fee_paid)
1460 }
1461
1462 /// Checks that the *effective* fee-rate (including ancestors) is at least `fee_rate`.
1463 ///
1464 /// Returns an error when the calculated rate is below the target.
1465 pub fn is_fee_rate_valid(&mut self, fee_rate: &FeeRate) -> Result<(), BitcoinTxError> {
1466 // Transaction by itself should have a valid fee
1467 let fee_paid = self.get_fee_paid()?;
1468 let tx_size = self.estimate_final_tx_vsize()?;
1469
1470 let real_fee_rate = FeeRate::try_from(fee_paid as f64 / tx_size as f64)
1471 .map_err(|_| BitcoinTxError::InvalidFeeRateTooLow)?;
1472
1473 if real_fee_rate.n() < fee_rate.n() {
1474 return Err(BitcoinTxError::InvalidFeeRateTooLow);
1475 }
1476
1477 // But also with ancestors.
1478 let (total_size_of_pending_utxos, total_fee_of_pending_utxos) =
1479 self.get_ancestors_totals()?;
1480
1481 let fee_paid_with_ancestors = fee_paid
1482 .checked_add(total_fee_of_pending_utxos)
1483 .ok_or(BitcoinTxError::InsufficientInputAmount)?;
1484
1485 let tx_size_with_ancestors = tx_size + total_size_of_pending_utxos;
1486
1487 let real_fee_rate_with_ancestors =
1488 FeeRate::try_from(fee_paid_with_ancestors as f64 / tx_size_with_ancestors as f64)
1489 .map_err(|_| BitcoinTxError::InvalidFeeRateTooLow)?;
1490
1491 if real_fee_rate_with_ancestors.n() < fee_rate.n() {
1492 return Err(BitcoinTxError::InvalidFeeRateTooLow);
1493 }
1494
1495 Ok(())
1496 }
1497
1498 /// Returns a slice of transaction inputs that are not state transitions.
1499 ///
1500 /// State transitions are always at the beginning of the transaction and correspond
1501 /// to entries in `modified_accounts`. This method returns all inputs after the
1502 /// state transition inputs.
1503 ///
1504 /// ## Returns
1505 ///
1506 /// A slice of [`TxIn`] containing only non-state-transition inputs. Returns an
1507 /// empty slice if all inputs are state transitions or if there are no inputs.
1508 ///
1509 /// ## Examples
1510 ///
1511 /// ```rust,no_run
1512 /// # use satellite_bitcoin_transactions::TransactionBuilder;
1513 /// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
1514 /// // After adding state transitions and regular inputs...
1515 /// let non_state_transition_inputs = builder.get_non_state_transition_inputs();
1516 ///
1517 /// // Process only non-state-transition inputs
1518 /// for input in non_state_transition_inputs {
1519 /// // Handle regular UTXO inputs
1520 /// }
1521 /// ```
1522 pub fn get_non_state_transition_inputs(&self) -> &[TxIn] {
1523 let state_transition_count = self.modified_accounts.len();
1524 &self.transaction.input[state_transition_count..]
1525 }
1526
1527 /// Finalizes the transaction and prepares it for signing by the Arch runtime.
1528 ///
1529 /// This method completes the transaction building process by transferring the constructed
1530 /// transaction and all associated metadata to the Arch runtime. Once called, the transaction
1531 /// is ready for the signature collection phase.
1532 ///
1533 /// ## What it does
1534 ///
1535 /// The method performs these final steps:
1536 /// 1. **Transfers ownership**: Passes the transaction to the Arch runtime
1537 /// 2. **Provides metadata**: Includes modified accounts and signing requirements
1538 /// 3. **Enables signing**: Makes the transaction available for signature collection
1539 /// 4. **Prepares broadcast**: Sets up the transaction for network submission
1540 ///
1541 /// ## Important Notes
1542 ///
1543 /// - **No further changes**: After calling `finalize()`, the builder should not be modified
1544 /// - **Not broadcasting**: This method does NOT broadcast the transaction to the network
1545 /// - **Signing phase**: The transaction enters the signing phase, handled by Arch runtime
1546 /// - **State consistency**: All modified accounts and inputs must be properly configured
1547 ///
1548 /// ## Prerequisites
1549 ///
1550 /// Before calling `finalize()`, ensure:
1551 /// - All required inputs have been added
1552 /// - All outputs have been configured
1553 /// - Fees have been adjusted with [`Self::adjust_transaction_to_pay_fees`]
1554 /// - Fee rate has been validated with [`Self::is_fee_rate_valid`]
1555 ///
1556 /// ## Transaction Lifecycle
1557 ///
1558 /// ```text
1559 /// 1. TransactionBuilder::new() ← Create builder
1560 /// 2. Add inputs/outputs ← Populate transaction
1561 /// 3. adjust_transaction_to_pay_fees() ← Set correct fees
1562 /// 4. finalize() ← Prepare for signing
1563 /// 5. [Arch runtime signs] ← Automatic signing
1564 /// 6. [Arch runtime broadcasts] ← Network submission
1565 /// ```
1566 ///
1567 /// ## Examples
1568 ///
1569 /// ```rust,no_run
1570 /// # use satellite_bitcoin_transactions::TransactionBuilder;
1571 /// # use satellite_bitcoin_transactions::fee_rate::FeeRate;
1572 /// # use bitcoin::ScriptBuf;
1573 /// # let mut builder: TransactionBuilder<8, 4, satellite_bitcoin_transactions::utxo_info::SingleRuneSet> = TransactionBuilder::new();
1574 /// // After building your transaction...
1575 ///
1576 /// // 1. Adjust fees
1577 /// let fee_rate = FeeRate::try_from(20.0)?;
1578 /// let change_address = ScriptBuf::new();
1579 /// builder.adjust_transaction_to_pay_fees(&fee_rate, Some(change_address))?;
1580 ///
1581 /// // 2. Validate fee rate
1582 /// builder.is_fee_rate_valid(&fee_rate)?;
1583 ///
1584 /// // 3. Finalize and hand over to Arch
1585 /// builder.finalize()?;
1586 ///
1587 /// // Transaction is now ready for signing and broadcast
1588 /// # Ok::<(), Box<dyn std::error::Error>>(())
1589 /// ```
1590 ///
1591 /// ## Error Handling
1592 ///
1593 /// Returns [`ProgramError`] if:
1594 /// - The transaction data is invalid
1595 /// - Required metadata is missing
1596 /// - The Arch runtime cannot accept the transaction
1597 /// - Internal state is inconsistent
1598 ///
1599 /// ## See Also
1600 ///
1601 /// - [`Self::adjust_transaction_to_pay_fees`] for fee adjustment
1602 /// - [`Self::is_fee_rate_valid`] for fee validation
1603 /// - [`arch_program::program::set_transaction_to_sign`] for the underlying mechanism
1604 pub fn finalize(&mut self) -> Result<(), ProgramError> {
1605 set_transaction_to_sign(
1606 self.modified_accounts.as_slice(),
1607 &self.transaction,
1608 self.inputs_to_sign.as_slice(),
1609 )?;
1610
1611 Ok(())
1612 }
1613
1614 fn add_tx_status<RS>(&mut self, utxo: &UtxoInfo<RS>, status: &TxStatus)
1615 where
1616 RS: FixedCapacitySet<Item = RuneAmount>,
1617 {
1618 // Check if we have not added this txid yet.
1619 for input in &self.transaction.input {
1620 let input_txid = txid_to_bytes_big_endian(&input.previous_output.txid);
1621 if input_txid == utxo.meta.txid_big_endian() {
1622 return;
1623 }
1624 }
1625
1626 match status {
1627 TxStatus::Pending(info) => {
1628 self.tx_statuses.total_fee += info.total_fee;
1629 self.tx_statuses.total_size += info.total_size;
1630 }
1631 TxStatus::Confirmed => {}
1632 }
1633 }
1634
1635 #[cfg(feature = "runes")]
1636 fn add_rune_input(&mut self, rune: RuneAmount) -> Result<(), BitcoinTxError> {
1637 add_rune_input(&mut self.total_rune_inputs, rune)?;
1638
1639 Ok(())
1640 }
1641}
1642
1643pub fn add_rune_input<RuneSet: FixedCapacitySet<Item = RuneAmount> + Default>(
1644 total_rune_inputs: &mut RuneSet,
1645 rune: RuneAmount,
1646) -> Result<(), BitcoinTxError> {
1647 total_rune_inputs.insert_or_modify::<BitcoinTxError, _>(rune, |rune_input| {
1648 rune_input.amount = rune_input
1649 .amount
1650 .checked_add(rune.amount)
1651 .ok_or(BitcoinTxError::RuneAdditionOverflow)?;
1652 Ok(())
1653 })?;
1654
1655 Ok(())
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660 use crate::utxo_info::UtxoInfoTrait;
1661
1662 #[cfg(feature = "utxo-consolidation")]
1663 use crate::utxo_info::FixedOptionF64;
1664
1665 use super::*;
1666 use crate::utxo_info::SingleRuneSet;
1667 use arch_program::rune::{RuneAmount, RuneId};
1668 use arch_program::utxo::UtxoMeta;
1669 use bitcoin::{Amount, TxOut};
1670
1671 #[allow(unused_macros)]
1672 macro_rules! new_tb {
1673 ($max_mod:expr, $max_inputs:expr) => {{
1674 // Always specify the `SingleRuneSet` type parameter so the invocation
1675 // matches the `TransactionBuilder` definition regardless of whether the
1676 // `runes` feature is enabled.
1677 TransactionBuilder::<$max_mod, $max_inputs, SingleRuneSet>::new()
1678 }};
1679 }
1680
1681 // Helper function to create a mock UtxoInfo
1682 fn create_mock_utxo(value: u64, txid: [u8; 32], vout: u32) -> UtxoInfo<SingleRuneSet> {
1683 UtxoInfo::new(UtxoMeta::from(txid, vout), value)
1684 }
1685
1686 // Helper function to create a mock UtxoInfo with runes
1687 fn create_mock_utxo_with_runes(
1688 value: u64,
1689 txid: [u8; 32],
1690 vout: u32,
1691 rune_amount: u128,
1692 ) -> UtxoInfo<SingleRuneSet> {
1693 let runes = {
1694 #[cfg(feature = "runes")]
1695 {
1696 let mut runes = SingleRuneSet::default();
1697 runes
1698 .insert(RuneAmount {
1699 id: RuneId::new(1, 1),
1700 amount: rune_amount,
1701 })
1702 .unwrap();
1703
1704 runes
1705 }
1706 #[cfg(not(feature = "runes"))]
1707 {
1708 SingleRuneSet::default()
1709 }
1710 };
1711
1712 let mut utxo = UtxoInfo::new(UtxoMeta::from(txid, vout), value);
1713
1714 #[cfg(feature = "runes")]
1715 {
1716 utxo.runes = runes;
1717 }
1718
1719 utxo
1720 }
1721
1722 mod new {
1723 use super::*;
1724
1725 #[test]
1726 fn creates_empty_transaction_builder() {
1727 let builder = new_tb!(0, 0);
1728
1729 assert_eq!(builder.transaction.version, Version::TWO);
1730 assert_eq!(builder.transaction.lock_time, LockTime::ZERO);
1731 assert_eq!(builder.transaction.input.len(), 0);
1732 assert_eq!(builder.transaction.output.len(), 0);
1733 assert_eq!(builder.total_btc_input, 0);
1734 #[cfg(feature = "runes")]
1735 assert_eq!(builder.total_rune_inputs.len(), 0);
1736 #[cfg(feature = "utxo-consolidation")]
1737 assert_eq!(builder.total_btc_consolidation_input, 0);
1738 #[cfg(feature = "utxo-consolidation")]
1739 assert_eq!(builder.extra_tx_size_for_consolidation, 0);
1740 assert_eq!(builder.modified_accounts.len(), 0);
1741 assert_eq!(builder.inputs_to_sign.len(), 0);
1742 }
1743 }
1744
1745 mod new_with_transaction {
1746 use super::*;
1747
1748 #[test]
1749 fn new_with_transaction_successfully() {
1750 // Create a transaction without inputs to avoid UTXO lookup issues
1751 let tx_output = TxOut {
1752 value: Amount::from_sat(50000),
1753 script_pubkey: ScriptBuf::new(),
1754 };
1755
1756 let transaction = Transaction {
1757 version: Version::ONE,
1758 lock_time: LockTime::ZERO,
1759 input: vec![TxIn {
1760 previous_output: OutPoint::from_str(
1761 "1111111111111111111111111111111111111111111111111111111111111111:0",
1762 )
1763 .unwrap(),
1764 script_sig: ScriptBuf::new(),
1765 sequence: Sequence::MAX,
1766 witness: Witness::new(),
1767 }], // Empty inputs to avoid lookup
1768 output: vec![tx_output],
1769 };
1770
1771 let utxo_metas = transaction
1772 .input
1773 .iter()
1774 .map(|input| {
1775 UtxoMeta::from_outpoint(input.previous_output.txid, input.previous_output.vout)
1776 })
1777 .collect::<Vec<_>>();
1778
1779 // Prepare mock mempool data reflecting a pending UTXO with specific fee/size
1780 let user_utxos = vec![create_mock_utxo_with_runes(
1781 25000,
1782 utxo_metas[0].txid_big_endian(),
1783 utxo_metas[0].vout(),
1784 1000,
1785 )];
1786
1787 let mempool_data = {
1788 let mut utxo_mempool_info = [None; 10];
1789 utxo_mempool_info[0] = Some((
1790 utxo_metas[0].txid_big_endian(),
1791 MempoolInfo {
1792 total_fee: 1000,
1793 total_size: 250,
1794 },
1795 ));
1796
1797 mempool::MempoolData::<10, 10>::new(
1798 utxo_mempool_info,
1799 std::array::from_fn(|_| mempool::AccountMempoolInfo::default()),
1800 )
1801 };
1802
1803 // Build the transaction builder directly from an existing transaction.
1804 let builder = TransactionBuilder::<10, 10, SingleRuneSet>::new_with_transaction(
1805 transaction.clone(),
1806 &mempool_data,
1807 &user_utxos,
1808 )
1809 .expect("Failed to create builder from transaction");
1810
1811 // The builder should now reflect the data derived from `transaction`.
1812 assert_eq!(builder.transaction.version, Version::ONE);
1813 assert_eq!(builder.transaction.input.len(), 1);
1814 assert_eq!(builder.transaction.output.len(), 1);
1815 assert_eq!(builder.total_btc_input, 25000);
1816 #[cfg(feature = "runes")]
1817 assert_eq!(builder.total_rune_inputs.len(), 1);
1818 #[cfg(feature = "runes")]
1819 assert_eq!(
1820 builder.total_rune_inputs.find(&RuneAmount {
1821 id: RuneId::new(1, 1),
1822 amount: 1000,
1823 }),
1824 Some(&RuneAmount {
1825 id: RuneId::new(1, 1),
1826 amount: 1000,
1827 })
1828 );
1829 assert_eq!(builder.tx_statuses.total_fee, 1000);
1830 assert_eq!(builder.tx_statuses.total_size, 250);
1831 }
1832
1833 #[cfg(feature = "runes")]
1834 #[test]
1835 fn calculates_rune_input_correctly() {
1836 let transaction =
1837 Transaction {
1838 version: Version::TWO,
1839 lock_time: LockTime::ZERO,
1840 input: vec![TxIn {
1841 previous_output: OutPoint::from_str(
1842 "1111111111111111111111111111111111111111111111111111111111111111:0",
1843 )
1844 .unwrap(),
1845 script_sig: ScriptBuf::new(),
1846 sequence: Sequence::MAX,
1847 witness: Witness::new(),
1848 },
1849 TxIn {
1850 previous_output: OutPoint::from_str(
1851 "2222222222222222222222222222222222222222222222222222222222222222:1",
1852 )
1853 .unwrap(),
1854 script_sig: ScriptBuf::new(),
1855 sequence: Sequence::MAX,
1856 witness: Witness::new(),
1857 },
1858 TxIn {
1859 previous_output: OutPoint::from_str(
1860 "3333333333333333333333333333333333333333333333333333333333333333:2",
1861 )
1862 .unwrap(),
1863 script_sig: ScriptBuf::new(),
1864 sequence: Sequence::MAX,
1865 witness: Witness::new(),
1866 }],
1867 output: vec![],
1868 };
1869
1870 let utxo_metas = transaction
1871 .input
1872 .iter()
1873 .map(|input| {
1874 UtxoMeta::from_outpoint(input.previous_output.txid, input.previous_output.vout)
1875 })
1876 .collect::<Vec<_>>();
1877
1878 // Test with multiple rune UTXOs but no pending mempool data required
1879 let user_utxos = vec![
1880 create_mock_utxo_with_runes(
1881 10000,
1882 utxo_metas[0].txid_big_endian(),
1883 utxo_metas[0].vout(),
1884 500,
1885 ),
1886 create_mock_utxo_with_runes(
1887 20000,
1888 utxo_metas[1].txid_big_endian(),
1889 utxo_metas[1].vout(),
1890 750,
1891 ),
1892 create_mock_utxo(30000, utxo_metas[2].txid_big_endian(), utxo_metas[2].vout()), // No runes
1893 ];
1894
1895 let mempool_data = mempool::MempoolData::<10, 10>::default();
1896
1897 let builder = TransactionBuilder::<10, 10, SingleRuneSet>::new_with_transaction(
1898 transaction,
1899 &mempool_data,
1900 &user_utxos,
1901 )
1902 .expect("Failed to build transaction");
1903
1904 assert_eq!(builder.total_rune_inputs.len(), 1);
1905 assert_eq!(
1906 builder.total_rune_inputs.find(&RuneAmount {
1907 id: RuneId::new(1, 1),
1908 amount: 1000,
1909 }),
1910 Some(&RuneAmount {
1911 id: RuneId::new(1, 1),
1912 amount: 1000,
1913 })
1914 );
1915 }
1916 }
1917
1918 mod get_fee_paid {
1919 use bitcoin::Amount;
1920
1921 use super::*;
1922
1923 #[test]
1924 fn calculates_fee_paid_correctly() {
1925 let mut builder = new_tb!(10, 10);
1926
1927 // Set total BTC input directly for this test
1928 builder.total_btc_input = 100000;
1929
1930 // Add output
1931 builder.transaction.output.push(TxOut {
1932 value: Amount::from_sat(95000),
1933 script_pubkey: ScriptBuf::new(),
1934 });
1935
1936 let fee_paid = builder.get_fee_paid().unwrap();
1937 assert_eq!(fee_paid, 5000); // 100000 - 95000
1938 }
1939
1940 #[test]
1941 fn returns_error_when_insufficient_input() {
1942 let mut builder = new_tb!(10, 10);
1943
1944 // Add output but no input
1945 builder.transaction.output.push(TxOut {
1946 value: Amount::from_sat(50000),
1947 script_pubkey: ScriptBuf::new(),
1948 });
1949
1950 let result = builder.get_fee_paid();
1951 assert!(result.is_err());
1952 assert_eq!(result.unwrap_err(), BitcoinTxError::InsufficientInputAmount);
1953 }
1954 }
1955
1956 mod get_ancestors_totals {
1957 use super::*;
1958
1959 #[test]
1960 fn returns_correct_ancestors_totals() {
1961 let mut builder = new_tb!(10, 10);
1962 builder.tx_statuses = MempoolInfo {
1963 total_fee: 1500,
1964 total_size: 300,
1965 };
1966
1967 let (total_size, total_fee) = builder.get_ancestors_totals().unwrap();
1968 assert_eq!(total_size, 300);
1969 assert_eq!(total_fee, 1500);
1970 }
1971 }
1972
1973 mod is_fee_rate_valid {
1974 use super::*;
1975
1976 #[test]
1977 fn validates_fee_rate_correctly() {
1978 let mut builder = new_tb!(10, 10);
1979
1980 // Set inputs and outputs manually for controlled test
1981 builder.total_btc_input = 100000;
1982
1983 // Add output with fee of 10000 sats
1984 builder.transaction.output.push(TxOut {
1985 value: Amount::from_sat(90000),
1986 script_pubkey: ScriptBuf::new(),
1987 });
1988
1989 // Assume transaction size is about 200 bytes, so fee rate is 50 sat/vB
1990 let fee_rate = FeeRate::try_from(30.0).unwrap(); // 30 sat/vB
1991 let result = builder.is_fee_rate_valid(&fee_rate);
1992
1993 // This should pass as our effective fee rate (50) is higher than required (30)
1994 assert!(result.is_ok());
1995 }
1996
1997 #[test]
1998 fn rejects_insufficient_fee_rate() {
1999 let mut builder = new_tb!(10, 10);
2000
2001 // Set inputs and outputs manually
2002 builder.total_btc_input = 100000;
2003
2004 // Add output with very low fee
2005 builder.transaction.output.push(TxOut {
2006 value: Amount::from_sat(99900),
2007 script_pubkey: ScriptBuf::new(),
2008 });
2009
2010 // Require high fee rate
2011 let fee_rate = FeeRate::try_from(100.0).unwrap(); // 100 sat/vB
2012 let result = builder.is_fee_rate_valid(&fee_rate);
2013
2014 assert!(result.is_err());
2015 assert_eq!(result.unwrap_err(), BitcoinTxError::InvalidFeeRateTooLow);
2016 }
2017 }
2018
2019 mod tx_status_handling {
2020 use super::*;
2021
2022 #[test]
2023 fn handles_confirmed_tx_status() {
2024 let mut builder = new_tb!(10, 10);
2025 let utxo = create_mock_utxo(50000, [1u8; 32], 0);
2026 let status = TxStatus::Confirmed;
2027
2028 // Manually test the add_tx_status logic
2029 builder.add_tx_status(&utxo, &status);
2030
2031 assert_eq!(builder.tx_statuses.total_fee, 0);
2032 assert_eq!(builder.tx_statuses.total_size, 0);
2033 }
2034
2035 #[test]
2036 fn handles_pending_tx_status() {
2037 let mut builder = new_tb!(10, 10);
2038 let utxo = create_mock_utxo(50000, [1u8; 32], 0);
2039 let pending_info = MempoolInfo {
2040 total_fee: 2000,
2041 total_size: 250,
2042 };
2043 let status = TxStatus::Pending(pending_info);
2044
2045 // Manually test the add_tx_status logic
2046 builder.add_tx_status(&utxo, &status);
2047
2048 assert_eq!(builder.tx_statuses.total_fee, 2000);
2049 assert_eq!(builder.tx_statuses.total_size, 250);
2050 }
2051 }
2052
2053 mod modified_account {
2054 use super::*;
2055
2056 #[test]
2057 fn modified_account_new_works() {
2058 // This test would require a mock AccountInfo which is complex to create
2059 // Skipping for now since we tested the core functionality elsewhere
2060 }
2061
2062 #[test]
2063 fn modified_account_default_is_none() {
2064 let modified = ModifiedAccount::default();
2065 assert!(modified.0.is_none());
2066 }
2067
2068 #[test]
2069 #[should_panic(expected = "ModifiedAccount is None")]
2070 fn modified_account_as_ref_panics_when_none() {
2071 let modified = ModifiedAccount::default();
2072 let _ = modified.as_ref();
2073 }
2074 }
2075
2076 mod estimate_final_tx_vsize {
2077 use super::*;
2078 use arch_program::input_to_sign::InputToSign;
2079 use arch_program::pubkey::Pubkey;
2080
2081 #[test]
2082 fn estimates_empty_transaction_size() {
2083 let mut builder = new_tb!(10, 10);
2084
2085 let vsize = builder.estimate_final_tx_vsize().unwrap();
2086
2087 // Empty transaction should have minimal size
2088 assert!(vsize > 0);
2089 assert!(vsize < 100); // Should be quite small
2090 }
2091
2092 #[test]
2093 fn estimates_transaction_size_with_inputs_to_sign() {
2094 let mut builder = new_tb!(10, 10);
2095
2096 // Add some mock inputs to sign
2097 let pubkey = Pubkey::system_program();
2098 builder
2099 .inputs_to_sign
2100 .push(InputToSign {
2101 index: 0,
2102 signer: pubkey,
2103 })
2104 .unwrap();
2105
2106 builder
2107 .inputs_to_sign
2108 .push(InputToSign {
2109 index: 1,
2110 signer: pubkey,
2111 })
2112 .unwrap();
2113
2114 // Add some transaction inputs
2115 builder.transaction.input.push(TxIn {
2116 previous_output: OutPoint::null(),
2117 script_sig: ScriptBuf::new(),
2118 sequence: Sequence::MAX,
2119 witness: Witness::new(),
2120 });
2121 builder.transaction.input.push(TxIn {
2122 previous_output: OutPoint::null(),
2123 script_sig: ScriptBuf::new(),
2124 sequence: Sequence::MAX,
2125 witness: Witness::new(),
2126 });
2127
2128 let vsize = builder.estimate_final_tx_vsize().unwrap();
2129
2130 // Should be larger than empty transaction due to witness overhead
2131 assert!(vsize > 100);
2132 }
2133 }
2134
2135 #[cfg(feature = "utxo-consolidation")]
2136 mod get_fee_paid_by_program {
2137 use super::*;
2138
2139 #[test]
2140 fn calculates_consolidation_fee_correctly() {
2141 let mut builder = new_tb!(10, 10);
2142
2143 // Set consolidation values
2144 builder.extra_tx_size_for_consolidation = 500; // 500 bytes
2145
2146 let fee_rate = FeeRate::try_from(10.0).unwrap(); // 10 sat/vB
2147 let fee = builder.get_fee_paid_by_program(&fee_rate);
2148
2149 assert_eq!(fee, 5000); // 500 bytes * 10 sat/vB = 5000 sats
2150 }
2151
2152 #[test]
2153 fn returns_zero_when_no_consolidation() {
2154 let builder = new_tb!(10, 10);
2155
2156 let fee_rate = FeeRate::try_from(50.0).unwrap();
2157 let fee = builder.get_fee_paid_by_program(&fee_rate);
2158
2159 assert_eq!(fee, 0);
2160 }
2161 }
2162
2163 mod input_index_management {
2164 use super::*;
2165 use arch_program::input_to_sign::InputToSign;
2166 use arch_program::pubkey::Pubkey;
2167
2168 #[test]
2169 fn updates_indices_correctly_when_inserting() {
2170 let mut builder = new_tb!(10, 10);
2171 let pubkey = Pubkey::system_program();
2172
2173 // Add initial inputs to sign
2174 builder
2175 .inputs_to_sign
2176 .push(InputToSign {
2177 index: 0,
2178 signer: pubkey,
2179 })
2180 .unwrap();
2181 builder
2182 .inputs_to_sign
2183 .push(InputToSign {
2184 index: 1,
2185 signer: pubkey,
2186 })
2187 .unwrap();
2188 builder
2189 .inputs_to_sign
2190 .push(InputToSign {
2191 index: 2,
2192 signer: pubkey,
2193 })
2194 .unwrap();
2195
2196 // Manually call the index update logic (simulate insertion at index 1)
2197 let insert_index = 1u32;
2198 for input in builder.inputs_to_sign.iter_mut() {
2199 if input.index >= insert_index {
2200 input.index += 1;
2201 }
2202 }
2203
2204 // Check that indices were updated correctly
2205 let slice = builder.inputs_to_sign.as_slice();
2206 assert_eq!(slice[0].index, 0); // Should remain 0
2207 assert_eq!(slice[1].index, 2); // Should be incremented from 1 to 2
2208 assert_eq!(slice[2].index, 3); // Should be incremented from 2 to 3
2209 }
2210
2211 #[test]
2212 fn handles_multiple_insertions() {
2213 let mut builder = new_tb!(10, 10);
2214 let pubkey = Pubkey::system_program();
2215
2216 // Add inputs to sign
2217 for i in 0..5 {
2218 builder
2219 .inputs_to_sign
2220 .push(InputToSign {
2221 index: i,
2222 signer: pubkey,
2223 })
2224 .unwrap();
2225 }
2226
2227 // Simulate multiple insertions
2228 // Insert at index 2 - indices 2,3,4 become 3,4,5
2229 for input in builder.inputs_to_sign.iter_mut() {
2230 if input.index >= 2 {
2231 input.index += 1;
2232 }
2233 }
2234
2235 // Insert at index 1 - indices 1,3,4,5 become 2,4,5,6
2236 for input in builder.inputs_to_sign.iter_mut() {
2237 if input.index >= 1 {
2238 input.index += 1;
2239 }
2240 }
2241
2242 // Check final indices - let's trace through what actually happens:
2243 // Original: 0,1,2,3,4
2244 // After first insertion at 2: 0,1,3,4,5
2245 // After second insertion at 1: 0,2,4,5,6
2246 let slice = builder.inputs_to_sign.as_slice();
2247 assert_eq!(slice[0].index, 0); // Should remain 0
2248 assert_eq!(slice[1].index, 2); // 1 -> 2
2249 assert_eq!(slice[2].index, 4); // 2 -> 3 -> 4
2250 assert_eq!(slice[3].index, 5); // 3 -> 4 -> 5
2251 assert_eq!(slice[4].index, 6); // 4 -> 5 -> 6
2252 }
2253 }
2254
2255 mod modified_accounts_tracking {
2256 use super::*;
2257
2258 #[test]
2259 fn tracks_modified_accounts_correctly() {
2260 let builder = new_tb!(10, 10);
2261
2262 // Test that we start with empty modified accounts
2263 assert_eq!(builder.modified_accounts.len(), 0);
2264
2265 // Test that the list is initially empty
2266 assert!(builder.modified_accounts.is_empty());
2267 }
2268
2269 #[test]
2270 fn respects_max_modified_accounts_limit() {
2271 let builder = new_tb!(10, 10);
2272
2273 // Test that we can't exceed MAX_MODIFIED_ACCOUNTS
2274 assert_eq!(builder.modified_accounts.len(), 0);
2275 // Note: FixedList doesn't have a capacity() method, but we can test max length through other means
2276 }
2277 }
2278
2279 mod boundary_conditions {
2280 use super::*;
2281
2282 #[test]
2283 fn handles_max_inputs_to_sign() {
2284 let builder = new_tb!(10, 10);
2285
2286 // Test that the list starts empty and can hold items
2287 assert_eq!(builder.inputs_to_sign.len(), 0);
2288 }
2289
2290 #[test]
2291 fn handles_large_btc_amounts() {
2292 let mut builder = new_tb!(10, 10);
2293
2294 // Test with large BTC amounts (but not MAX to avoid overflow in calculations)
2295 builder.total_btc_input = 21_000_000 * 100_000_000; // 21M BTC in satoshis
2296
2297 assert_eq!(builder.total_btc_input, 21_000_000 * 100_000_000);
2298 }
2299 }
2300
2301 mod fee_rate_validation_edge_cases {
2302 use super::*;
2303
2304 #[test]
2305 fn handles_zero_fee_rate() {
2306 let mut builder = new_tb!(10, 10);
2307
2308 builder.total_btc_input = 100000;
2309 builder.transaction.output.push(TxOut {
2310 value: Amount::from_sat(95000), // 5000 sat fee for a more reasonable rate
2311 script_pubkey: ScriptBuf::new(),
2312 });
2313
2314 // Low fee rate
2315 let fee_rate = FeeRate::try_from(1.0).unwrap(); // 1 sat/vB
2316 let result = builder.is_fee_rate_valid(&fee_rate);
2317
2318 // Should pass since we have sufficient fee
2319 assert!(result.is_ok());
2320 }
2321
2322 #[test]
2323 fn handles_ancestors_with_high_fees() {
2324 let mut builder = new_tb!(10, 10);
2325
2326 builder.total_btc_input = 100000;
2327 builder.transaction.output.push(TxOut {
2328 value: Amount::from_sat(95000),
2329 script_pubkey: ScriptBuf::new(),
2330 });
2331
2332 // Set high ancestor fees
2333 builder.tx_statuses = MempoolInfo {
2334 total_fee: 50000, // High ancestor fees
2335 total_size: 1000,
2336 };
2337
2338 let fee_rate = FeeRate::try_from(10.0).unwrap();
2339 let result = builder.is_fee_rate_valid(&fee_rate);
2340
2341 // Should pass due to high ancestor fees contributing to overall rate
2342 assert!(result.is_ok());
2343 }
2344
2345 #[test]
2346 fn handles_very_large_transactions() {
2347 let mut builder = new_tb!(10, 10);
2348
2349 // Create a large transaction with many inputs
2350 for i in 0..50 {
2351 builder.transaction.input.push(TxIn {
2352 previous_output: OutPoint {
2353 txid: bitcoin::Txid::from_str(&format!("{:064x}", i)).unwrap(),
2354 vout: 0,
2355 },
2356 script_sig: ScriptBuf::new(),
2357 sequence: Sequence::MAX,
2358 witness: Witness::new(),
2359 });
2360 }
2361
2362 builder.total_btc_input = 5000000; // 5M sats
2363 builder.transaction.output.push(TxOut {
2364 value: Amount::from_sat(4950000), // 50k sats fee
2365 script_pubkey: ScriptBuf::new(),
2366 });
2367
2368 let fee_rate = FeeRate::try_from(10.0).unwrap();
2369 let result = builder.is_fee_rate_valid(&fee_rate);
2370
2371 // Should handle large transactions gracefully
2372 assert!(result.is_ok() || result.is_err()); // Just ensure it doesn't panic
2373 }
2374 }
2375
2376 #[cfg(feature = "utxo-consolidation")]
2377 mod consolidation_tests {
2378 use super::*;
2379
2380 #[test]
2381 fn tracks_consolidation_input_amounts() {
2382 let mut builder = new_tb!(10, 10);
2383
2384 // Manually set consolidation amounts (normally set by add_consolidation_utxos)
2385 builder.total_btc_consolidation_input = 250000;
2386
2387 assert_eq!(builder.total_btc_consolidation_input, 250000);
2388 }
2389
2390 #[test]
2391 fn tracks_extra_consolidation_size() {
2392 let mut builder = new_tb!(10, 10);
2393
2394 // Manually set extra tx size (normally set by add_consolidation_utxos)
2395 builder.extra_tx_size_for_consolidation = 1500;
2396
2397 assert_eq!(builder.extra_tx_size_for_consolidation, 1500);
2398 }
2399
2400 #[test]
2401 fn consolidation_fee_calculation_integration() {
2402 let mut builder = new_tb!(10, 10);
2403
2404 builder.extra_tx_size_for_consolidation = 800;
2405
2406 let fee_rate = FeeRate::try_from(25.0).unwrap(); // 25 sat/vB
2407 let fee = builder.get_fee_paid_by_program(&fee_rate);
2408
2409 assert_eq!(fee, 20000); // 800 * 25 = 20000 sats
2410 }
2411 }
2412
2413 mod transaction_structure {
2414 use super::*;
2415
2416 #[test]
2417 fn maintains_transaction_structure_integrity() {
2418 let mut builder = new_tb!(10, 10);
2419
2420 // Add inputs and outputs
2421 builder.transaction.input.push(TxIn {
2422 previous_output: OutPoint::null(),
2423 script_sig: ScriptBuf::new(),
2424 sequence: Sequence::MAX,
2425 witness: Witness::new(),
2426 });
2427
2428 builder.transaction.output.push(TxOut {
2429 value: Amount::from_sat(50000),
2430 script_pubkey: ScriptBuf::new(),
2431 });
2432
2433 // Verify structure
2434 assert_eq!(builder.transaction.input.len(), 1);
2435 assert_eq!(builder.transaction.output.len(), 1);
2436 assert_eq!(builder.transaction.version, Version::TWO);
2437 assert_eq!(builder.transaction.lock_time, LockTime::ZERO);
2438 }
2439
2440 #[test]
2441 fn handles_empty_transaction_gracefully() {
2442 let mut builder = new_tb!(10, 10);
2443
2444 // Empty transaction should be valid
2445 assert_eq!(builder.transaction.input.len(), 0);
2446 assert_eq!(builder.transaction.output.len(), 0);
2447
2448 // Should be able to estimate size even when empty
2449 let vsize = builder.estimate_final_tx_vsize().unwrap();
2450 assert!(vsize > 0);
2451 }
2452 }
2453
2454 mod error_handling {
2455 use super::*;
2456
2457 #[test]
2458 fn handles_fee_calculation_edge_cases() {
2459 let mut builder = new_tb!(10, 10);
2460
2461 // Test with zero input
2462 builder.total_btc_input = 0;
2463 builder.transaction.output.push(TxOut {
2464 value: Amount::from_sat(1000),
2465 script_pubkey: ScriptBuf::new(),
2466 });
2467
2468 let result = builder.get_fee_paid();
2469 assert!(result.is_err());
2470 assert_eq!(result.unwrap_err(), BitcoinTxError::InsufficientInputAmount);
2471 }
2472
2473 #[test]
2474 fn handles_ancestor_totals_correctly() {
2475 let mut builder = new_tb!(10, 10);
2476
2477 // Test with default (empty) mempool info
2478 let (size, fee) = builder.get_ancestors_totals().unwrap();
2479 assert_eq!(size, 0);
2480 assert_eq!(fee, 0);
2481
2482 // Test with some ancestor data
2483 builder.tx_statuses.total_fee = 5000;
2484 builder.tx_statuses.total_size = 500;
2485
2486 let (size, fee) = builder.get_ancestors_totals().unwrap();
2487 assert_eq!(size, 500);
2488 assert_eq!(fee, 5000);
2489 }
2490 }
2491
2492 mod find_btc {
2493 use super::*;
2494
2495 const PUBKEY: Pubkey = Pubkey([0; 32]);
2496
2497 #[test]
2498 fn finds_btc_with_one_utxo() {
2499 let utxos = vec![UtxoInfo::new(UtxoMeta::from([0; 32], 0), 10_000)];
2500
2501 let amount = 10_000;
2502
2503 let mut transaction_builder = new_tb!(10, 10);
2504
2505 let utxo_refs: Vec<&UtxoInfo<SingleRuneSet>> = utxos.iter().collect();
2506 let (found_utxo_indices, found_amount) = transaction_builder
2507 .find_btc_in_utxos(&utxo_refs, &PUBKEY, amount)
2508 .unwrap();
2509
2510 assert_eq!(found_utxo_indices.len(), 1, "Found a single UTXO");
2511 assert_eq!(found_amount, 10_000);
2512 }
2513
2514 #[test]
2515 fn finds_btc_with_multiple_utxos() {
2516 let utxos = vec![
2517 UtxoInfo::new(UtxoMeta::from([0; 32], 0), 5_000),
2518 UtxoInfo::new(UtxoMeta::from([0; 32], 1), 8_000),
2519 UtxoInfo::new(UtxoMeta::from([0; 32], 2), 12_000),
2520 ];
2521
2522 let amount = 10_000;
2523
2524 let mut transaction_builder = new_tb!(10, 10);
2525
2526 let utxo_refs: Vec<&UtxoInfo<SingleRuneSet>> = utxos.iter().collect();
2527 let (found_utxo_indices, found_amount) = transaction_builder
2528 .find_btc_in_utxos(&utxo_refs, &PUBKEY, amount)
2529 .unwrap();
2530
2531 assert_eq!(found_utxo_indices.len(), 1, "Found a single UTXO");
2532 assert_eq!(utxos[found_utxo_indices[0]].meta.vout(), 2);
2533 assert_eq!(found_amount, 12_000);
2534 }
2535
2536 #[test]
2537 #[cfg(feature = "utxo-consolidation")]
2538 fn finds_btc_with_consolidation_utxos() {
2539 let mut utxos = vec![
2540 UtxoInfo::new(UtxoMeta::from([0; 32], 0), 5_000),
2541 UtxoInfo::new(UtxoMeta::from([0; 32], 1), 8_000),
2542 UtxoInfo::new(UtxoMeta::from([0; 32], 2), 12_000),
2543 ];
2544
2545 *utxos[1].needs_consolidation_mut() = FixedOptionF64::some(1.0);
2546 *utxos[2].needs_consolidation_mut() = FixedOptionF64::some(1.0);
2547
2548 let amount = 10_000;
2549
2550 let mut transaction_builder = new_tb!(10, 10);
2551
2552 let utxo_refs: Vec<&UtxoInfo<SingleRuneSet>> = utxos.iter().collect();
2553 let (found_utxo_indices, found_amount) = transaction_builder
2554 .find_btc_in_utxos(&utxo_refs, &PUBKEY, amount)
2555 .unwrap();
2556
2557 assert_eq!(found_utxo_indices.len(), 2, "Found two UTXOs");
2558 assert_eq!(
2559 utxos[found_utxo_indices[0]].meta.vout(),
2560 0,
2561 "First UTXO matches"
2562 );
2563 assert_eq!(
2564 utxos[found_utxo_indices[1]].meta.vout(),
2565 2,
2566 "Second UTXO matches"
2567 );
2568 assert_eq!(found_amount, 17_000);
2569 }
2570 }
2571
2572 mod find_btc_from_holder {
2573 use super::*;
2574
2575 const PUBKEY: Pubkey = Pubkey([0; 32]);
2576
2577 struct TestHolder {
2578 utxos: Vec<UtxoInfo<SingleRuneSet>>,
2579 }
2580
2581 impl BtcUtxoHolder for TestHolder {
2582 fn btc_utxos(&self) -> &[UtxoInfo] {
2583 self.utxos.as_slice()
2584 }
2585 }
2586
2587 fn utxo(value: u64, vout: u32) -> UtxoInfo<SingleRuneSet> {
2588 UtxoInfo::new(UtxoMeta::from([0; 32], vout), value)
2589 }
2590
2591 #[cfg(feature = "utxo-consolidation")]
2592 fn utxo_cons(value: u64, vout: u32) -> UtxoInfo<SingleRuneSet> {
2593 let mut u = utxo(value, vout);
2594 *u.needs_consolidation_mut() = FixedOptionF64::some(1.0);
2595 u
2596 }
2597
2598 #[test]
2599 fn selects_basic_across_holders() {
2600 // Two holders with one UTXO each; ensure we pick the largest to satisfy the amount.
2601 let holders = vec![
2602 TestHolder {
2603 utxos: vec![utxo(5_000, 0)],
2604 },
2605 TestHolder {
2606 utxos: vec![utxo(12_000, 1)],
2607 },
2608 ];
2609
2610 let mut builder = new_tb!(10, 10);
2611 let amount = 10_000;
2612
2613 let found_amount = builder
2614 .find_btc_in_utxos_from_holder(&holders, &PUBKEY, amount, false)
2615 .unwrap();
2616
2617 assert_eq!(found_amount, 17_000);
2618
2619 #[cfg(feature = "utxo-consolidation")]
2620 {
2621 assert_eq!(builder.total_btc_consolidation_input, 0);
2622 assert_eq!(builder.extra_tx_size_for_consolidation, 0);
2623 }
2624 }
2625
2626 #[test]
2627 #[cfg(feature = "utxo-consolidation")]
2628 fn tracks_consolidation_single_input() {
2629 // Prefer non-consolidation UTXOs first, then include one consolidation UTXO.
2630 let holders = vec![
2631 TestHolder {
2632 utxos: vec![utxo(5_000, 0), utxo_cons(12_000, 1)],
2633 },
2634 TestHolder {
2635 utxos: vec![utxo(9_000, 2)],
2636 },
2637 ];
2638
2639 let mut builder = new_tb!(10, 10);
2640 let amount = 17_000;
2641
2642 let found_amount = builder
2643 .find_btc_in_utxos_from_holder(&holders, &PUBKEY, amount, false)
2644 .unwrap();
2645
2646 // Selection: 9k (non-cons) + 5k (non-cons) + 12k (cons) = 26k
2647 assert_eq!(found_amount, 26_000);
2648 assert_eq!(builder.total_btc_consolidation_input, 12_000);
2649 assert_eq!(
2650 builder.extra_tx_size_for_consolidation,
2651 crate::input_calc::ARCH_INPUT_SIZE
2652 );
2653 }
2654
2655 #[test]
2656 #[cfg(feature = "utxo-consolidation")]
2657 fn tracks_consolidation_multiple_inputs() {
2658 // Require two consolidation UTXOs to reach the target amount.
2659 let holders = vec![
2660 TestHolder {
2661 utxos: vec![utxo(4_000, 0)],
2662 },
2663 TestHolder {
2664 utxos: vec![utxo_cons(7_000, 1)],
2665 },
2666 TestHolder {
2667 utxos: vec![utxo_cons(6_000, 2)],
2668 },
2669 ];
2670
2671 let mut builder = new_tb!(10, 10);
2672 let amount = 15_000;
2673
2674 let found_amount = builder
2675 .find_btc_in_utxos_from_holder(&holders, &PUBKEY, amount, false)
2676 .unwrap();
2677
2678 // Selection: 4k (non-cons) + 7k (cons) + 6k (cons) = 17k
2679 assert_eq!(found_amount, 17_000);
2680 assert_eq!(builder.total_btc_consolidation_input, 13_000);
2681 assert_eq!(
2682 builder.extra_tx_size_for_consolidation,
2683 crate::input_calc::ARCH_INPUT_SIZE * 2
2684 );
2685 }
2686 }
2687}