From f3b0864ca2d14569e9dca3e42434fcfc4e3fe22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 20 May 2026 05:19:14 +0000 Subject: [PATCH 1/9] refactor!: rename Selection to TxTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure rename — same struct, same methods, same parameters. No behaviour change. The next commit adds the resolved tx-shape fields (version, lock_time, fallback_sequence), the corresponding setters, and the PSBT/AFS pipeline that consumes them. Selection -> TxTemplate Selection::new -> TxTemplate::from_parts (still pub(crate)) IntoSelectionError -> IntoTxTemplateError InputCandidates::into_selection -> into_tx_template Selector::try_finalize() -> Option Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/anti_fee_sniping.rs | 2 +- examples/synopsis.rs | 4 ++-- src/finalizer.rs | 22 +++++++++++----------- src/input.rs | 12 ++++++------ src/input_candidates.rs | 28 ++++++++++++++-------------- src/lib.rs | 4 ++-- src/selector.rs | 6 +++--- src/{selection.rs => tx_template.rs} | 26 +++++++++++++------------- 8 files changed, 52 insertions(+), 52 deletions(-) rename src/{selection.rs => tx_template.rs} (97%) diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index da318b93..1c0a57e3 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -72,7 +72,7 @@ fn main() -> anyhow::Result<()> { .all_candidates() .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_time))) - .into_selection( + .into_tx_template( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams { // For waste optimization when deciding change. diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 2d629ba6..c2685e8c 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -52,7 +52,7 @@ fn main() -> anyhow::Result<()> { .all_candidates() .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_mtp))) - .into_selection( + .into_tx_template( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams { // For waste-optimization when deciding change. @@ -122,7 +122,7 @@ fn main() -> anyhow::Result<()> { let selection = rbf_candidates // Do coin selection. - .into_selection( + .into_tx_template( // Coin selection algorithm. selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams { diff --git a/src/finalizer.rs b/src/finalizer.rs index 582494fd..21024619 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -15,7 +15,7 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// # Usage /// /// Construct a [`Finalizer`] from a list of `(outpoint, plan)` pairs, or by calling -/// [`into_finalizer`] on a particular [`Selection`]. Use [`finalize_input`] to finalize a single +/// [`into_finalizer`] on a particular [`TxTemplate`]. Use [`finalize_input`] to finalize a single /// input, or [`finalize`] to finalize every input and return a map containing the result of /// finalization at each index. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata /// from non-essential fields of the PSBT inputs and outputs, ensuring that only the necessary @@ -27,7 +27,7 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// # use bdk_tx::PsbtParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); /// # let keymap = std::collections::BTreeMap::new(); -/// # let selection: bdk_tx::Selection = unimplemented!(); +/// # let template: bdk_tx::TxTemplate = unimplemented!(); /// // Create PSBT from a selection of inputs and outputs. /// let mut psbt = selection.create_psbt(PsbtParams::default())?; /// @@ -46,8 +46,8 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// ``` /// /// [BIP174]: -/// [`Selection`]: crate::Selection -/// [`into_finalizer`]: crate::Selection::into_finalizer +/// [`TxTemplate`]: crate::TxTemplate +/// [`into_finalizer`]: crate::TxTemplate::into_finalizer /// [`Plan`]: miniscript::plan::Plan /// [`Transaction`]: bitcoin::Transaction /// [`finalize_input`]: Finalizer::finalize_input @@ -165,7 +165,7 @@ impl FinalizeMap { #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { - use crate::{Finalizer, Output, PsbtParams, Selection, Signer}; + use crate::{Finalizer, Output, PsbtParams, Signer, TxTemplate}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{absolute, transaction, Amount, ScriptBuf, TxIn, TxOut}; use miniscript::bitcoin; @@ -217,7 +217,7 @@ mod tests { fn test_finalize_single_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); let mut psbt = selection.create_psbt(PsbtParams::default())?; let finalizer = selection.into_finalizer(); @@ -237,7 +237,7 @@ mod tests { fn test_finalize_sets_final_script_sig() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(PKH_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); let mut psbt = selection.create_psbt(PsbtParams::default())?; let finalizer = selection.into_finalizer(); @@ -260,7 +260,7 @@ mod tests { let taproot_output_descriptor = derive_descriptor_at(TR_XPRV, 10)?; let wpkh_output_descriptor = derive_descriptor_at(WPKH_XPRV, 11)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input_0, input_1, input_2], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -315,7 +315,7 @@ mod tests { input_0.plan().cloned().expect("plan must exist"), )]); - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input_0, input_1], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -355,7 +355,7 @@ mod tests { let (input, _) = create_input_from_descriptor_at(TR_XPRV, 0)?; let taproot_output_descriptor = derive_descriptor_at(TR_XPRV, 10)?; let wpkh_output_descriptor = derive_descriptor_at(WPKH_XPRV, 11)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -389,7 +389,7 @@ mod tests { fn test_already_finalized_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); let mut psbt = selection.create_psbt(PsbtParams::default())?; let finalizer = selection.into_finalizer(); diff --git a/src/input.rs b/src/input.rs index dfe632cb..c50358d0 100644 --- a/src/input.rs +++ b/src/input.rs @@ -665,16 +665,16 @@ impl Input { } } -/// Mutable handle to an [`Input`] held inside a [`Selection`]. +/// Mutable handle to an [`Input`] held inside a [`TxTemplate`]. /// -/// Returned by [`Selection::input_mut`] and [`Selection::inputs_mut`]. This wrapper restricts -/// mutation to operations that preserve [`Selection`]'s coin-selection invariants. +/// Returned by [`TxTemplate::input_mut`] and [`TxTemplate::inputs_mut`]. This wrapper restricts +/// mutation to operations that preserve [`TxTemplate`]'s coin-selection invariants. /// /// Read-only access to the underlying [`Input`] is available via [`Deref`]. /// -/// [`Selection`]: crate::Selection -/// [`Selection::input_mut`]: crate::Selection::input_mut -/// [`Selection::inputs_mut`]: crate::Selection::inputs_mut +/// [`TxTemplate`]: crate::TxTemplate +/// [`TxTemplate::input_mut`]: crate::TxTemplate::input_mut +/// [`TxTemplate::inputs_mut`]: crate::TxTemplate::inputs_mut /// [`Deref`]: core::ops::Deref #[derive(Debug)] pub struct InputMut<'a>(&'a mut Input); diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 131f6af8..75f90851 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -7,8 +7,8 @@ use miniscript::bitcoin; use crate::collections::{BTreeMap, HashSet}; use crate::{ - CannotMeetTarget, FeeRateExt, Input, InputGroup, Selection, Selector, SelectorError, - SelectorParams, + CannotMeetTarget, FeeRateExt, Input, InputGroup, Selector, SelectorError, SelectorParams, + TxTemplate, }; /// Input candidates. @@ -191,30 +191,30 @@ impl InputCandidates { self } - /// Attempt to convert the input candidates into a valid [`Selection`] with a given + /// Attempt to convert the input candidates into a valid [`TxTemplate`] with a given /// `algorithm` and selector `params`. - pub fn into_selection( + pub fn into_tx_template( self, algorithm: A, params: SelectorParams, - ) -> Result> + ) -> Result> where A: FnMut(&mut Selector) -> Result<(), E>, { - let mut selector = Selector::new(&self, params).map_err(IntoSelectionError::Selector)?; + let mut selector = Selector::new(&self, params).map_err(IntoTxTemplateError::Selector)?; selector .select_with_algorithm(algorithm) - .map_err(IntoSelectionError::SelectionAlgorithm)?; + .map_err(IntoTxTemplateError::SelectionAlgorithm)?; let selection = selector .try_finalize() - .ok_or(IntoSelectionError::CannotMeetTarget(CannotMeetTarget))?; + .ok_or(IntoTxTemplateError::CannotMeetTarget(CannotMeetTarget))?; Ok(selection) } } /// Occurs when we cannot find a solution for selection. #[derive(Debug)] -pub enum IntoSelectionError { +pub enum IntoTxTemplateError { /// Coin selector returned an error Selector(SelectorError), /// Selection algorithm failed. @@ -223,22 +223,22 @@ pub enum IntoSelectionError { CannotMeetTarget(CannotMeetTarget), } -impl fmt::Display for IntoSelectionError { +impl fmt::Display for IntoTxTemplateError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - IntoSelectionError::Selector(error) => { + IntoTxTemplateError::Selector(error) => { write!(f, "{error}") } - IntoSelectionError::SelectionAlgorithm(error) => { + IntoTxTemplateError::SelectionAlgorithm(error) => { write!(f, "selection algorithm failed: {error}") } - IntoSelectionError::CannotMeetTarget(error) => write!(f, "{error}"), + IntoTxTemplateError::CannotMeetTarget(error) => write!(f, "{error}"), } } } #[cfg(feature = "std")] -impl std::error::Error for IntoSelectionError {} +impl std::error::Error for IntoTxTemplateError {} /// Select for lowest fee with bnb pub fn selection_algorithm_lowest_fee_bnb( diff --git a/src/lib.rs b/src/lib.rs index b60312ec..990d527c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,9 +19,9 @@ mod input_candidates; mod no_std_rand; mod output; mod rbf; -mod selection; mod selector; mod signer; +mod tx_template; pub use afs::*; pub use canonical_unspents::*; @@ -34,9 +34,9 @@ use miniscript::{DefiniteDescriptorKey, Descriptor}; use no_std_rand::*; pub use output::*; pub use rbf::*; -pub use selection::*; pub use selector::*; pub use signer::*; +pub use tx_template::*; #[cfg(feature = "std")] pub(crate) mod collections { diff --git a/src/selector.rs b/src/selector.rs index 4c98ec86..ef4e3ff6 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -4,7 +4,7 @@ use miniscript::bitcoin; use crate::{ DefiniteDescriptor, FeeRateExt, Input, InputCandidates, InputGroup, Output, ScriptSource, - Selection, + TxTemplate, }; use alloc::boxed::Box; use alloc::vec::Vec; @@ -486,7 +486,7 @@ impl<'c> Selector<'c> { /// Try get final selection. /// /// Return `None` if target is not met yet. - pub fn try_finalize(&self) -> Option { + pub fn try_finalize(&self) -> Option { if !self.inner.is_target_met(self.target) { return None; } @@ -506,7 +506,7 @@ impl<'c> Selector<'c> { Amount::from_sat(maybe_change.value), ))); } - Some(Selection::new(inputs, outputs)) + Some(TxTemplate::from_parts(inputs, outputs)) } } diff --git a/src/selection.rs b/src/tx_template.rs similarity index 97% rename from src/selection.rs rename to src/tx_template.rs index e1dd4100..685e1842 100644 --- a/src/selection.rs +++ b/src/tx_template.rs @@ -16,7 +16,7 @@ use crate::{ /// Final selection of inputs and outputs. #[derive(Debug, Clone)] #[must_use] -pub struct Selection { +pub struct TxTemplate { inputs: Vec, outputs: Vec, } @@ -49,7 +49,7 @@ pub struct PsbtParams { /// /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining the /// transaction to only be valid at or after the chain tip. When enabled, - /// [`Selection::create_psbt`] sets either the transaction's `nLockTime` or the `nSequence` of + /// [`TxTemplate::create_psbt`] sets either the transaction's `nLockTime` or the `nSequence` of /// one Taproot input to a value derived from `tip_height`. /// /// AFS only operates on a height-based `tx.lock_time`. If [`min_locktime`] or any input's @@ -61,7 +61,7 @@ pub struct PsbtParams { /// /// # Errors /// - /// When `Some(..)`, [`Selection::create_psbt`] returns [`CreatePsbtError::AntiFeeSniping`] if: + /// When `Some(..)`, [`TxTemplate::create_psbt`] returns [`CreatePsbtError::AntiFeeSniping`] if: /// - the transaction version is less than 2 /// ([`AntiFeeSnipingError::UnsupportedVersion`]) — v2 is required for relative locktimes; or /// - a time-based (MTP) locktime is in effect @@ -130,8 +130,8 @@ impl core::fmt::Display for CreatePsbtError { #[cfg(feature = "std")] impl std::error::Error for CreatePsbtError {} -impl Selection { - pub(crate) fn new(inputs: Vec, outputs: Vec) -> Self { +impl TxTemplate { + pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { Self { inputs, outputs } } @@ -379,7 +379,7 @@ mod tests { let (input, desc) = setup_cltv_input(abs_locktime)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -439,7 +439,7 @@ mod tests { let (input, desc) = setup_cltv_input(time_locktime)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -506,7 +506,7 @@ mod tests { let current_height = 2_500; let input = setup_test_input(2_000).unwrap(); let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); // Disabled - default behavior is disable let psbt = selection.create_psbt(PsbtParams { @@ -531,7 +531,7 @@ mod tests { while !used_locktime || !used_sequence { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input.clone()], vec![output]); + let selection = TxTemplate::from_parts(vec![input.clone()], vec![output]); let psbt = selection.create_psbt(PsbtParams { anti_fee_sniping: Some(tip), @@ -580,7 +580,7 @@ mod tests { let mut loops = 0; while !used_locktime || !used_sequence { - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input1.clone(), input2.clone(), input3.clone()], vec![output.clone()], ); @@ -622,7 +622,7 @@ mod tests { // Tip is well below the input's CLTV requirement. let tip = absolute::Height::from_consensus(50_000)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -696,7 +696,7 @@ mod tests { let mut observed_sequence_path = false; for _ in 0..100 { - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![regular_input.clone(), csv_input.clone()], vec![output.clone()], ); @@ -743,7 +743,7 @@ mod tests { let (input, desc) = setup_cltv_input(time_locktime)?; let tip = absolute::Height::from_consensus(800_000)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, From c460cbb7e4dec9af046170b2ba64e391f587f994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 20 May 2026 05:20:46 +0000 Subject: [PATCH 2/9] feat(tx-template)!: route tx-shape decisions through TxTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #57. TxTemplate now owns the resolved tx-shape fields and the methods that mutate them. The selector hands you a TxTemplate already configured with sensible defaults; everything else is method calls on it. New fields on TxTemplate: - version (default V2) - lock_time (= max(input CLTV) or ZERO) - fallback_sequence (default ENABLE_RBF_NO_LOCKTIME) New setters with validation: - set_version -> SetVersionError::RelativeTimelockRequiresV2 - set_locktime -> SetLockTimeError::{BelowInputCltv, UnitMismatch} - set_fallback_sequence The PSBT/AFS pipeline is restructured around these fields: - PsbtParams -> PsbtBuildParams (PSBT-only knobs; version/locktime /AFS removed) - CreatePsbtError -> BuildPsbtError - create_psbt(params) -> (Psbt, Finalizer) (was just Psbt) - anti-fee-sniping moves off PsbtParams::anti_fee_sniping into TxTemplate::apply_anti_fee_sniping(tip, &mut rng), a separate chainable step that composes the public set_locktime / Input::set_sequence - to_unsigned_tx() materializes the tx for non-PSBT signing flows Chain ergonomics: sort_inputs_by / shuffle_inputs (etc.) now consume self and return Self. into_finalizer is dropped — Finalizer comes from create_psbt or from Finalizer::new for callers that want it standalone. What was previously silent is now an explicit error: - min_locktime of the wrong unit was silently ignored - min_locktime below an input's CLTV was silently clamped up Both now error via SetLockTimeError. Setting v < 2 with a relative- timelock input errors via SetVersionError. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/anti_fee_sniping.rs | 9 +- examples/synopsis.rs | 13 +- src/afs.rs | 77 ++-- src/finalizer.rs | 38 +- src/input.rs | 2 +- src/input_candidates.rs | 8 +- src/selector.rs | 4 +- src/tx_template.rs | 852 +++++++++++++++++++++-------------- 8 files changed, 573 insertions(+), 430 deletions(-) diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 1c0a57e3..038ceb2a 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, SelectorParams, }; use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate}; @@ -90,10 +90,9 @@ fn main() -> anyhow::Result<()> { let selection_inputs = selection.inputs().to_vec(); - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip_height), - ..Default::default() - })?; + let (psbt, _) = selection + .apply_anti_fee_sniping(tip_height, &mut rand::thread_rng())? + .create_psbt(PsbtBuildParams::default())?; let tx = psbt.unsigned_tx; diff --git a/examples/synopsis.rs b/examples/synopsis.rs index c2685e8c..ae24a7b2 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -1,6 +1,6 @@ use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, SelectorParams, Signer, }; use bitcoin::{key::Secp256k1, Amount, FeeRate}; @@ -48,7 +48,7 @@ fn main() -> anyhow::Result<()> { .assume_checked(); // Okay now create tx. - let selection = wallet + let (mut psbt, finalizer) = wallet .all_candidates() .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_mtp))) @@ -66,10 +66,8 @@ fn main() -> anyhow::Result<()> { bdk_tx::ChangeScript::from_descriptor(internal.at_derivation_index(0)?), ) }, - )?; - - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + )? + .create_psbt(PsbtBuildParams::default())?; let _ = psbt.sign(&signer, &secp); let res = finalizer.finalize(&mut psbt); @@ -146,7 +144,6 @@ fn main() -> anyhow::Result<()> { }, )?; - let mut psbt = selection.create_psbt(PsbtParams::default())?; println!( "selected inputs: {:?}", selection @@ -156,7 +153,7 @@ fn main() -> anyhow::Result<()> { .collect::>() ); - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; psbt.sign(&signer, &secp).expect("failed to sign"); assert!( finalizer.finalize(&mut psbt).is_finalized(), diff --git a/src/afs.rs b/src/afs.rs index 9fd2bb03..427ba6ed 100644 --- a/src/afs.rs +++ b/src/afs.rs @@ -1,12 +1,12 @@ use crate::{ no_std_rand::{random_probability, random_range}, - Input, + TxTemplate, }; use alloc::vec::Vec; use miniscript::bitcoin::{ absolute::{self, LockTime}, transaction::Version, - Sequence, Transaction, + Sequence, }; use rand_core::RngCore; @@ -16,7 +16,7 @@ pub enum AntiFeeSnipingError { /// Transaction `version` must be >= 2 for AFS to use relative locktimes. UnsupportedVersion(Version), /// AFS only supports height-based locktimes. The transaction's locktime is - /// time-based (MTP), which can originate from either `PsbtParams::min_locktime` + /// time-based (MTP), which can originate from either `TxTemplateParams::min_locktime` /// or an input's time-based CLTV requirement. UnsupportedLockTime(absolute::LockTime), } @@ -73,79 +73,78 @@ impl std::error::Error for AntiFeeSnipingError {} /// # Errors /// - [`AntiFeeSnipingError::UnsupportedVersion`] if `tx.version < 2`. /// - [`AntiFeeSnipingError::UnsupportedLockTime`] if `tx.lock_time` is time-based -/// (either from `PsbtParams::min_locktime` or an input's time-based CLTV). +/// (either from `TxTemplateParams::min_locktime` or an input's time-based CLTV). /// /// # See Also /// [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki) pub(crate) fn apply_anti_fee_sniping( - tx: &mut Transaction, - inputs: &[Input], + mut template: TxTemplate, tip_height: absolute::Height, rng: &mut impl RngCore, -) -> Result<(), AntiFeeSnipingError> { +) -> Result { const MAX_RELATIVE_HEIGHT: u32 = 65_535; const FIFTY_PERCENT_PROBABILITY_RANGE: u32 = 2; const MIN_SEQUENCE_VALUE: u32 = 1; const TEN_PERCENT_PROBABILITY_RANGE: u32 = 10; const MAX_RANDOM_OFFSET: u32 = 100; - if tx.version < Version::TWO { - return Err(AntiFeeSnipingError::UnsupportedVersion(tx.version)); + if template.version() < Version::TWO { + return Err(AntiFeeSnipingError::UnsupportedVersion(template.version())); } - if !tx.lock_time.is_block_height() { - return Err(AntiFeeSnipingError::UnsupportedLockTime(tx.lock_time)); + if !template.lock_time().is_block_height() { + return Err(AntiFeeSnipingError::UnsupportedLockTime( + template.lock_time(), + )); } - let rbf_enabled = tx.is_explicitly_rbf(); + // A tx signals RBF if at least one input has `nSequence < 0xfffffffe`. + let fallback = template.fallback_sequence(); + let rbf_enabled = template + .inputs() + .iter() + .any(|input| input.sequence().unwrap_or(fallback).is_rbf()); - // vector of input_index and associated Input ref. - let taproot_inputs: Vec<(usize, &Input)> = tx - .input + // Indices of taproot inputs without a relative timelock — candidates for the nSequence path. + let taproot_inputs: Vec = template + .inputs() .iter() .enumerate() - .filter_map(|(vin, txin)| { - let input = inputs - .iter() - .find(|input| input.prev_outpoint() == txin.previous_output)?; - if input.prev_txout().script_pubkey.is_p2tr() && input.relative_timelock().is_none() { - Some((vin, input)) - } else { - None - } + .filter_map(|(i, input)| { + (input.prev_txout().script_pubkey.is_p2tr() && input.relative_timelock().is_none()) + .then_some(i) }) .collect(); // Conditions that force nLockTime (vs nSequence). let must_use_locktime = taproot_inputs.is_empty() - || inputs.iter().any(|input| { + || template.inputs().iter().any(|input| { let confirmation = input.confirmations(tip_height); confirmation == 0 || confirmation > MAX_RELATIVE_HEIGHT }); let use_locktime = !rbf_enabled || must_use_locktime - || taproot_inputs.is_empty() || random_probability(rng, FIFTY_PERCENT_PROBABILITY_RANGE); if use_locktime { - // Use nLockTime let mut afs_height = tip_height.to_consensus_u32(); - if random_probability(rng, TEN_PERCENT_PROBABILITY_RANGE) { let random_offset = random_range(rng, MAX_RANDOM_OFFSET); afs_height = afs_height.saturating_sub(random_offset); } - let afs_locktime = LockTime::from_height(afs_height).expect("must be valid Height"); - if tx.lock_time.is_implied_by(afs_locktime) { - tx.lock_time = afs_locktime; + // Only apply if it's a bump (i.e. doesn't regress an input's CLTV requirement). + if template.lock_time().is_implied_by(afs_locktime) { + template = template + .set_locktime(afs_locktime) + .expect("AFS picks a value ≥ current lock_time (same height-based unit)"); } } else { - // Use Sequence - let random_index = random_range(rng, taproot_inputs.len() as u32); - let (input_index, input) = taproot_inputs[random_index as usize]; - let confirmation = input.confirmations(tip_height); + let random_index = random_range(rng, taproot_inputs.len() as u32) as usize; + let input_index = taproot_inputs[random_index]; + let outpoint = template.inputs()[input_index].prev_outpoint(); + let confirmation = template.inputs()[input_index].confirmations(tip_height); let mut sequence_value = confirmation; if random_probability(rng, TEN_PERCENT_PROBABILITY_RANGE) { @@ -155,8 +154,12 @@ pub(crate) fn apply_anti_fee_sniping( .max(MIN_SEQUENCE_VALUE); } - tx.input[input_index].sequence = Sequence(sequence_value); + template + .input_mut(outpoint) + .expect("taproot input index resolved above") + .set_sequence(Sequence(sequence_value)) + .expect("AFS only picks inputs without timelock constraints"); } - Ok(()) + Ok(template) } diff --git a/src/finalizer.rs b/src/finalizer.rs index 21024619..e40daa01 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -14,29 +14,26 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// /// # Usage /// -/// Construct a [`Finalizer`] from a list of `(outpoint, plan)` pairs, or by calling -/// [`into_finalizer`] on a particular [`TxTemplate`]. Use [`finalize_input`] to finalize a single -/// input, or [`finalize`] to finalize every input and return a map containing the result of -/// finalization at each index. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata -/// from non-essential fields of the PSBT inputs and outputs, ensuring that only the necessary -/// information remains for transaction extraction. +/// A [`Finalizer`] is typically obtained alongside a PSBT from [`TxTemplate::create_psbt`]. +/// It can also be constructed directly from a list of `(outpoint, plan)` pairs via +/// [`Finalizer::new`]. Use [`finalize_input`] to finalize a single input, or [`finalize`] to +/// finalize every input. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata from +/// non-essential fields of the PSBT inputs and outputs. /// /// # Example /// /// ```rust,no_run -/// # use bdk_tx::PsbtParams; +/// # use bdk_tx::PsbtBuildParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); /// # let keymap = std::collections::BTreeMap::new(); /// # let template: bdk_tx::TxTemplate = unimplemented!(); -/// // Create PSBT from a selection of inputs and outputs. -/// let mut psbt = selection.create_psbt(PsbtParams::default())?; +/// let (mut psbt, finalizer) = template.create_psbt(PsbtBuildParams::default())?; /// /// // Sign the PSBT using your preferred method. /// let signer = bdk_tx::Signer(keymap); /// let _ = psbt.sign(&signer, &secp); /// /// // Finalize the PSBT. -/// let finalizer = selection.into_finalizer(); /// let finalize_map = finalizer.finalize(&mut psbt); /// assert!(finalize_map.is_finalized()); /// @@ -47,7 +44,7 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// /// [BIP174]: /// [`TxTemplate`]: crate::TxTemplate -/// [`into_finalizer`]: crate::TxTemplate::into_finalizer +/// [`TxTemplate::create_psbt`]: crate::TxTemplate::create_psbt /// [`Plan`]: miniscript::plan::Plan /// [`Transaction`]: bitcoin::Transaction /// [`finalize_input`]: Finalizer::finalize_input @@ -165,7 +162,7 @@ impl FinalizeMap { #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { - use crate::{Finalizer, Output, PsbtParams, Signer, TxTemplate}; + use crate::{Finalizer, Output, PsbtBuildParams, Signer, TxTemplate}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{absolute, transaction, Amount, ScriptBuf, TxIn, TxOut}; use miniscript::bitcoin; @@ -219,8 +216,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -239,8 +235,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -268,8 +263,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; assert!(!psbt.outputs[0].tap_key_origins.is_empty()); assert!(psbt.outputs[0].tap_internal_key.is_some()); @@ -323,7 +317,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; + let (mut psbt, _) = selection.create_psbt(PsbtBuildParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -363,8 +357,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -391,8 +384,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); diff --git a/src/input.rs b/src/input.rs index c50358d0..6f6b33df 100644 --- a/src/input.rs +++ b/src/input.rs @@ -668,7 +668,7 @@ impl Input { /// Mutable handle to an [`Input`] held inside a [`TxTemplate`]. /// /// Returned by [`TxTemplate::input_mut`] and [`TxTemplate::inputs_mut`]. This wrapper restricts -/// mutation to operations that preserve [`TxTemplate`]'s coin-selection invariants. +/// mutation to operations that preserve the template's invariants. /// /// Read-only access to the underlying [`Input`] is available via [`Deref`]. /// diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 75f90851..53f4e5a1 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -191,8 +191,7 @@ impl InputCandidates { self } - /// Attempt to convert the input candidates into a valid [`TxTemplate`] with a given - /// `algorithm` and selector `params`. + /// Run coin selection with `algorithm` and selector `params`, returning a [`TxTemplate`]. pub fn into_tx_template( self, algorithm: A, @@ -205,10 +204,9 @@ impl InputCandidates { selector .select_with_algorithm(algorithm) .map_err(IntoTxTemplateError::SelectionAlgorithm)?; - let selection = selector + selector .try_finalize() - .ok_or(IntoTxTemplateError::CannotMeetTarget(CannotMeetTarget))?; - Ok(selection) + .ok_or(IntoTxTemplateError::CannotMeetTarget(CannotMeetTarget)) } } diff --git a/src/selector.rs b/src/selector.rs index ef4e3ff6..a5324301 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -483,9 +483,9 @@ impl<'c> Selector<'c> { Some(has_drain) } - /// Try get final selection. + /// Try to finalize the selection into a [`TxTemplate`]. /// - /// Return `None` if target is not met yet. + /// Returns `None` if the target is not yet met. pub fn try_finalize(&self) -> Option { if !self.inner.is_target_met(self.target) { return None; diff --git a/src/tx_template.rs b/src/tx_template.rs index 685e1842..af59654d 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -1,10 +1,21 @@ +//! Tx-shaping stage between coin selection and the final [`Psbt`] or [`Transaction`]. +//! +//! A [`TxTemplate`] is obtained from [`Selector::try_finalize`] or +//! [`InputCandidates::into_tx_template`], then mutated (sort, shuffle, anti-fee-sniping, +//! set_version, set_locktime, set_fallback_sequence, per-input sequence overrides) before +//! being emitted as a PSBT or a [`Transaction`]. +//! +//! [`Selector::try_finalize`]: crate::Selector::try_finalize +//! [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template +//! [`Transaction`]: bitcoin::Transaction + use alloc::boxed::Box; use alloc::vec::Vec; use core::cmp::Ordering; -use core::fmt::{Debug, Display}; +use core::fmt::Display; use miniscript::bitcoin; -use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence}; +use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence, Transaction, TxIn}; use miniscript::psbt::PsbtExt; use rand_core::RngCore; @@ -13,80 +24,124 @@ use crate::{ Output, }; -/// Final selection of inputs and outputs. +/// Default `nSequence` for plan-based inputs that don't specify their own. +/// +/// Matches Bitcoin Core's wallet default (`0xfffffffd`, +/// [`Sequence::ENABLE_RBF_NO_LOCKTIME`]). +pub const FALLBACK_SEQUENCE: Sequence = Sequence::ENABLE_RBF_NO_LOCKTIME; + +/// Error returned by [`TxTemplate::set_version`]. +#[derive(Debug, Clone, PartialEq)] +pub enum SetVersionError { + /// A relative-timelock input requires `version >= 2` (BIP-68). + RelativeTimelockRequiresV2 { + /// The version the caller attempted to set. + attempted: transaction::Version, + }, +} + +impl Display for SetVersionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::RelativeTimelockRequiresV2 { attempted } => write!( + f, + "version {attempted} is invalid: an input has a relative timelock, which requires version >= 2 (BIP-68)", + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SetVersionError {} + +/// Error returned by [`TxTemplate::set_locktime`]. +#[derive(Debug, Clone, PartialEq)] +pub enum SetLockTimeError { + /// The provided lock_time is below an input's required CLTV. + BelowInputCltv { + /// CLTV required by an input. + required: absolute::LockTime, + /// LockTime the caller attempted to set. + attempted: absolute::LockTime, + }, + /// The provided lock_time uses a different unit (block-height vs. time) than an input's CLTV. + UnitMismatch { + /// CLTV required by an input. + input: absolute::LockTime, + /// LockTime the caller attempted to set. + attempted: absolute::LockTime, + }, +} + +impl Display for SetLockTimeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::BelowInputCltv { + required, + attempted, + } => write!( + f, + "lock_time {attempted} is below an input's required CLTV {required}", + ), + Self::UnitMismatch { input, attempted } => write!( + f, + "lock_time {attempted} has a different unit than an input's CLTV {input}", + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SetLockTimeError {} + +/// A fully-resolved tx shape — the workspace between coin selection and the final [`Psbt`] +/// or [`Transaction`]. +/// +/// Typically obtained from [`Selector::try_finalize`] (or +/// [`InputCandidates::into_tx_template`]). Exposes the operations that *shape* the resulting +/// transaction: input/output ordering, anti-fee-sniping, version/locktime overrides, and +/// final emission to PSBT or [`Transaction`]. +/// +/// New templates start with `version = TWO`, `lock_time = max(input CLTVs)` (or `ZERO`), and +/// `fallback_sequence = ENABLE_RBF_NO_LOCKTIME`. +/// +/// [`Selector::try_finalize`]: crate::Selector::try_finalize +/// [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template #[derive(Debug, Clone)] #[must_use] pub struct TxTemplate { + version: transaction::Version, + lock_time: absolute::LockTime, + fallback_sequence: Sequence, inputs: Vec, outputs: Vec, } -/// Parameters for creating a psbt. +/// Parameters for emitting a [`Psbt`] from a [`TxTemplate`]. +/// +/// Carries only PSBT-specific options. Transaction-shape decisions (version, locktime, +/// sequence, anti-fee-sniping, input/output ordering) all live on [`TxTemplate`]. #[derive(Debug, Clone)] -pub struct PsbtParams { - /// Use a specific [`transaction::Version`]. - pub version: transaction::Version, - - /// Minimum tx locktime — a floor on the resulting `tx.lock_time`. - /// - /// The final `tx.lock_time` is the maximum of this value and any absolute locktime required by - /// an input's CLTV, provided the locktime units agree. If `min_locktime` uses a different unit - /// (block-height vs. time) than an input's CLTV, it is ignored — a height-based `min_locktime` - /// will not be combined with a time-based CLTV (and vice versa). - pub min_locktime: absolute::LockTime, - - /// Whether to require the full tx, aka [`non_witness_utxo`] for segwit v0 inputs. +pub struct PsbtBuildParams { + /// Whether to require the full tx (aka [`non_witness_utxo`]) for segwit v0 inputs. /// - /// Default is `true`. + /// Default: `true`. /// /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo pub mandate_full_tx_for_segwit_v0: bool, - - /// Apply BIP-326 anti-fee-sniping (AFS) protection, using the given block height. - /// - /// * `None` (default) — no AFS is applied. - /// * `Some(tip_height)` — AFS is applied with `tip_height` as the current chain tip. - /// - /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining the - /// transaction to only be valid at or after the chain tip. When enabled, - /// [`TxTemplate::create_psbt`] sets either the transaction's `nLockTime` or the `nSequence` of - /// one Taproot input to a value derived from `tip_height`. - /// - /// AFS only operates on a height-based `tx.lock_time`. If [`min_locktime`] or any input's - /// CLTV is time-based, enabling AFS produces [`AntiFeeSnipingError::UnsupportedLockTime`]. - /// - /// If `tx.lock_time` is already a block height greater than `tip_height` (e.g., because an - /// input's CLTV pins the tx to a future block), AFS leaves the transaction unchanged — the - /// existing CLTV already provides equivalent protection. - /// - /// # Errors - /// - /// When `Some(..)`, [`TxTemplate::create_psbt`] returns [`CreatePsbtError::AntiFeeSniping`] if: - /// - the transaction version is less than 2 - /// ([`AntiFeeSnipingError::UnsupportedVersion`]) — v2 is required for relative locktimes; or - /// - a time-based (MTP) locktime is in effect - /// ([`AntiFeeSnipingError::UnsupportedLockTime`]) — AFS only supports height-based locktimes. - /// - /// See [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki) for more details. - /// - /// [`min_locktime`]: Self::min_locktime - pub anti_fee_sniping: Option, } -impl Default for PsbtParams { +impl Default for PsbtBuildParams { fn default() -> Self { Self { - version: transaction::Version::TWO, - min_locktime: absolute::LockTime::ZERO, mandate_full_tx_for_segwit_v0: true, - anti_fee_sniping: None, } } } -/// Occurs when creating a psbt fails. +/// Error returned by [`TxTemplate::create_psbt`]. #[derive(Debug)] -pub enum CreatePsbtError { +pub enum BuildPsbtError { /// Missing tx for legacy input. MissingFullTxForLegacyInput(Box), /// Missing tx for segwit v0 input. @@ -95,61 +150,138 @@ pub enum CreatePsbtError { Psbt(bitcoin::psbt::Error), /// Update psbt output with descriptor error. OutputUpdate(miniscript::psbt::OutputUpdateError), - /// Occurs when applying anti-fee-sniping fails. - AntiFeeSniping(AntiFeeSnipingError), } -impl From for CreatePsbtError { - fn from(e: AntiFeeSnipingError) -> Self { - Self::AntiFeeSniping(e) - } -} - -impl core::fmt::Display for CreatePsbtError { +impl Display for BuildPsbtError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - CreatePsbtError::MissingFullTxForLegacyInput(input) => write!( + Self::MissingFullTxForLegacyInput(input) => write!( f, "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", input.prev_outpoint() ), - CreatePsbtError::MissingFullTxForSegwitV0Input(input) => write!( + Self::MissingFullTxForSegwitV0Input(input) => write!( f, "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", input.prev_outpoint() ), - CreatePsbtError::Psbt(error) => Display::fmt(&error, f), - CreatePsbtError::OutputUpdate(output_update_error) => { - Display::fmt(&output_update_error, f) - } - CreatePsbtError::AntiFeeSniping(e) => Display::fmt(e, f), + Self::Psbt(e) => Display::fmt(e, f), + Self::OutputUpdate(e) => Display::fmt(e, f), } } } #[cfg(feature = "std")] -impl std::error::Error for CreatePsbtError {} +impl std::error::Error for BuildPsbtError {} impl TxTemplate { pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { - Self { inputs, outputs } + let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); + Self { + version: transaction::Version::TWO, + lock_time, + fallback_sequence: FALLBACK_SEQUENCE, + inputs, + outputs, + } + } + + /// Resolved transaction version. + pub fn version(&self) -> transaction::Version { + self.version + } + + /// Override `tx.version`. + /// + /// Default is [`transaction::Version::TWO`]. Setting a different value is allowed only when + /// no input has a relative timelock (BIP-68 requires v2 in that case). + /// + /// # Errors + /// + /// - [`SetVersionError::RelativeTimelockRequiresV2`] if `version < 2` and any input has a + /// relative timelock. + pub fn set_version(mut self, version: transaction::Version) -> Result { + if version < transaction::Version::TWO + && self.inputs.iter().any(|i| i.relative_timelock().is_some()) + { + return Err(SetVersionError::RelativeTimelockRequiresV2 { attempted: version }); + } + self.version = version; + Ok(self) + } + + /// Resolved transaction lock_time. + pub fn lock_time(&self) -> absolute::LockTime { + self.lock_time + } + + /// Set the fallback `nSequence` used for inputs that don't specify their own. + /// + /// The fallback is applied lazily at materialization (in [`Self::to_unsigned_tx`] and + /// [`Self::create_psbt`]); calling this method after other transformations does not + /// retroactively change inputs whose sequence has already been set explicitly (e.g. by + /// [`apply_anti_fee_sniping`](Self::apply_anti_fee_sniping)). + pub fn set_fallback_sequence(mut self, sequence: Sequence) -> Self { + self.fallback_sequence = sequence; + self + } + + /// Override `tx.lock_time`. + /// + /// Returns an error if `lock_time` would conflict with an input's required CLTV + /// (either by being below the requirement, or by using a different unit). + /// + /// Setting `lock_time` to a non-zero value when *every* input has `nSequence::MAX` + /// is *not* rejected, but per BIP-65 / Bitcoin's `IsFinalTx` rule the lock_time will + /// then be ignored at validation time. + /// + /// # Errors + /// + /// - [`SetLockTimeError::BelowInputCltv`] if `lock_time < required` (same unit). + /// - [`SetLockTimeError::UnitMismatch`] if `lock_time` is height-based and an input's + /// CLTV is time-based (or vice versa). + pub fn set_locktime(mut self, lock_time: absolute::LockTime) -> Result { + for input in &self.inputs { + let Some(required) = input.absolute_timelock() else { + continue; + }; + if !required.is_same_unit(lock_time) { + return Err(SetLockTimeError::UnitMismatch { + input: required, + attempted: lock_time, + }); + } + if !required.is_implied_by(lock_time) { + return Err(SetLockTimeError::BelowInputCltv { + required, + attempted: lock_time, + }); + } + } + self.lock_time = lock_time; + Ok(self) } - /// Inputs in this selection. + /// Fallback `nSequence` applied to inputs that don't specify their own. + pub fn fallback_sequence(&self) -> Sequence { + self.fallback_sequence + } + + /// Inputs in this template. pub fn inputs(&self) -> &[Input] { &self.inputs } - /// Outputs in this selection. + /// Outputs in this template. pub fn outputs(&self) -> &[Output] { &self.outputs } /// Mutable handle to the input spending `outpoint`, if any. /// - /// Returns [`None`] if no input in this selection spends `outpoint`. The returned - /// [`InputMut`] only permits mutations that preserve the selection's coin-selection - /// invariants — see [`InputMut`] for the available operations. + /// Returns [`None`] if no input in this template spends `outpoint`. The returned + /// [`InputMut`] only permits mutations that preserve the template's invariants — see + /// [`InputMut`] for the available operations. pub fn input_mut(&mut self, outpoint: OutPoint) -> Option> { self.inputs .iter_mut() @@ -157,124 +289,115 @@ impl TxTemplate { .map(InputMut::new) } - /// Iterator yielding a mutable handle to every input in this selection. + /// Iterator yielding a mutable handle to every input in this template. /// - /// Each yielded [`InputMut`] only permits mutations that preserve the selection's - /// coin-selection invariants — see [`InputMut`] for the available operations. + /// Each yielded [`InputMut`] only permits mutations that preserve the template's + /// invariants — see [`InputMut`] for the available operations. pub fn inputs_mut(&mut self) -> impl Iterator> { self.inputs.iter_mut().map(InputMut::new) } - /// Reorder inputs in-place using `compare`. + /// Reorder inputs using `compare`. Uses a stable sort. /// - /// Uses a stable sort: inputs that compare equal retain their relative order. /// Typical use is BIP-69 lexicographic ordering by previous outpoint. - pub fn sort_inputs_by(&mut self, compare: F) + pub fn sort_inputs_by(mut self, compare: F) -> Self where F: FnMut(&Input, &Input) -> Ordering, { self.inputs.sort_by(compare); + self } - /// Randomly shuffle inputs in-place using `rng`. + /// Randomly shuffle inputs using `rng`. /// /// Useful for chain-analysis resistance when no deterministic ordering is required. - pub fn shuffle_inputs(&mut self, rng: &mut R) { + pub fn shuffle_inputs(mut self, rng: &mut R) -> Self { fisher_yates_shuffle(&mut self.inputs, rng); + self } - /// Reorder outputs in-place using `compare`. + /// Reorder outputs using `compare`. Uses a stable sort. /// - /// Uses a stable sort: outputs that compare equal retain their relative order. /// Typical use is BIP-69 (ascending by amount, then by `script_pubkey`). - pub fn sort_outputs_by(&mut self, compare: F) + pub fn sort_outputs_by(mut self, compare: F) -> Self where F: FnMut(&Output, &Output) -> Ordering, { self.outputs.sort_by(compare); + self } - /// Randomly shuffle outputs in-place using `rng`. + /// Randomly shuffle outputs using `rng`. /// - /// Useful for chain-analysis resistance — in particular, hiding which output - /// is the change. - pub fn shuffle_outputs(&mut self, rng: &mut R) { + /// Useful for chain-analysis resistance — in particular, hiding which output is the + /// change. + pub fn shuffle_outputs(mut self, rng: &mut R) -> Self { fisher_yates_shuffle(&mut self.outputs, rng); + self } - /// Accumulates the maximum locktime from an iterator of input-required locktimes. - /// - /// Returns `min_locktime` if the locktimes iterator is empty, otherwise the maximum locktime - /// across the inputs (with `min_locktime` only applied when compatible with the inputs' unit). + /// Materialize the unsigned `bitcoin::Transaction` represented by this template. /// - /// # Panics - /// - /// In debug builds, panics if `locktimes` contains values with different units (height vs. - /// time). `Selector::new` rejects such candidates upstream, so this should never fire in - /// practice. - fn accumulate_max_locktime( - locktimes: impl IntoIterator, - min_locktime: absolute::LockTime, - ) -> absolute::LockTime { - // Accumulate locktimes required by inputs. An input-vs-input unit mismatch is rejected - // upstream by `Selector::new`. `min_locktime` is only used when it is compatible with - // the input requirements; a different unit is intentionally ignored so that, e.g., a - // height-based `min_locktime` does not conflict with a time-based CLTV requirement. - let inputs_max = locktimes.into_iter().reduce(|a, b| { - debug_assert!( - a.is_same_unit(b), - "Selector::new should reject mixed-unit candidates", - ); - if a.is_implied_by(b) { - b - } else { - a - } - }); - match inputs_max { - Some(lt) if lt.is_implied_by(min_locktime) => min_locktime, - Some(lt) => lt, - None => min_locktime, - } - } - - /// Create PSBT. - #[cfg(feature = "std")] - pub fn create_psbt(&self, params: PsbtParams) -> Result { - self.create_psbt_with_rng(params, &mut rand::thread_rng()) - } - - /// Create PSBT with `rng`. - pub fn create_psbt_with_rng( - &self, - params: PsbtParams, - rng: &mut impl RngCore, - ) -> Result { - let mut tx = bitcoin::Transaction { - version: params.version, - lock_time: Self::accumulate_max_locktime( - self.inputs - .iter() - .filter_map(|input| input.absolute_timelock()), - params.min_locktime, - ), + /// Each input's `nSequence` is its own [`Input::sequence`] if set, otherwise + /// [`fallback_sequence`](Self::fallback_sequence). + pub fn to_unsigned_tx(&self) -> Transaction { + Transaction { + version: self.version, + lock_time: self.lock_time, input: self .inputs .iter() - .map(|input| bitcoin::TxIn { + .map(|input| TxIn { previous_output: input.prev_outpoint(), - sequence: input.sequence().unwrap_or(Sequence::ENABLE_RBF_NO_LOCKTIME), + sequence: input.sequence().unwrap_or(self.fallback_sequence), ..Default::default() }) .collect(), - output: self.outputs.iter().map(|output| output.txout()).collect(), - }; + output: self.outputs.iter().map(Output::txout).collect(), + } + } - if let Some(tip_height) = params.anti_fee_sniping { - apply_anti_fee_sniping(&mut tx, &self.inputs, tip_height, rng)?; - }; + /// Apply BIP-326 anti-fee-sniping (AFS) protection using `tip_height` as the chain tip. + /// + /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining + /// the transaction to only be valid at or after the chain tip. This sets either + /// `tx.lock_time` (via [`set_locktime`](Self::set_locktime)) or the `nSequence` of one + /// Taproot input (via [`Input::set_sequence`]). + /// + /// AFS only operates on a height-based `tx.lock_time`. If any input's CLTV is time-based, + /// this returns [`AntiFeeSnipingError::UnsupportedLockTime`]. + /// + /// If `tx.lock_time` is already a block height greater than `tip_height` (e.g., because an + /// input's CLTV pins the tx to a future block), this leaves the template unchanged. + /// + /// See [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki). + /// + /// # Errors + /// + /// - [`AntiFeeSnipingError::UnsupportedVersion`] if `version < 2`. + /// - [`AntiFeeSnipingError::UnsupportedLockTime`] if `lock_time` is time-based. + pub fn apply_anti_fee_sniping( + self, + tip_height: absolute::Height, + rng: &mut R, + ) -> Result { + apply_anti_fee_sniping(self, tip_height, rng) + } - let mut psbt = Psbt::from_unsigned_tx(tx).map_err(CreatePsbtError::Psbt)?; + /// Build the [`Psbt`] and its associated [`Finalizer`]. + #[cfg(feature = "std")] + pub fn create_psbt(self, params: PsbtBuildParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { + self.create_psbt_with_rng(params, &mut rand::thread_rng()) + } + + /// Build the [`Psbt`] and its associated [`Finalizer`] with a custom `rng`. + pub fn create_psbt_with_rng( + self, + params: PsbtBuildParams, + _rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), BuildPsbtError> { + let tx = self.to_unsigned_tx(); + let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) { if let Some(finalized_psbt_input) = plan_input.psbt_input() { @@ -288,49 +411,66 @@ impl TxTemplate { if witness_version.is_some() { psbt_input.witness_utxo = Some(plan_input.prev_txout().clone()); } - // We are allowed to have full tx for segwit inputs. Might as well include it. - // If the caller does not wish to include the full tx in Segwit V0 inputs, they should not - // include it in `crate::Input`. psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); if psbt_input.non_witness_utxo.is_none() { if witness_version.is_none() { - return Err(CreatePsbtError::MissingFullTxForLegacyInput(Box::new( + return Err(BuildPsbtError::MissingFullTxForLegacyInput(Box::new( plan_input.clone(), ))); } if params.mandate_full_tx_for_segwit_v0 && witness_version == Some(bitcoin::WitnessVersion::V0) { - return Err(CreatePsbtError::MissingFullTxForSegwitV0Input(Box::new( + return Err(BuildPsbtError::MissingFullTxForSegwitV0Input(Box::new( plan_input.clone(), ))); } } - continue; } unreachable!("input candidate must either have finalized psbt input or plan"); } + for (output_index, output) in self.outputs.iter().enumerate() { if let Some(desc) = output.descriptor() { psbt.update_output_with_descriptor(output_index, desc) - .map_err(CreatePsbtError::OutputUpdate)?; + .map_err(BuildPsbtError::OutputUpdate)?; } } - Ok(psbt) - } + let finalizer = Finalizer::new(self.inputs.into_iter().filter_map(|input| { + let outpoint = input.prev_outpoint(); + let plan = input.plan().cloned()?; + Some((outpoint, plan)) + })); - /// Into psbt finalizer. - pub fn into_finalizer(self) -> Finalizer { - Finalizer::new( - self.inputs - .iter() - .filter_map(|input| Some((input.prev_outpoint(), input.plan().cloned()?))), - ) + Ok((psbt, finalizer)) } } +/// Maximum CLTV requirement across `inputs`, or `None` if no input has a CLTV. +/// +/// # Panics +/// +/// In debug builds, panics if inputs have CLTVs of different units (height vs. time). +/// `Selector::new` rejects such candidates upstream, so this should never fire in practice. +fn max_input_cltv(inputs: &[Input]) -> Option { + inputs + .iter() + .filter_map(Input::absolute_timelock) + .reduce(|a, b| { + debug_assert!( + a.is_same_unit(b), + "Selector::new should reject mixed-unit candidates", + ); + if a.is_implied_by(b) { + b + } else { + a + } + }) +} + #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { @@ -339,10 +479,11 @@ mod tests { absolute::{self, LockTime, Time}, relative, secp256k1::Secp256k1, - transaction::{self, Version}, - Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, + transaction::Version, + Amount, ScriptBuf, Transaction, TxIn, TxOut, }; use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + use rand::thread_rng; use rand_core::OsRng; const TEST_DESCRIPTOR: &str = "tr([83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*)"; @@ -361,7 +502,7 @@ mod tests { .plan(&Assets::new().add(desc_pk).after(cltv)) .unwrap(); let prev_tx = Transaction { - version: transaction::Version::TWO, + version: Version::TWO, lock_time: absolute::LockTime::ZERO, input: vec![TxIn::default()], output: vec![TxOut { @@ -373,12 +514,40 @@ mod tests { Ok((input, desc)) } - #[test] - fn test_min_locktime_height() -> anyhow::Result<()> { - let abs_locktime = absolute::LockTime::from_consensus(100_000); + fn setup_test_input(confirmation_height: u32) -> anyhow::Result { + let secp = Secp256k1::new(); + let desc = Descriptor::parse_descriptor(&secp, TEST_DESCRIPTOR) + .unwrap() + .0; + let def_desc = desc.at_derivation_index(0).unwrap(); + let script_pubkey = def_desc.script_pubkey(); + let desc_pk: DescriptorPublicKey = TEST_DESCRIPTOR_PK.parse()?; + let assets = Assets::new().add(desc_pk); + let plan = def_desc.plan(&assets).expect("failed to create plan"); - let (input, desc) = setup_cltv_input(abs_locktime)?; + let prev_tx = Transaction { + version: Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey, + value: Amount::from_sat(10_000), + }], + }; + + let status = crate::ConfirmationStatus { + height: absolute::Height::from_consensus(confirmation_height)?, + prev_mtp: Some(Time::from_consensus(500_000_000)?), + }; + Ok(Input::from_prev_tx(plan, prev_tx, 0, Some(status))?) + } + + /// Construction takes lock_time from the input CLTV; `set_locktime` bumps it above. + #[test] + fn set_locktime_height_above_input_cltv() -> anyhow::Result<()> { + let cltv = absolute::LockTime::from_consensus(100_000); + let (input, desc) = setup_cltv_input(cltv)?; let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( @@ -387,58 +556,49 @@ mod tests { )], ); - struct TestCase { - name: &'static str, - psbt_params: PsbtParams, - exp_locktime: u32, - } + let template = selection.clone(); + assert_eq!( + template.lock_time(), + cltv, + "construction takes lock_time from input CLTV" + ); - let cases = vec![ - TestCase { - name: "no min_locktime, use plan locktime", - psbt_params: PsbtParams::default(), - exp_locktime: 100_000, - }, - TestCase { - name: "larger min_locktime is used", - psbt_params: PsbtParams { - min_locktime: absolute::LockTime::from_consensus(100_100), - ..Default::default() - }, - exp_locktime: 100_100, - }, - TestCase { - name: "smaller min_locktime is ignored", - psbt_params: PsbtParams { - min_locktime: absolute::LockTime::from_consensus(99_900), - ..Default::default() - }, - exp_locktime: 100_000, - }, - ]; + let higher = absolute::LockTime::from_consensus(100_100); + let bumped = selection.set_locktime(higher)?; + assert_eq!(bumped.lock_time(), higher); - for test in cases { - let psbt = selection.create_psbt(test.psbt_params)?; - assert_eq!( - psbt.unsigned_tx.lock_time.to_consensus_u32(), - test.exp_locktime, - "Test failed {}", - test.name, - ); - } + Ok(()) + } + + /// `set_locktime` rejects values below an input's CLTV requirement. + #[test] + fn set_locktime_below_input_cltv_errors() -> anyhow::Result<()> { + let cltv = absolute::LockTime::from_consensus(100_000); + let (input, desc) = setup_cltv_input(cltv)?; + let selection = TxTemplate::from_parts( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); + let too_low = absolute::LockTime::from_consensus(99_999); + let result = selection.set_locktime(too_low); + + assert!(matches!( + result, + Err(SetLockTimeError::BelowInputCltv { required, attempted }) + if required == cltv && attempted == too_low + )); Ok(()) } - /// Tests that a height-based `min_locktime` is ignored when the input - /// requires a time-based (UNIX timestamp) CLTV, and that an explicit time-based - /// `min_locktime` greater than the requirement is respected. + /// A time-based input CLTV propagates to the template's lock_time at construction. #[test] - fn test_min_locktime_respects_lock_type() -> anyhow::Result<()> { + fn lock_time_takes_time_based_cltv_from_input() -> anyhow::Result<()> { let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); - let (input, desc) = setup_cltv_input(time_locktime)?; - let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( @@ -447,75 +607,50 @@ mod tests { )], ); - // Default `min_locktime` is height 0 (block-height unit). It is incompatible with - // the time-based CLTV requirement, so it must be ignored. - let psbt = selection.create_psbt(PsbtParams::default())?; - assert_eq!( - psbt.unsigned_tx.lock_time, time_locktime, - "time-based CLTV requirement should be used; height-based `min_locktime` must be ignored", - ); - - // An explicit time-based `min_locktime` *greater* than the requirement should be respected. - let larger_time = absolute::LockTime::from_consensus(1_772_167_108); - assert!(larger_time > time_locktime); - let psbt = selection.create_psbt(PsbtParams { - min_locktime: larger_time, - ..Default::default() - })?; - assert_eq!( - psbt.unsigned_tx.lock_time, larger_time, - "a larger time-based `min_locktime` should override the CLTV requirement", - ); + assert_eq!(selection.lock_time(), time_locktime); Ok(()) } - pub fn setup_test_input(confirmation_height: u32) -> anyhow::Result { - let secp = Secp256k1::new(); - let desc = Descriptor::parse_descriptor(&secp, TEST_DESCRIPTOR) - .unwrap() - .0; - let def_desc = desc.at_derivation_index(0).unwrap(); - let script_pubkey = def_desc.script_pubkey(); - let desc_pk: DescriptorPublicKey = TEST_DESCRIPTOR_PK.parse()?; - let assets = Assets::new().add(desc_pk); - let plan = def_desc.plan(&assets).expect("failed to create plan"); - - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey, - value: Amount::from_sat(10_000), - }], - }; - - let status = crate::ConfirmationStatus { - height: absolute::Height::from_consensus(confirmation_height)?, - prev_mtp: Some(Time::from_consensus(500_000_000)?), - }; + /// `set_locktime` errors when the supplied unit conflicts with an input's CLTV unit. + #[test] + fn set_locktime_unit_mismatch_errors() -> anyhow::Result<()> { + let height_cltv = absolute::LockTime::from_consensus(100_000); + let (input, desc) = setup_cltv_input(height_cltv)?; + let selection = TxTemplate::from_parts( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); - let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + let time_attempt = absolute::LockTime::from_consensus(1_734_230_218); + let result = selection.set_locktime(time_attempt); - Ok(input) + assert!(matches!( + result, + Err(SetLockTimeError::UnitMismatch { input, attempted }) + if input == height_cltv && attempted == time_attempt + )); + Ok(()) } + /// `set_locktime` propagates the chosen value through PSBT creation. #[test] - fn test_anti_fee_sniping_disabled() -> anyhow::Result<()> { + fn set_locktime_propagates_to_psbt() -> anyhow::Result<()> { let current_height = 2_500; - let input = setup_test_input(2_000).unwrap(); + let input = setup_test_input(2_000)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - // Disabled - default behavior is disable - let psbt = selection.create_psbt(PsbtParams { - min_locktime: absolute::LockTime::from_consensus(current_height), - ..Default::default() - })?; - let tx = psbt.unsigned_tx; - assert_eq!(tx.lock_time.to_consensus_u32(), current_height); - + let (psbt, _) = selection + .set_locktime(absolute::LockTime::from_consensus(current_height))? + .create_psbt(PsbtBuildParams::default())?; + assert_eq!( + psbt.unsigned_tx.lock_time.to_consensus_u32(), + current_height + ); Ok(()) } @@ -533,10 +668,9 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input.clone()], vec![output]); - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .create_psbt(PsbtBuildParams::default())?; let tx = psbt.unsigned_tx; @@ -545,19 +679,14 @@ mod tests { let locktime_value = tx.lock_time.to_consensus_u32(); let min_height = current_height.saturating_sub(100); assert!((min_height..=current_height).contains(&locktime_value)); - assert!(locktime_value <= current_height); - assert!(locktime_value >= current_height.saturating_sub(100)); } else { used_sequence = true; let sequence_value = tx.input[0].sequence.to_consensus_u32(); let confirmations = input.confirmations(absolute::Height::from_consensus(current_height).unwrap()); - let min_sequence = confirmations.saturating_sub(100); assert!((min_sequence..=confirmations).contains(&sequence_value)); assert!(sequence_value >= 1, "Sequence must be at least 1"); - assert!(sequence_value <= confirmations); - assert!(sequence_value >= confirmations.saturating_sub(100)); } loops += 1; @@ -584,11 +713,10 @@ mod tests { vec![input1.clone(), input2.clone(), input3.clone()], vec![output.clone()], ); - let psbt = selection - .create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - }) + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng()) + .unwrap() + .create_psbt(PsbtBuildParams::default()) .unwrap(); let tx = psbt.unsigned_tx; @@ -597,7 +725,6 @@ mod tests { used_locktime = true; } else { used_sequence = true; - // One of the inputs should have modified sequence let has_modified_sequence = tx.input.iter().any(|txin| { let seq = txin.sequence.to_consensus_u32(); seq > 0 && seq < 65_535 @@ -613,13 +740,12 @@ mod tests { } } - /// Regression: pre-fix, the AFS nLockTime path could overwrite `tx.lock_time` with a value + /// Regression: pre-fix, AFS's nLockTime path could overwrite `tx.lock_time` with a value /// lower than an input's required CLTV. #[test] fn test_anti_fee_sniping_preserves_input_cltv() -> anyhow::Result<()> { let cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(cltv)?; - // Tip is well below the input's CLTV requirement. let tip = absolute::Height::from_consensus(50_000)?; let selection = TxTemplate::from_parts( @@ -630,13 +756,11 @@ mod tests { )], ); - // The input is wsh (not Taproot), so AFS deterministically takes the locktime path; loop a - // few times anyway as cheap insurance against future control-flow changes. for _ in 0..100 { - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; + let (psbt, _) = selection + .clone() + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .create_psbt(PsbtBuildParams::default())?; assert_eq!( psbt.unsigned_tx.lock_time, cltv, "AFS must not overwrite an input's CLTV with a lower value", @@ -646,22 +770,16 @@ mod tests { Ok(()) } - /// Regression: pre-fix, the AFS nSequence path could pick a Taproot input that already carried - /// a CSV (relative-timelock) requirement and overwrite its sequence. The presence of a regular - /// Taproot input ensures the sequence path remains reachable — so the test also catches a - /// regression where AFS degrades to "never use the sequence path." + /// Regression: pre-fix, AFS's nSequence path could pick a Taproot input that already carried + /// a CSV (relative-timelock) requirement and overwrite its sequence. #[test] fn test_anti_fee_sniping_skips_taproot_csv_input() -> anyhow::Result<()> { let tip = absolute::Height::from_consensus(3_000)?; let csv_blocks = 10; - // Input A: regular Taproot, no CSV. let regular_input = setup_test_input(2_500)?; let regular_outpoint = regular_input.prev_outpoint(); - // Input B: Taproot whose script-path requires CSV. The internal key is omitted from - // `assets`, forcing planning to use the script-path leaf (which sets - // `plan.relative_timelock`). let secp = Secp256k1::new(); let desc_str = format!("tr({TEST_HEX_PK},and_v(v:pk({TEST_DESCRIPTOR_PK}),older({csv_blocks})))"); @@ -691,8 +809,6 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(18_000)); - // We will run AFS for 100 rounds. - // Track whether AFS's nSequence path actually fired for at least one of the rounds. let mut observed_sequence_path = false; for _ in 0..100 { @@ -700,10 +816,9 @@ mod tests { vec![regular_input.clone(), csv_input.clone()], vec![output.clone()], ); - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .create_psbt(PsbtBuildParams::default())?; let tx = psbt.unsigned_tx; let csv_txin = tx @@ -721,22 +836,20 @@ mod tests { .iter() .find(|t| t.previous_output == regular_outpoint) .expect("regular input must be present"); - if regular_txin.sequence != Sequence::ENABLE_RBF_NO_LOCKTIME { + if regular_txin.sequence != FALLBACK_SEQUENCE { observed_sequence_path = true; } } assert!( observed_sequence_path, - "AFS nSequence path must fire at least once across the 100 trials (otherwise the \ - CSV-preservation check above doesn't exercise the candidate-pool exclusion)", + "AFS nSequence path must fire at least once across the 100 trials", ); Ok(()) } - /// A time-based CLTV propagates to `tx.lock_time`; AFS only supports height-based locktimes, so - /// it must surface `UnsupportedLockTime`. + /// A time-based CLTV propagates to `tx.lock_time`; AFS only supports height-based locktimes. #[test] fn test_anti_fee_sniping_rejects_time_based_locktime() -> anyhow::Result<()> { let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); @@ -751,51 +864,92 @@ mod tests { )], ); - let result = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - }); + let result = selection.apply_anti_fee_sniping(tip, &mut thread_rng()); assert!(matches!( result, - Err(CreatePsbtError::AntiFeeSniping(AntiFeeSnipingError::UnsupportedLockTime(lt))) - if lt == time_locktime + Err(AntiFeeSnipingError::UnsupportedLockTime(lt)) if lt == time_locktime )); Ok(()) } #[test] - fn test_anti_fee_sniping_unsupported_version_error() { + fn test_anti_fee_sniping_unsupported_version_error() -> anyhow::Result<()> { let confirmation_height = 800_000; - let input = setup_test_input(confirmation_height).unwrap(); - let inputs = vec![input]; - let current_height = absolute::Height::from_consensus(confirmation_height + 50).unwrap(); - - let mut tx = Transaction { - version: Version::ONE, - lock_time: LockTime::from_height(current_height.to_consensus_u32()).unwrap(), - input: vec![TxIn { - previous_output: inputs[0].prev_outpoint(), - ..Default::default() + let input = setup_test_input(confirmation_height)?; + let current_height = absolute::Height::from_consensus(confirmation_height + 50)?; + + let selection = TxTemplate::from_parts( + vec![input], + vec![Output::with_script( + ScriptBuf::new(), + Amount::from_sat(9_000), + )], + ); + + let result = selection + .set_version(Version::ONE)? + .apply_anti_fee_sniping(current_height, &mut OsRng); + + assert!(matches!( + result, + Err(AntiFeeSnipingError::UnsupportedVersion(_)) + )); + + Ok(()) + } + + /// `set_version` rejects v1 when any input has a relative timelock (BIP-68). + #[test] + fn set_version_below_two_with_relative_timelock_errors() -> anyhow::Result<()> { + let csv_blocks = 10; + let secp = Secp256k1::new(); + let desc_str = + format!("tr({TEST_HEX_PK},and_v(v:pk({TEST_DESCRIPTOR_PK}),older({csv_blocks})))"); + let desc = Descriptor::parse_descriptor(&secp, &desc_str)? + .0 + .at_derivation_index(0)?; + let prev_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: desc.script_pubkey(), + value: Amount::from_sat(10_000), }], - output: vec![], }; + let assets = Assets::new() + .add(TEST_DESCRIPTOR_PK.parse::()?) + .older(relative::LockTime::from_height(csv_blocks)); + let plan = desc.plan(&assets).expect("script-path plan with CSV"); + let status = crate::ConfirmationStatus { + height: absolute::Height::from_consensus(2_500)?, + prev_mtp: Some(Time::from_consensus(500_000_000)?), + }; + let csv_input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; - let result = apply_anti_fee_sniping(&mut tx, &inputs, current_height, &mut OsRng); + let selection = TxTemplate::from_parts( + vec![csv_input], + vec![Output::with_script( + ScriptBuf::new(), + Amount::from_sat(9_000), + )], + ); - assert!( - matches!(result, Err(AntiFeeSnipingError::UnsupportedVersion(_))), - "should return UnsupportedVersion error for version < 2" + assert_eq!( + selection.version(), + Version::TWO, + "construction defaults to v2", ); - } - #[test] - fn test_fisher_yates_shuffle_preserves_multiset() { - let original: Vec = (0..32).collect(); - let mut shuffled = original.clone(); - fisher_yates_shuffle(&mut shuffled, &mut OsRng); - shuffled.sort(); - assert_eq!(shuffled, original); + let result = selection.set_version(Version::ONE); + assert!(matches!( + result, + Err(SetVersionError::RelativeTimelockRequiresV2 { attempted }) + if attempted == Version::ONE + )); + + Ok(()) } } From de8bfc54357021922e1d89b863770b9a8c2f38ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 19 May 2026 17:05:32 +0000 Subject: [PATCH 3/9] refactor!: drop rand dependency from the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_psbt no longer needs an RNG (AFS — the only consumer — takes its own rng explicitly), so the create_psbt_with_rng wrapper and its thread_rng() call were dead weight. Collapses both into a single create_psbt(self, params) and moves rand to dev-dependencies. The library now depends only on rand_core (for the RngCore trait) + miniscript + bdk_coin_select. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 2 +- src/tx_template.rs | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5c7c1fb2..3edd3539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } bdk_coin_select = { version = "0.4.1" } miniscript = { version = "12.3.7", default-features = false } rand_core = { version = "0.6.4", default-features = false } -rand = { version = "0.8", optional = true } [dev-dependencies] anyhow = "1" @@ -27,6 +26,7 @@ bitcoin = { version = "0.32.10", default-features = false, features = ["rand-std bdk_testenv = "0.13.0" bdk_bitcoind_rpc = "0.22.0" bdk_chain = { version = "0.23.3" } +rand = "0.8" [features] default = ["std"] diff --git a/src/tx_template.rs b/src/tx_template.rs index af59654d..b5ae3863 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -385,17 +385,7 @@ impl TxTemplate { } /// Build the [`Psbt`] and its associated [`Finalizer`]. - #[cfg(feature = "std")] pub fn create_psbt(self, params: PsbtBuildParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { - self.create_psbt_with_rng(params, &mut rand::thread_rng()) - } - - /// Build the [`Psbt`] and its associated [`Finalizer`] with a custom `rng`. - pub fn create_psbt_with_rng( - self, - params: PsbtBuildParams, - _rng: &mut impl RngCore, - ) -> Result<(Psbt, Finalizer), BuildPsbtError> { let tx = self.to_unsigned_tx(); let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; From 94ab8d5684a8caabec393c77eb695cec4c0e26ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 16 Jun 2026 22:06:17 +0000 Subject: [PATCH 4/9] refactor: rename create_psbt to build_psbt and extract its types Settle on the "build" verb so the method, params, and error type agree: create_psbt -> build_psbt, PsbtBuildParams -> BuildPsbtParams (also fixing the word order). Move BuildPsbtParams/BuildPsbtError into a new build_psbt module; the build_psbt method stays inherent on TxTemplate since it touches private fields. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/anti_fee_sniping.rs | 4 +- examples/synopsis.rs | 6 +-- src/build_psbt.rs | 75 ++++++++++++++++++++++++++++++++++++ src/finalizer.rs | 22 +++++------ src/lib.rs | 2 + src/selector.rs | 2 +- src/tx_template.rs | 75 +++++------------------------------- 7 files changed, 103 insertions(+), 83 deletions(-) create mode 100644 src/build_psbt.rs diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 038ceb2a..a926a49e 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output, SelectorParams, }; use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate}; @@ -92,7 +92,7 @@ fn main() -> anyhow::Result<()> { let (psbt, _) = selection .apply_anti_fee_sniping(tip_height, &mut rand::thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let tx = psbt.unsigned_tx; diff --git a/examples/synopsis.rs b/examples/synopsis.rs index ae24a7b2..2ce70129 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -1,6 +1,6 @@ use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output, SelectorParams, Signer, }; use bitcoin::{key::Secp256k1, Amount, FeeRate}; @@ -67,7 +67,7 @@ fn main() -> anyhow::Result<()> { ) }, )? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let _ = psbt.sign(&signer, &secp); let res = finalizer.finalize(&mut psbt); @@ -153,7 +153,7 @@ fn main() -> anyhow::Result<()> { .collect::>() ); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; psbt.sign(&signer, &secp).expect("failed to sign"); assert!( finalizer.finalize(&mut psbt).is_finalized(), diff --git a/src/build_psbt.rs b/src/build_psbt.rs new file mode 100644 index 00000000..6160dec3 --- /dev/null +++ b/src/build_psbt.rs @@ -0,0 +1,75 @@ +//! Parameters and error type for [`TxTemplate::build_psbt`]. +//! +//! The build logic itself lives as an inherent method on [`TxTemplate`]; only the standalone +//! parameter and error types are housed here to keep `tx_template.rs` focused on tx shaping. +//! +//! [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt + +use alloc::boxed::Box; +use core::fmt::Display; + +use miniscript::bitcoin; + +use crate::Input; + +/// Parameters for emitting a [`Psbt`] from a [`TxTemplate`]. +/// +/// Carries only PSBT-specific options. Transaction-shape decisions (version, locktime, +/// sequence, anti-fee-sniping, input/output ordering) all live on [`TxTemplate`]. +/// +/// [`Psbt`]: bitcoin::Psbt +/// [`TxTemplate`]: crate::TxTemplate +#[derive(Debug, Clone)] +pub struct BuildPsbtParams { + /// Whether to require the full tx (aka [`non_witness_utxo`]) for segwit v0 inputs. + /// + /// Default: `true`. + /// + /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo + pub mandate_full_tx_for_segwit_v0: bool, +} + +impl Default for BuildPsbtParams { + fn default() -> Self { + Self { + mandate_full_tx_for_segwit_v0: true, + } + } +} + +/// Error returned by [`TxTemplate::build_psbt`]. +/// +/// [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt +#[derive(Debug)] +pub enum BuildPsbtError { + /// Missing tx for legacy input. + MissingFullTxForLegacyInput(Box), + /// Missing tx for segwit v0 input. + MissingFullTxForSegwitV0Input(Box), + /// Psbt error. + Psbt(bitcoin::psbt::Error), + /// Update psbt output with descriptor error. + OutputUpdate(miniscript::psbt::OutputUpdateError), +} + +impl Display for BuildPsbtError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::MissingFullTxForLegacyInput(input) => write!( + f, + "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + Self::MissingFullTxForSegwitV0Input(input) => write!( + f, + "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + Self::Psbt(e) => Display::fmt(e, f), + Self::OutputUpdate(e) => Display::fmt(e, f), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for BuildPsbtError {} diff --git a/src/finalizer.rs b/src/finalizer.rs index e40daa01..c3c7a2af 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -14,7 +14,7 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// /// # Usage /// -/// A [`Finalizer`] is typically obtained alongside a PSBT from [`TxTemplate::create_psbt`]. +/// A [`Finalizer`] is typically obtained alongside a PSBT from [`TxTemplate::build_psbt`]. /// It can also be constructed directly from a list of `(outpoint, plan)` pairs via /// [`Finalizer::new`]. Use [`finalize_input`] to finalize a single input, or [`finalize`] to /// finalize every input. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata from @@ -23,11 +23,11 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// # Example /// /// ```rust,no_run -/// # use bdk_tx::PsbtBuildParams; +/// # use bdk_tx::BuildPsbtParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); /// # let keymap = std::collections::BTreeMap::new(); /// # let template: bdk_tx::TxTemplate = unimplemented!(); -/// let (mut psbt, finalizer) = template.create_psbt(PsbtBuildParams::default())?; +/// let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; /// /// // Sign the PSBT using your preferred method. /// let signer = bdk_tx::Signer(keymap); @@ -44,7 +44,7 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// /// [BIP174]: /// [`TxTemplate`]: crate::TxTemplate -/// [`TxTemplate::create_psbt`]: crate::TxTemplate::create_psbt +/// [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt /// [`Plan`]: miniscript::plan::Plan /// [`Transaction`]: bitcoin::Transaction /// [`finalize_input`]: Finalizer::finalize_input @@ -162,7 +162,7 @@ impl FinalizeMap { #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { - use crate::{Finalizer, Output, PsbtBuildParams, Signer, TxTemplate}; + use crate::{BuildPsbtParams, Finalizer, Output, Signer, TxTemplate}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{absolute, transaction, Amount, ScriptBuf, TxIn, TxOut}; use miniscript::bitcoin; @@ -216,7 +216,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -235,7 +235,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -263,7 +263,7 @@ mod tests { ], ); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; assert!(!psbt.outputs[0].tap_key_origins.is_empty()); assert!(psbt.outputs[0].tap_internal_key.is_some()); @@ -317,7 +317,7 @@ mod tests { ], ); - let (mut psbt, _) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, _) = selection.build_psbt(BuildPsbtParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -357,7 +357,7 @@ mod tests { ], ); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -384,7 +384,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); diff --git a/src/lib.rs b/src/lib.rs index 990d527c..8a50a5b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ extern crate std; pub extern crate bdk_coin_select; mod afs; +mod build_psbt; mod canonical_unspents; mod finalizer; mod input; @@ -24,6 +25,7 @@ mod signer; mod tx_template; pub use afs::*; +pub use build_psbt::*; pub use canonical_unspents::*; pub use finalizer::*; pub use input::*; diff --git a/src/selector.rs b/src/selector.rs index a5324301..1f02c704 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -404,7 +404,7 @@ impl<'c> Selector<'c> { } // Verify that all inputs agree on absolute timelock unit (height vs time). - // Downstream stages (create_psbt, apply_anti_fee_sniping) rely on this invariant. + // Downstream stages (build_psbt, apply_anti_fee_sniping) rely on this invariant. let mut unit: Option = None; for lt in candidates.inputs().filter_map(Input::absolute_timelock) { match unit { diff --git a/src/tx_template.rs b/src/tx_template.rs index b5ae3863..a94188fd 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -20,8 +20,8 @@ use miniscript::psbt::PsbtExt; use rand_core::RngCore; use crate::{ - apply_anti_fee_sniping, fisher_yates_shuffle, AntiFeeSnipingError, Finalizer, Input, InputMut, - Output, + apply_anti_fee_sniping, fisher_yates_shuffle, AntiFeeSnipingError, BuildPsbtError, + BuildPsbtParams, Finalizer, Input, InputMut, Output, }; /// Default `nSequence` for plan-based inputs that don't specify their own. @@ -117,63 +117,6 @@ pub struct TxTemplate { outputs: Vec, } -/// Parameters for emitting a [`Psbt`] from a [`TxTemplate`]. -/// -/// Carries only PSBT-specific options. Transaction-shape decisions (version, locktime, -/// sequence, anti-fee-sniping, input/output ordering) all live on [`TxTemplate`]. -#[derive(Debug, Clone)] -pub struct PsbtBuildParams { - /// Whether to require the full tx (aka [`non_witness_utxo`]) for segwit v0 inputs. - /// - /// Default: `true`. - /// - /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo - pub mandate_full_tx_for_segwit_v0: bool, -} - -impl Default for PsbtBuildParams { - fn default() -> Self { - Self { - mandate_full_tx_for_segwit_v0: true, - } - } -} - -/// Error returned by [`TxTemplate::create_psbt`]. -#[derive(Debug)] -pub enum BuildPsbtError { - /// Missing tx for legacy input. - MissingFullTxForLegacyInput(Box), - /// Missing tx for segwit v0 input. - MissingFullTxForSegwitV0Input(Box), - /// Psbt error. - Psbt(bitcoin::psbt::Error), - /// Update psbt output with descriptor error. - OutputUpdate(miniscript::psbt::OutputUpdateError), -} - -impl Display for BuildPsbtError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::MissingFullTxForLegacyInput(input) => write!( - f, - "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() - ), - Self::MissingFullTxForSegwitV0Input(input) => write!( - f, - "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() - ), - Self::Psbt(e) => Display::fmt(e, f), - Self::OutputUpdate(e) => Display::fmt(e, f), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for BuildPsbtError {} - impl TxTemplate { pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); @@ -218,7 +161,7 @@ impl TxTemplate { /// Set the fallback `nSequence` used for inputs that don't specify their own. /// /// The fallback is applied lazily at materialization (in [`Self::to_unsigned_tx`] and - /// [`Self::create_psbt`]); calling this method after other transformations does not + /// [`Self::build_psbt`]); calling this method after other transformations does not /// retroactively change inputs whose sequence has already been set explicitly (e.g. by /// [`apply_anti_fee_sniping`](Self::apply_anti_fee_sniping)). pub fn set_fallback_sequence(mut self, sequence: Sequence) -> Self { @@ -385,7 +328,7 @@ impl TxTemplate { } /// Build the [`Psbt`] and its associated [`Finalizer`]. - pub fn create_psbt(self, params: PsbtBuildParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { + pub fn build_psbt(self, params: BuildPsbtParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { let tx = self.to_unsigned_tx(); let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; @@ -636,7 +579,7 @@ mod tests { let (psbt, _) = selection .set_locktime(absolute::LockTime::from_consensus(current_height))? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; assert_eq!( psbt.unsigned_tx.lock_time.to_consensus_u32(), current_height @@ -660,7 +603,7 @@ mod tests { let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let tx = psbt.unsigned_tx; @@ -706,7 +649,7 @@ mod tests { let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng()) .unwrap() - .create_psbt(PsbtBuildParams::default()) + .build_psbt(BuildPsbtParams::default()) .unwrap(); let tx = psbt.unsigned_tx; @@ -750,7 +693,7 @@ mod tests { let (psbt, _) = selection .clone() .apply_anti_fee_sniping(tip, &mut thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; assert_eq!( psbt.unsigned_tx.lock_time, cltv, "AFS must not overwrite an input's CLTV with a lower value", @@ -808,7 +751,7 @@ mod tests { ); let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let tx = psbt.unsigned_tx; let csv_txin = tx From adfb2796b71757a0060a21d1b600677102d10548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 16 Jun 2026 22:25:15 +0000 Subject: [PATCH 5/9] feat(tx-template)!: seal TxTemplate into SealedTxTemplate after anti-fee-sniping apply_anti_fee_sniping now consumes the template and returns a SealedTxTemplate exposing only reads + emission, so version/locktime/sequence/ordering can't be changed after AFS. TxTemplate wraps SealedTxTemplate and derefs to it for the shared read/emit surface; the free afs helper mutates &mut self in place and the method does the sealing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/afs.rs | 16 ++- src/tx_template.rs | 312 ++++++++++++++++++++++++++------------------- 2 files changed, 193 insertions(+), 135 deletions(-) diff --git a/src/afs.rs b/src/afs.rs index 427ba6ed..b93228dd 100644 --- a/src/afs.rs +++ b/src/afs.rs @@ -56,9 +56,11 @@ impl std::error::Error for AntiFeeSnipingError {} /// Random offsets (0-99 blocks) are applied with 10% probability to avoid creating /// a unique fingerprint that could identify transactions from this wallet. /// +/// Mutates `template` in place; sealing into a [`SealedTxTemplate`](crate::SealedTxTemplate) +/// is the caller's job (see [`TxTemplate::apply_anti_fee_sniping`](crate::TxTemplate::apply_anti_fee_sniping)). +/// /// # Parameters -/// - `tx`: The transaction to modify -/// - `inputs`: The inputs associated with the transaction +/// - `template`: The tx template to modify /// - `tip_height`: The current blockchain height (used as the base for time locks) /// - `rng`: Random number generator implementing `RngCore` /// @@ -78,10 +80,10 @@ impl std::error::Error for AntiFeeSnipingError {} /// # See Also /// [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki) pub(crate) fn apply_anti_fee_sniping( - mut template: TxTemplate, + template: &mut TxTemplate, tip_height: absolute::Height, rng: &mut impl RngCore, -) -> Result { +) -> Result<(), AntiFeeSnipingError> { const MAX_RELATIVE_HEIGHT: u32 = 65_535; const FIFTY_PERCENT_PROBABILITY_RANGE: u32 = 2; const MIN_SEQUENCE_VALUE: u32 = 1; @@ -136,8 +138,8 @@ pub(crate) fn apply_anti_fee_sniping( // Only apply if it's a bump (i.e. doesn't regress an input's CLTV requirement). if template.lock_time().is_implied_by(afs_locktime) { - template = template - .set_locktime(afs_locktime) + template + .set_locktime_in_place(afs_locktime) .expect("AFS picks a value ≥ current lock_time (same height-based unit)"); } } else { @@ -161,5 +163,5 @@ pub(crate) fn apply_anti_fee_sniping( .expect("AFS only picks inputs without timelock constraints"); } - Ok(template) + Ok(()) } diff --git a/src/tx_template.rs b/src/tx_template.rs index a94188fd..b04ceffc 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -1,9 +1,10 @@ //! Tx-shaping stage between coin selection and the final [`Psbt`] or [`Transaction`]. //! //! A [`TxTemplate`] is obtained from [`Selector::try_finalize`] or -//! [`InputCandidates::into_tx_template`], then mutated (sort, shuffle, anti-fee-sniping, -//! set_version, set_locktime, set_fallback_sequence, per-input sequence overrides) before -//! being emitted as a PSBT or a [`Transaction`]. +//! [`InputCandidates::into_tx_template`], then mutated (sort, shuffle, set_version, +//! set_locktime, set_fallback_sequence, per-input sequence overrides) before being emitted as +//! a PSBT or a [`Transaction`]. Anti-fee-sniping is the terminal step: it seals the template +//! into a [`SealedTxTemplate`], which permits only reads and emission. //! //! [`Selector::try_finalize`]: crate::Selector::try_finalize //! [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template @@ -94,13 +95,137 @@ impl Display for SetLockTimeError { #[cfg(feature = "std")] impl std::error::Error for SetLockTimeError {} +/// A sealed, fully-resolved transaction shape — the read/emit core shared with [`TxTemplate`]. +/// +/// Produced by [`TxTemplate::apply_anti_fee_sniping`]. Because anti-fee-sniping is the terminal +/// shaping step, the version, locktime, sequences and input/output ordering are all fixed once +/// sealed — so this type deliberately exposes *no* mutators, only read access and final emission +/// ([`to_unsigned_tx`](Self::to_unsigned_tx), [`build_psbt`](Self::build_psbt)). +/// +/// [`TxTemplate`] derefs to this type, so every read/emit method here is also reachable on an +/// unsealed template. +#[derive(Debug, Clone)] +#[must_use] +pub struct SealedTxTemplate { + version: transaction::Version, + lock_time: absolute::LockTime, + fallback_sequence: Sequence, + inputs: Vec, + outputs: Vec, +} + +impl SealedTxTemplate { + /// Resolved transaction version. + pub fn version(&self) -> transaction::Version { + self.version + } + + /// Resolved transaction lock_time. + pub fn lock_time(&self) -> absolute::LockTime { + self.lock_time + } + + /// Fallback `nSequence` applied to inputs that don't specify their own. + pub fn fallback_sequence(&self) -> Sequence { + self.fallback_sequence + } + + /// Inputs in this template. + pub fn inputs(&self) -> &[Input] { + &self.inputs + } + + /// Outputs in this template. + pub fn outputs(&self) -> &[Output] { + &self.outputs + } + + /// Materialize the unsigned `bitcoin::Transaction` represented by this template. + /// + /// Each input's `nSequence` is its own [`Input::sequence`] if set, otherwise + /// [`fallback_sequence`](Self::fallback_sequence). + pub fn to_unsigned_tx(&self) -> Transaction { + Transaction { + version: self.version, + lock_time: self.lock_time, + input: self + .inputs + .iter() + .map(|input| TxIn { + previous_output: input.prev_outpoint(), + sequence: input.sequence().unwrap_or(self.fallback_sequence), + ..Default::default() + }) + .collect(), + output: self.outputs.iter().map(Output::txout).collect(), + } + } + + /// Build the [`Psbt`] and its associated [`Finalizer`]. + pub fn build_psbt(self, params: BuildPsbtParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { + let tx = self.to_unsigned_tx(); + let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; + + for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) { + if let Some(finalized_psbt_input) = plan_input.psbt_input() { + *psbt_input = finalized_psbt_input.clone(); + continue; + } + if let Some(plan) = plan_input.plan() { + plan.update_psbt_input(psbt_input); + + let witness_version = plan.witness_version(); + if witness_version.is_some() { + psbt_input.witness_utxo = Some(plan_input.prev_txout().clone()); + } + psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); + if psbt_input.non_witness_utxo.is_none() { + if witness_version.is_none() { + return Err(BuildPsbtError::MissingFullTxForLegacyInput(Box::new( + plan_input.clone(), + ))); + } + if params.mandate_full_tx_for_segwit_v0 + && witness_version == Some(bitcoin::WitnessVersion::V0) + { + return Err(BuildPsbtError::MissingFullTxForSegwitV0Input(Box::new( + plan_input.clone(), + ))); + } + } + continue; + } + unreachable!("input candidate must either have finalized psbt input or plan"); + } + + for (output_index, output) in self.outputs.iter().enumerate() { + if let Some(desc) = output.descriptor() { + psbt.update_output_with_descriptor(output_index, desc) + .map_err(BuildPsbtError::OutputUpdate)?; + } + } + + let finalizer = Finalizer::new(self.inputs.into_iter().filter_map(|input| { + let outpoint = input.prev_outpoint(); + let plan = input.plan().cloned()?; + Some((outpoint, plan)) + })); + + Ok((psbt, finalizer)) + } +} + /// A fully-resolved tx shape — the workspace between coin selection and the final [`Psbt`] /// or [`Transaction`]. /// /// Typically obtained from [`Selector::try_finalize`] (or /// [`InputCandidates::into_tx_template`]). Exposes the operations that *shape* the resulting -/// transaction: input/output ordering, anti-fee-sniping, version/locktime overrides, and -/// final emission to PSBT or [`Transaction`]. +/// transaction: input/output ordering, version/locktime overrides, and final emission to PSBT +/// or [`Transaction`]. Anti-fee-sniping is the terminal shaping step — it consumes the template +/// and yields a [`SealedTxTemplate`] that can only be read and emitted. +/// +/// All read/emit methods come from [`SealedTxTemplate`] via [`Deref`](core::ops::Deref); this +/// type adds the mutators on top. /// /// New templates start with `version = TWO`, `lock_time = max(input CLTVs)` (or `ZERO`), and /// `fallback_sequence = ENABLE_RBF_NO_LOCKTIME`. @@ -109,29 +234,26 @@ impl std::error::Error for SetLockTimeError {} /// [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template #[derive(Debug, Clone)] #[must_use] -pub struct TxTemplate { - version: transaction::Version, - lock_time: absolute::LockTime, - fallback_sequence: Sequence, - inputs: Vec, - outputs: Vec, +pub struct TxTemplate(SealedTxTemplate); + +impl core::ops::Deref for TxTemplate { + type Target = SealedTxTemplate; + + fn deref(&self) -> &SealedTxTemplate { + &self.0 + } } impl TxTemplate { pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); - Self { + Self(SealedTxTemplate { version: transaction::Version::TWO, lock_time, fallback_sequence: FALLBACK_SEQUENCE, inputs, outputs, - } - } - - /// Resolved transaction version. - pub fn version(&self) -> transaction::Version { - self.version + }) } /// Override `tx.version`. @@ -145,27 +267,26 @@ impl TxTemplate { /// relative timelock. pub fn set_version(mut self, version: transaction::Version) -> Result { if version < transaction::Version::TWO - && self.inputs.iter().any(|i| i.relative_timelock().is_some()) + && self + .0 + .inputs + .iter() + .any(|i| i.relative_timelock().is_some()) { return Err(SetVersionError::RelativeTimelockRequiresV2 { attempted: version }); } - self.version = version; + self.0.version = version; Ok(self) } - /// Resolved transaction lock_time. - pub fn lock_time(&self) -> absolute::LockTime { - self.lock_time - } - /// Set the fallback `nSequence` used for inputs that don't specify their own. /// - /// The fallback is applied lazily at materialization (in [`Self::to_unsigned_tx`] and - /// [`Self::build_psbt`]); calling this method after other transformations does not - /// retroactively change inputs whose sequence has already been set explicitly (e.g. by - /// [`apply_anti_fee_sniping`](Self::apply_anti_fee_sniping)). + /// The fallback is applied lazily at materialization (in + /// [`to_unsigned_tx`](SealedTxTemplate::to_unsigned_tx) and [`Self::build_psbt`]); it does + /// not retroactively change inputs whose sequence has already been set explicitly (e.g. via + /// [`InputMut::set_sequence`]). pub fn set_fallback_sequence(mut self, sequence: Sequence) -> Self { - self.fallback_sequence = sequence; + self.0.fallback_sequence = sequence; self } @@ -184,7 +305,17 @@ impl TxTemplate { /// - [`SetLockTimeError::UnitMismatch`] if `lock_time` is height-based and an input's /// CLTV is time-based (or vice versa). pub fn set_locktime(mut self, lock_time: absolute::LockTime) -> Result { - for input in &self.inputs { + self.set_locktime_in_place(lock_time)?; + Ok(self) + } + + /// In-place [`set_locktime`](Self::set_locktime), for callers holding `&mut self` + /// (e.g. anti-fee-sniping). Validation is identical. + pub(crate) fn set_locktime_in_place( + &mut self, + lock_time: absolute::LockTime, + ) -> Result<(), SetLockTimeError> { + for input in &self.0.inputs { let Some(required) = input.absolute_timelock() else { continue; }; @@ -201,23 +332,8 @@ impl TxTemplate { }); } } - self.lock_time = lock_time; - Ok(self) - } - - /// Fallback `nSequence` applied to inputs that don't specify their own. - pub fn fallback_sequence(&self) -> Sequence { - self.fallback_sequence - } - - /// Inputs in this template. - pub fn inputs(&self) -> &[Input] { - &self.inputs - } - - /// Outputs in this template. - pub fn outputs(&self) -> &[Output] { - &self.outputs + self.0.lock_time = lock_time; + Ok(()) } /// Mutable handle to the input spending `outpoint`, if any. @@ -226,7 +342,8 @@ impl TxTemplate { /// [`InputMut`] only permits mutations that preserve the template's invariants — see /// [`InputMut`] for the available operations. pub fn input_mut(&mut self, outpoint: OutPoint) -> Option> { - self.inputs + self.0 + .inputs .iter_mut() .find(|input| input.prev_outpoint() == outpoint) .map(InputMut::new) @@ -237,7 +354,7 @@ impl TxTemplate { /// Each yielded [`InputMut`] only permits mutations that preserve the template's /// invariants — see [`InputMut`] for the available operations. pub fn inputs_mut(&mut self) -> impl Iterator> { - self.inputs.iter_mut().map(InputMut::new) + self.0.inputs.iter_mut().map(InputMut::new) } /// Reorder inputs using `compare`. Uses a stable sort. @@ -247,7 +364,7 @@ impl TxTemplate { where F: FnMut(&Input, &Input) -> Ordering, { - self.inputs.sort_by(compare); + self.0.inputs.sort_by(compare); self } @@ -255,7 +372,7 @@ impl TxTemplate { /// /// Useful for chain-analysis resistance when no deterministic ordering is required. pub fn shuffle_inputs(mut self, rng: &mut R) -> Self { - fisher_yates_shuffle(&mut self.inputs, rng); + fisher_yates_shuffle(&mut self.0.inputs, rng); self } @@ -266,7 +383,7 @@ impl TxTemplate { where F: FnMut(&Output, &Output) -> Ordering, { - self.outputs.sort_by(compare); + self.0.outputs.sort_by(compare); self } @@ -275,31 +392,10 @@ impl TxTemplate { /// Useful for chain-analysis resistance — in particular, hiding which output is the /// change. pub fn shuffle_outputs(mut self, rng: &mut R) -> Self { - fisher_yates_shuffle(&mut self.outputs, rng); + fisher_yates_shuffle(&mut self.0.outputs, rng); self } - /// Materialize the unsigned `bitcoin::Transaction` represented by this template. - /// - /// Each input's `nSequence` is its own [`Input::sequence`] if set, otherwise - /// [`fallback_sequence`](Self::fallback_sequence). - pub fn to_unsigned_tx(&self) -> Transaction { - Transaction { - version: self.version, - lock_time: self.lock_time, - input: self - .inputs - .iter() - .map(|input| TxIn { - previous_output: input.prev_outpoint(), - sequence: input.sequence().unwrap_or(self.fallback_sequence), - ..Default::default() - }) - .collect(), - output: self.outputs.iter().map(Output::txout).collect(), - } - } - /// Apply BIP-326 anti-fee-sniping (AFS) protection using `tip_height` as the chain tip. /// /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining @@ -313,71 +409,31 @@ impl TxTemplate { /// If `tx.lock_time` is already a block height greater than `tip_height` (e.g., because an /// input's CLTV pins the tx to a future block), this leaves the template unchanged. /// - /// See [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki). + /// AFS is the *final* shaping step: it consumes the template and returns a + /// [`SealedTxTemplate`], which permits only reads and emission. Apply any ordering, + /// version, locktime or sequence changes *before* calling this. See [BIP326]. + /// + /// [BIP326]: https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki /// /// # Errors /// /// - [`AntiFeeSnipingError::UnsupportedVersion`] if `version < 2`. /// - [`AntiFeeSnipingError::UnsupportedLockTime`] if `lock_time` is time-based. pub fn apply_anti_fee_sniping( - self, + mut self, tip_height: absolute::Height, rng: &mut R, - ) -> Result { - apply_anti_fee_sniping(self, tip_height, rng) + ) -> Result { + apply_anti_fee_sniping(&mut self, tip_height, rng)?; + Ok(self.0) } /// Build the [`Psbt`] and its associated [`Finalizer`]. + /// + /// Convenience for building without sealing; equivalent to the inherent + /// [`SealedTxTemplate::build_psbt`] on the unsealed shape. pub fn build_psbt(self, params: BuildPsbtParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { - let tx = self.to_unsigned_tx(); - let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; - - for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) { - if let Some(finalized_psbt_input) = plan_input.psbt_input() { - *psbt_input = finalized_psbt_input.clone(); - continue; - } - if let Some(plan) = plan_input.plan() { - plan.update_psbt_input(psbt_input); - - let witness_version = plan.witness_version(); - if witness_version.is_some() { - psbt_input.witness_utxo = Some(plan_input.prev_txout().clone()); - } - psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); - if psbt_input.non_witness_utxo.is_none() { - if witness_version.is_none() { - return Err(BuildPsbtError::MissingFullTxForLegacyInput(Box::new( - plan_input.clone(), - ))); - } - if params.mandate_full_tx_for_segwit_v0 - && witness_version == Some(bitcoin::WitnessVersion::V0) - { - return Err(BuildPsbtError::MissingFullTxForSegwitV0Input(Box::new( - plan_input.clone(), - ))); - } - } - continue; - } - unreachable!("input candidate must either have finalized psbt input or plan"); - } - - for (output_index, output) in self.outputs.iter().enumerate() { - if let Some(desc) = output.descriptor() { - psbt.update_output_with_descriptor(output_index, desc) - .map_err(BuildPsbtError::OutputUpdate)?; - } - } - - let finalizer = Finalizer::new(self.inputs.into_iter().filter_map(|input| { - let outpoint = input.prev_outpoint(); - let plan = input.plan().cloned()?; - Some((outpoint, plan)) - })); - - Ok((psbt, finalizer)) + self.0.build_psbt(params) } } From d7252e1912b080c6d93706156adcf824645c8d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 15 Jun 2026 20:09:42 +0000 Subject: [PATCH 6/9] feat: Add single_random_draw selection algorithm Provide selection_algorithm_single_random_draw, which shuffles candidates with a caller-supplied rng and selects until the target is met. Pass it to Selector::select_with_algorithm so randomness lives at the selection step and candidate construction stays deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 53f4e5a1..e3350888 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -1,9 +1,10 @@ use alloc::{vec, vec::Vec}; use core::fmt; -use bdk_coin_select::{metrics::LowestFee, Candidate, NoBnbSolution}; +use bdk_coin_select::{metrics::LowestFee, Candidate, InsufficientFunds, NoBnbSolution}; use bitcoin::{absolute, FeeRate, OutPoint}; use miniscript::bitcoin; +use rand_core::RngCore; use crate::collections::{BTreeMap, HashSet}; use crate::{ @@ -261,6 +262,31 @@ pub fn selection_algorithm_lowest_fee_bnb( } } +/// Coin selection algorithm that selects candidates in a uniformly-random order until the target +/// is met (single random draw). +/// +/// The `rng` is carried by the returned algorithm, so candidate construction stays deterministic +/// and randomness lives at the selection step. Pass the result to +/// [`Selector::select_with_algorithm`]. +/// +/// [`Selector::select_with_algorithm`]: crate::Selector::select_with_algorithm +pub fn selection_algorithm_single_random_draw( + rng: &mut impl RngCore, +) -> impl FnMut(&mut Selector) -> Result<(), InsufficientFunds> + '_ { + move |selector| { + // Assign every candidate a random sort key, then sort by it to obtain a uniform shuffle. + // The keys are precomputed (one per candidate) so the closure handed to + // `sort_candidates_by_key` is a deterministic lookup: that closure is invoked multiple + // times per comparison, so it must not draw from the rng itself. + let n = selector.inner().candidates().len(); + let keys: Vec = (0..n).map(|_| rng.next_u64()).collect(); + selector + .inner_mut() + .sort_candidates_by_key(|(i, _)| keys[i]); + selector.select_until_target_met() + } +} + /// Default group policy. pub fn group_by_spk() -> impl Fn(&Input) -> bitcoin::ScriptBuf { |input| input.prev_txout().script_pubkey.clone() From 0f1c8b27568f117b9d13b5bd908a64afc5f2cc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 16 Jun 2026 12:35:48 +0000 Subject: [PATCH 7/9] feat: Add InputCandidates::push_must_select / push_can_select Incremental builders to add an Input to the must-select group or as an optional can-select group, with outpoint de-duplication. Lets callers extend an InputCandidates after construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index e3350888..0cfd4e62 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -192,6 +192,37 @@ impl InputCandidates { self } + /// Add `input` to the must-select group (always included by coin selection). + /// + /// All must-select inputs form a single group spent together. If `input`'s outpoint is already + /// a candidate, it is ignored and the existing candidate is kept. + pub fn push_must_select(mut self, input: impl Into) -> Self { + let input = input.into(); + if self.contains.insert(input.prev_outpoint()) { + let mut inputs = self + .must_select + .take() + .map_or_else(Vec::new, InputGroup::into_inputs); + inputs.push(input); + self.must_select = InputGroup::from_inputs(inputs); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + } + self + } + + /// Add `input` as its own optional (can-select) group. + /// + /// If `input`'s outpoint is already a candidate, it is ignored and the existing candidate is + /// kept. + pub fn push_can_select(mut self, input: impl Into) -> Self { + let input = input.into(); + if self.contains.insert(input.prev_outpoint()) { + self.can_select.push(InputGroup::from_input(input)); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + } + self + } + /// Run coin selection with `algorithm` and selector `params`, returning a [`TxTemplate`]. pub fn into_tx_template( self, From 2adcccc5ece983e856846692a94c0c02436256d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 25 Jun 2026 11:04:40 +0000 Subject: [PATCH 8/9] refactor(tx_template): Rename `TxTemplate::from_parts` to `new` --- src/finalizer.rs | 12 ++++++------ src/selector.rs | 2 +- src/tx_template.rs | 26 +++++++++++++------------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index c3c7a2af..512d0a83 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -214,7 +214,7 @@ mod tests { fn test_finalize_single_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; @@ -233,7 +233,7 @@ mod tests { fn test_finalize_sets_final_script_sig() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(PKH_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; @@ -255,7 +255,7 @@ mod tests { let taproot_output_descriptor = derive_descriptor_at(TR_XPRV, 10)?; let wpkh_output_descriptor = derive_descriptor_at(WPKH_XPRV, 11)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input_0, input_1, input_2], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -309,7 +309,7 @@ mod tests { input_0.plan().cloned().expect("plan must exist"), )]); - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input_0, input_1], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -349,7 +349,7 @@ mod tests { let (input, _) = create_input_from_descriptor_at(TR_XPRV, 0)?; let taproot_output_descriptor = derive_descriptor_at(TR_XPRV, 10)?; let wpkh_output_descriptor = derive_descriptor_at(WPKH_XPRV, 11)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -382,7 +382,7 @@ mod tests { fn test_already_finalized_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; diff --git a/src/selector.rs b/src/selector.rs index 1f02c704..f40ef8a9 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -506,7 +506,7 @@ impl<'c> Selector<'c> { Amount::from_sat(maybe_change.value), ))); } - Some(TxTemplate::from_parts(inputs, outputs)) + Some(TxTemplate::new(inputs, outputs)) } } diff --git a/src/tx_template.rs b/src/tx_template.rs index b04ceffc..54423d71 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -245,7 +245,7 @@ impl core::ops::Deref for TxTemplate { } impl TxTemplate { - pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { + pub(crate) fn new(inputs: Vec, outputs: Vec) -> Self { let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); Self(SealedTxTemplate { version: transaction::Version::TWO, @@ -537,7 +537,7 @@ mod tests { fn set_locktime_height_above_input_cltv() -> anyhow::Result<()> { let cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(cltv)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -564,7 +564,7 @@ mod tests { fn set_locktime_below_input_cltv_errors() -> anyhow::Result<()> { let cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(cltv)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -588,7 +588,7 @@ mod tests { fn lock_time_takes_time_based_cltv_from_input() -> anyhow::Result<()> { let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); let (input, desc) = setup_cltv_input(time_locktime)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -606,7 +606,7 @@ mod tests { fn set_locktime_unit_mismatch_errors() -> anyhow::Result<()> { let height_cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(height_cltv)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -631,7 +631,7 @@ mod tests { let current_height = 2_500; let input = setup_test_input(2_000)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (psbt, _) = selection .set_locktime(absolute::LockTime::from_consensus(current_height))? @@ -655,7 +655,7 @@ mod tests { while !used_locktime || !used_sequence { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input.clone()], vec![output]); + let selection = TxTemplate::new(vec![input.clone()], vec![output]); let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng())? @@ -698,7 +698,7 @@ mod tests { let mut loops = 0; while !used_locktime || !used_sequence { - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input1.clone(), input2.clone(), input3.clone()], vec![output.clone()], ); @@ -737,7 +737,7 @@ mod tests { let (input, desc) = setup_cltv_input(cltv)?; let tip = absolute::Height::from_consensus(50_000)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -801,7 +801,7 @@ mod tests { let mut observed_sequence_path = false; for _ in 0..100 { - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![regular_input.clone(), csv_input.clone()], vec![output.clone()], ); @@ -845,7 +845,7 @@ mod tests { let (input, desc) = setup_cltv_input(time_locktime)?; let tip = absolute::Height::from_consensus(800_000)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -869,7 +869,7 @@ mod tests { let input = setup_test_input(confirmation_height)?; let current_height = absolute::Height::from_consensus(confirmation_height + 50)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_script( ScriptBuf::new(), @@ -918,7 +918,7 @@ mod tests { }; let csv_input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![csv_input], vec![Output::with_script( ScriptBuf::new(), From 0e7d0635c51b9177dd530717eb97e9a2833df23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 25 Jun 2026 11:29:56 +0000 Subject: [PATCH 9/9] feat(input_candidates): upsert in push_must_select / push_can_select Previously these were no-ops when the outpoint already existed, so a push could neither promote a can-select candidate to must-select nor replace stale input data, and the surviving candidate depended on insertion order rather than group precedence. They now upsert: the existing candidate is replaced and moved to the requested group, with must-select taking precedence over can-select (consistent with `new`). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 188 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 172 insertions(+), 16 deletions(-) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 0cfd4e62..a9bacc7d 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -194,35 +194,84 @@ impl InputCandidates { /// Add `input` to the must-select group (always included by coin selection). /// - /// All must-select inputs form a single group spent together. If `input`'s outpoint is already - /// a candidate, it is ignored and the existing candidate is kept. + /// All must-select inputs form a single group spent together. + /// + /// If `input`'s outpoint is already a candidate it is *upserted*: the existing candidate is + /// replaced with `input` and moved into the must-select group. When the previous candidate was + /// part of a multi-input `can_select` group, it is detached and the remaining members stay + /// grouped together. pub fn push_must_select(mut self, input: impl Into) -> Self { let input = input.into(); - if self.contains.insert(input.prev_outpoint()) { - let mut inputs = self - .must_select - .take() - .map_or_else(Vec::new, InputGroup::into_inputs); - inputs.push(input); - self.must_select = InputGroup::from_inputs(inputs); - self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); - } + self.take_input(input.prev_outpoint()); + self.contains.insert(input.prev_outpoint()); + let mut inputs = self + .must_select + .take() + .map_or_else(Vec::new, InputGroup::into_inputs); + inputs.push(input); + self.must_select = InputGroup::from_inputs(inputs); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); self } /// Add `input` as its own optional (can-select) group. /// - /// If `input`'s outpoint is already a candidate, it is ignored and the existing candidate is - /// kept. + /// If `input`'s outpoint is already a candidate it is *upserted*: the existing candidate is + /// replaced with `input`. As must-select takes precedence over can-select (consistent with + /// [`new`](Self::new)), an outpoint that is already must-select keeps its data replaced but is + /// *not* demoted to can-select. pub fn push_can_select(mut self, input: impl Into) -> Self { let input = input.into(); - if self.contains.insert(input.prev_outpoint()) { - self.can_select.push(InputGroup::from_input(input)); - self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + let outpoint = input.prev_outpoint(); + let in_must_select = self + .must_select + .as_ref() + .is_some_and(|g| g.inputs().iter().any(|i| i.prev_outpoint() == outpoint)); + if in_must_select { + return self.push_must_select(input); } + self.take_input(outpoint); + self.contains.insert(outpoint); + self.can_select.push(InputGroup::from_input(input)); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); self } + /// Remove and return the candidate input with `outpoint` from wherever it currently lives. + /// + /// When the input was part of a multi-input `can_select` group, the remaining members are kept + /// together as a group (in the same position). Returns `None` if `outpoint` is not a candidate. + /// Does not rebuild [`Self::cs_candidates`]; callers must do so. + fn take_input(&mut self, outpoint: OutPoint) -> Option { + if !self.contains.remove(&outpoint) { + return None; + } + if let Some(group) = self.must_select.take() { + let mut inputs = group.into_inputs(); + if let Some(pos) = inputs.iter().position(|i| i.prev_outpoint() == outpoint) { + let removed = inputs.remove(pos); + self.must_select = InputGroup::from_inputs(inputs); + return Some(removed); + } + self.must_select = InputGroup::from_inputs(inputs); + } + for idx in 0..self.can_select.len() { + let pos = self.can_select[idx] + .inputs() + .iter() + .position(|i| i.prev_outpoint() == outpoint); + if let Some(pos) = pos { + let mut inputs = self.can_select.remove(idx).into_inputs(); + let removed = inputs.remove(pos); + if let Some(group) = InputGroup::from_inputs(inputs) { + self.can_select.insert(idx, group); + } + return Some(removed); + } + } + None + } + /// Run coin selection with `algorithm` and selector `params`, returning a [`TxTemplate`]. pub fn into_tx_template( self, @@ -337,3 +386,110 @@ pub fn filter_unspendable( pub fn no_filtering() -> impl Fn(&InputGroup) -> bool { |_| true } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Input; + use bitcoin::{hashes::Hash, Amount, OutPoint, TxOut, Txid}; + use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + use std::str::FromStr; + + const TEST_XPUB: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; + + /// Build an [`Input`] at `vout` carrying `value` sats; `value` doubles as an identity tag so + /// tests can assert an upsert actually replaced the previous candidate's data. + fn input_at(vout: u32, value: u64) -> Input { + let desc = + Descriptor::::from_str(&format!("tr({TEST_XPUB})")).unwrap(); + let definite = desc.at_derivation_index(0).unwrap(); + let script_pubkey = definite.script_pubkey(); + let assets = Assets::new().add(DescriptorPublicKey::from_str(TEST_XPUB).unwrap()); + let plan = definite.plan(&assets).unwrap(); + let outpoint = OutPoint::new(Txid::all_zeros(), vout); + let txout = TxOut { + value: Amount::from_sat(value), + script_pubkey, + }; + Input::from_prev_txout(plan, outpoint, txout, None, false) + } + + fn op(vout: u32) -> OutPoint { + OutPoint::new(Txid::all_zeros(), vout) + } + + fn value_at(c: &InputCandidates, outpoint: OutPoint) -> Option { + c.inputs() + .find(|i| i.prev_outpoint() == outpoint) + .map(|i| i.prev_txout().value.to_sat()) + } + + fn is_must(c: &InputCandidates, outpoint: OutPoint) -> bool { + c.must_select() + .is_some_and(|g| g.inputs().iter().any(|i| i.prev_outpoint() == outpoint)) + } + + fn is_can(c: &InputCandidates, outpoint: OutPoint) -> bool { + c.can_select() + .iter() + .any(|g| g.inputs().iter().any(|i| i.prev_outpoint() == outpoint)) + } + + #[test] + fn push_must_select_promotes_and_replaces_can_select_candidate() { + let c = InputCandidates::new([], [input_at(0, 100)]); + assert!(is_can(&c, op(0))); + + let c = c.push_must_select(input_at(0, 200)); + assert!( + is_must(&c, op(0)), + "outpoint should be promoted to must-select" + ); + assert!( + !is_can(&c, op(0)), + "outpoint should no longer be can-select" + ); + assert_eq!( + value_at(&c, op(0)), + Some(200), + "candidate data should be replaced" + ); + assert_eq!(c.inputs().count(), 1, "no duplicate candidate"); + assert_eq!(c.coin_select_candidates().len(), 1); + } + + #[test] + fn push_can_select_does_not_demote_must_select_but_replaces_data() { + let c = InputCandidates::new([input_at(0, 100)], []); + + let c = c.push_can_select(input_at(0, 200)); + assert!( + is_must(&c, op(0)), + "must-select takes precedence; no demotion" + ); + assert!(!is_can(&c, op(0))); + assert_eq!( + value_at(&c, op(0)), + Some(200), + "candidate data should be replaced" + ); + assert_eq!(c.inputs().count(), 1); + } + + #[test] + fn push_must_select_detaches_outpoint_from_multi_input_group() { + // All inputs share a script pubkey, so grouping by spk yields a single can-select group. + let c = InputCandidates::new([], [input_at(0, 100), input_at(1, 100), input_at(2, 100)]) + .regroup(group_by_spk()); + assert_eq!(c.can_select().len(), 1); + assert_eq!(c.can_select()[0].inputs().len(), 3); + + let c = c.push_must_select(input_at(1, 999)); + assert!(is_must(&c, op(1))); + assert_eq!(value_at(&c, op(1)), Some(999)); + assert_eq!(c.can_select().len(), 1, "remaining members stay grouped"); + assert_eq!(c.can_select()[0].inputs().len(), 2); + assert_eq!(c.inputs().count(), 3, "no input lost or duplicated"); + assert_eq!(c.coin_select_candidates().len(), 2); + } +}