diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 83c0228c..31bb7cc4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,8 +17,8 @@ jobs: - version: stable - version: 1.85.0 features: - - --no-default-features --features miniscript/no-std - - --all-features + - -p bdk_tx --no-default-features --features miniscript/no-std + - --workspace --all-features steps: - uses: actions/checkout@v6 - name: Install Rust @@ -46,7 +46,7 @@ jobs: toolchain: stable cache: true - name: Check no-std - run: cargo check --no-default-features --features miniscript/no-std + run: cargo check -p bdk_tx --no-default-features --features miniscript/no-std fmt-clippy: runs-on: ubuntu-latest @@ -61,4 +61,4 @@ jobs: - name: Rust fmt run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --all-targets --all-features -- -Dwarnings + run: cargo clippy --workspace --all-targets --all-features -- -Dwarnings diff --git a/Cargo.toml b/Cargo.toml index 5c7c1fb2..fd4972fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,43 +1,3 @@ -[package] -name = "bdk_tx" -version = "0.2.0" -edition = "2021" -rust-version = "1.85.0" -homepage = "https://bitcoindevkit.org" -repository = "https://github.com/bitcoindevkit/bdk-tx" -documentation = "https://docs.rs/bdk_tx" -description = "Bitcoin transaction building library." -license = "MIT OR Apache-2.0" -readme = "README.md" - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } - -[dependencies] -# TODO: coin-select dependency should set no default features -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" -bdk_tx = { path = "." } -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" } - -[features] -default = ["std"] -std = ["miniscript/std", "rand/std", "bdk_coin_select/std"] - -[[example]] -name = "synopsis" - -[[example]] -name = "common" -crate-type = ["lib"] - -[[example]] -name = "anti_fee_sniping" +[workspace] +members = ["tx", "wallet_tx"] +resolver = "2" diff --git a/README.md b/README.md index 3c4fa942..45a2ffce 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,25 @@ -# `bdk_tx` +# bdk-tx workspace -This is a transaction building library based on `rust-miniscript` that lets you build, update, and finalize PSBTs with minimal dependencies. +A Cargo workspace with two crates: - -Because the project builds upon [miniscript] we support [descriptors] natively. +- [`tx/`](tx) -- **`bdk_tx`**, a low-level Bitcoin transaction-building library (coin selection, + tx-template shaping, PSBT emission and finalization). See [`tx/README.md`](tx/README.md). +- [`wallet_tx/`](wallet_tx) -- **`bdk_wallet_tx`**, a bridge crate that drives `bdk_tx`'s multi-stage + transaction building from a `bdk_wallet::Wallet` via the `WalletTxExt` extension trait. See + [`wallet_tx/README.md`](wallet_tx/README.md). -Refer to [BIP174], [BIP370], and [BIP371] to learn more about partially signed bitcoin transactions (PSBT). +`wallet_tx` depends on both `bdk_wallet` and `bdk_tx`, so neither base crate depends on the other: +`bdk_wallet` stays stable, `bdk_tx` stays free to move, and the bridge absorbs the coupling. -**Note:** -The library is unstable and API changes should be expected. Check the [examples] directory for detailed usage examples. +## Building +```sh +cargo build --workspace +cargo test --workspace +``` -## Contributing -Found a bug, have an issue or a feature request? Feel free to open an issue on GitHub. This library is open source licensed under MIT. +`bdk_tx` additionally supports `no_std`: -[miniscript]: https://github.com/bitcoin/bips/blob/master/bip-0379.md -[descriptors]: https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md -[BIP174]: https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki -[BIP370]: https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki -[BIP371]: https://github.com/bitcoin/bips/blob/master/bip-0371.mediawiki -[examples]: ./examples +```sh +cargo check -p bdk_tx --no-default-features --features miniscript/no-std +``` diff --git a/src/input_candidates.rs b/src/input_candidates.rs deleted file mode 100644 index 131f6af8..00000000 --- a/src/input_candidates.rs +++ /dev/null @@ -1,284 +0,0 @@ -use alloc::{vec, vec::Vec}; -use core::fmt; - -use bdk_coin_select::{metrics::LowestFee, Candidate, NoBnbSolution}; -use bitcoin::{absolute, FeeRate, OutPoint}; -use miniscript::bitcoin; - -use crate::collections::{BTreeMap, HashSet}; -use crate::{ - CannotMeetTarget, FeeRateExt, Input, InputGroup, Selection, Selector, SelectorError, - SelectorParams, -}; - -/// Input candidates. -#[must_use] -#[derive(Debug, Clone)] -pub struct InputCandidates { - /// Pre-selected input group that is included before optional candidates. - must_select: Option, - /// Optional input groups that coin selection may add. - can_select: Vec, - /// Cached coin-select candidate metadata, kept in the same order as [`Self::groups`]. - cs_candidates: Vec, - /// Cached outpoints used for deduplication and O(1) membership checks. - contains: HashSet, -} - -impl InputCandidates { - /// Construct [`InputCandidates`] with a list of inputs that must be selected as well as - /// those that may additionally be selected. If the same outpoint occurs in both `must_select` and - /// `can_select`, the one in `must_select` is retained. - pub fn new(must_select: A, can_select: B) -> Self - where - A: IntoIterator, - B: IntoIterator, - { - let mut contains = HashSet::::new(); - let must_select = InputGroup::from_inputs( - must_select - .into_iter() - .filter(|input| contains.insert(input.prev_outpoint())), - ); - let can_select = can_select - .into_iter() - .filter(|input| contains.insert(input.prev_outpoint())) - .map(InputGroup::from_input) - .collect::>(); - let cs_candidates = Self::build_cs_candidates(&must_select, &can_select); - InputCandidates { - must_select, - can_select, - cs_candidates, - contains, - } - } - - fn build_cs_candidates( - must_select: &Option, - can_select: &[InputGroup], - ) -> Vec { - must_select - .iter() - .chain(can_select) - .map(|group| Candidate { - value: group.value().to_sat(), - weight: group.weight(), - input_count: group.input_count(), - is_segwit: group.is_segwit(), - }) - .collect() - } - - /// Iterate over all contained inputs of all groups. - pub fn inputs(&self) -> impl Iterator + '_ { - self.groups().flat_map(InputGroup::inputs) - } - - /// Consume and iterate over all contained inputs of all groups. - pub fn into_inputs(self) -> impl Iterator { - self.into_groups().flat_map(InputGroup::into_inputs) - } - - /// Iterate over all contained groups. - pub fn groups(&self) -> impl Iterator + '_ { - self.must_select.iter().chain(&self.can_select) - } - - /// Consume and iterate over all contained groups. - pub fn into_groups(self) -> impl Iterator { - self.must_select.into_iter().chain(self.can_select) - } - - /// Inputs that coin selection may choose from. - pub fn can_select(&self) -> &[InputGroup] { - &self.can_select - } - - /// Inputs that must be selected, if any. - pub fn must_select(&self) -> Option<&InputGroup> { - self.must_select.as_ref() - } - - /// Cached candidate metadata used by `bdk_coin_select`. - pub fn coin_select_candidates(&self) -> &Vec { - &self.cs_candidates - } - - /// Whether the outpoint is an input candidate. - pub fn contains(&self, outpoint: OutPoint) -> bool { - self.contains.contains(&outpoint) - } - - /// Regroup inputs with given `policy`. - /// - /// Anything grouped with `must_select` inputs also becomes `must_select`. - pub fn regroup(self, mut policy: P) -> Self - where - P: FnMut(&Input) -> G, - G: Ord + Clone, - { - let mut order = Vec::::with_capacity(self.contains.len()); - let mut groups = BTreeMap::>::new(); - for input in self - .can_select - .into_iter() - .flat_map(InputGroup::into_inputs) - { - let group_id = policy(&input); - use crate::collections::btree_map::Entry; - let entry = match groups.entry(group_id.clone()) { - Entry::Vacant(entry) => { - order.push(group_id.clone()); - entry.insert(vec![]) - } - Entry::Occupied(entry) => entry.into_mut(), - }; - entry.push(input); - } - - let mut must_select = self.must_select.map_or(vec![], |g| g.into_inputs()); - let must_select_order = must_select.iter().map(&mut policy).collect::>(); - for g_id in must_select_order { - if let Some(inputs) = groups.remove(&g_id) { - must_select.extend(inputs); - } - } - let must_select = InputGroup::from_inputs(must_select); - - let mut can_select = Vec::::new(); - for g_id in order { - if let Some(inputs) = groups.remove(&g_id) { - if let Some(group) = InputGroup::from_inputs(inputs) { - can_select.push(group); - } - } - } - - let cs_candidates = Self::build_cs_candidates(&must_select, &can_select); - let no_dup = self.contains; - - Self { - must_select, - can_select, - cs_candidates, - contains: no_dup, - } - } - - /// Filters out inputs. - /// - /// If a filtered-out input is part of a group, the group will also be filtered out. - /// Does not filter `must_select` inputs. - pub fn filter

(mut self, mut policy: P) -> Self - where - P: FnMut(&Input) -> bool, - { - let mut to_rm = Vec::::new(); - self.can_select.retain(|group| { - let retain = group.all(&mut policy); - if !retain { - for input in group.inputs() { - to_rm.push(input.prev_outpoint()); - } - } - retain - }); - for op in to_rm { - self.contains.remove(&op); - } - self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); - self - } - - /// Attempt to convert the input candidates into a valid [`Selection`] with a given - /// `algorithm` and selector `params`. - pub fn into_selection( - self, - algorithm: A, - params: SelectorParams, - ) -> Result> - where - A: FnMut(&mut Selector) -> Result<(), E>, - { - let mut selector = Selector::new(&self, params).map_err(IntoSelectionError::Selector)?; - selector - .select_with_algorithm(algorithm) - .map_err(IntoSelectionError::SelectionAlgorithm)?; - let selection = selector - .try_finalize() - .ok_or(IntoSelectionError::CannotMeetTarget(CannotMeetTarget))?; - Ok(selection) - } -} - -/// Occurs when we cannot find a solution for selection. -#[derive(Debug)] -pub enum IntoSelectionError { - /// Coin selector returned an error - Selector(SelectorError), - /// Selection algorithm failed. - SelectionAlgorithm(E), - /// The target cannot be met - CannotMeetTarget(CannotMeetTarget), -} - -impl fmt::Display for IntoSelectionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - IntoSelectionError::Selector(error) => { - write!(f, "{error}") - } - IntoSelectionError::SelectionAlgorithm(error) => { - write!(f, "selection algorithm failed: {error}") - } - IntoSelectionError::CannotMeetTarget(error) => write!(f, "{error}"), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for IntoSelectionError {} - -/// Select for lowest fee with bnb -pub fn selection_algorithm_lowest_fee_bnb( - longterm_feerate: FeeRate, - max_rounds: usize, -) -> impl FnMut(&mut Selector) -> Result<(), NoBnbSolution> { - let long_term_feerate = longterm_feerate.into_cs_feerate(); - move |selector| { - let target = selector.target(); - let change_policy = selector.cs_change_policy(); - selector - .inner_mut() - .run_bnb( - LowestFee { - target, - long_term_feerate, - change_policy, - }, - max_rounds, - ) - .map(|_| ()) - } -} - -/// Default group policy. -pub fn group_by_spk() -> impl Fn(&Input) -> bitcoin::ScriptBuf { - |input| input.prev_txout().script_pubkey.clone() -} - -/// Filter out inputs that cannot be spent now. -/// -/// If an input's spendability cannot be determined, it will also be filtered out. -pub fn filter_unspendable( - tip_height: absolute::Height, - tip_mtp: Option, -) -> impl Fn(&Input) -> bool { - move |input| input.is_spendable(tip_height, tip_mtp).unwrap_or(false) -} - -/// No filtering. -pub fn no_filtering() -> impl Fn(&InputGroup) -> bool { - |_| true -} diff --git a/src/selection.rs b/src/selection.rs deleted file mode 100644 index e1dd4100..00000000 --- a/src/selection.rs +++ /dev/null @@ -1,801 +0,0 @@ -use alloc::boxed::Box; -use alloc::vec::Vec; -use core::cmp::Ordering; -use core::fmt::{Debug, Display}; - -use miniscript::bitcoin; -use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence}; -use miniscript::psbt::PsbtExt; -use rand_core::RngCore; - -use crate::{ - apply_anti_fee_sniping, fisher_yates_shuffle, AntiFeeSnipingError, Finalizer, Input, InputMut, - Output, -}; - -/// Final selection of inputs and outputs. -#[derive(Debug, Clone)] -#[must_use] -pub struct Selection { - inputs: Vec, - outputs: Vec, -} - -/// Parameters for creating a psbt. -#[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. - /// - /// Default is `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, - /// [`Selection::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(..)`, [`Selection::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 { - 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. -#[derive(Debug)] -pub enum CreatePsbtError { - /// 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), - /// 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 { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - CreatePsbtError::MissingFullTxForLegacyInput(input) => write!( - f, - "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() - ), - CreatePsbtError::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), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for CreatePsbtError {} - -impl Selection { - pub(crate) fn new(inputs: Vec, outputs: Vec) -> Self { - Self { inputs, outputs } - } - - /// Inputs in this selection. - pub fn inputs(&self) -> &[Input] { - &self.inputs - } - - /// Outputs in this selection. - 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. - pub fn input_mut(&mut self, outpoint: OutPoint) -> Option> { - self.inputs - .iter_mut() - .find(|input| input.prev_outpoint() == outpoint) - .map(InputMut::new) - } - - /// Iterator yielding a mutable handle to every input in this selection. - /// - /// Each yielded [`InputMut`] only permits mutations that preserve the selection's - /// coin-selection 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`. - /// - /// 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) - where - F: FnMut(&Input, &Input) -> Ordering, - { - self.inputs.sort_by(compare); - } - - /// Randomly shuffle inputs in-place using `rng`. - /// - /// Useful for chain-analysis resistance when no deterministic ordering is required. - pub fn shuffle_inputs(&mut self, rng: &mut R) { - fisher_yates_shuffle(&mut self.inputs, rng); - } - - /// Reorder outputs in-place using `compare`. - /// - /// 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) - where - F: FnMut(&Output, &Output) -> Ordering, - { - self.outputs.sort_by(compare); - } - - /// Randomly shuffle outputs in-place using `rng`. - /// - /// Useful for chain-analysis resistance — in particular, hiding which output - /// is the change. - pub fn shuffle_outputs(&mut self, rng: &mut R) { - fisher_yates_shuffle(&mut self.outputs, rng); - } - - /// 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). - /// - /// # 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, - ), - input: self - .inputs - .iter() - .map(|input| bitcoin::TxIn { - previous_output: input.prev_outpoint(), - sequence: input.sequence().unwrap_or(Sequence::ENABLE_RBF_NO_LOCKTIME), - ..Default::default() - }) - .collect(), - output: self.outputs.iter().map(|output| output.txout()).collect(), - }; - - if let Some(tip_height) = params.anti_fee_sniping { - apply_anti_fee_sniping(&mut tx, &self.inputs, tip_height, rng)?; - }; - - let mut psbt = Psbt::from_unsigned_tx(tx).map_err(CreatePsbtError::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()); - } - // 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( - plan_input.clone(), - ))); - } - if params.mandate_full_tx_for_segwit_v0 - && witness_version == Some(bitcoin::WitnessVersion::V0) - { - return Err(CreatePsbtError::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)?; - } - } - - Ok(psbt) - } - - /// Into psbt finalizer. - pub fn into_finalizer(self) -> Finalizer { - Finalizer::new( - self.inputs - .iter() - .filter_map(|input| Some((input.prev_outpoint(), input.plan().cloned()?))), - ) - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -#[cfg(test)] -mod tests { - use super::*; - use bitcoin::{ - absolute::{self, LockTime, Time}, - relative, - secp256k1::Secp256k1, - transaction::{self, Version}, - Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, - }; - use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; - use rand_core::OsRng; - - const TEST_DESCRIPTOR: &str = "tr([83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*)"; - const TEST_DESCRIPTOR_PK: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; - const TEST_HEX_PK: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; - - fn setup_cltv_input( - cltv: absolute::LockTime, - ) -> anyhow::Result<(Input, Descriptor)> { - let secp = Secp256k1::new(); - let desc_str = format!("wsh(and_v(v:pk({TEST_HEX_PK}),after({cltv})))"); - let desc_pk: DescriptorPublicKey = TEST_HEX_PK.parse()?; - let (desc, _) = Descriptor::parse_descriptor(&secp, &desc_str)?; - let plan = desc - .at_derivation_index(0)? - .plan(&Assets::new().add(desc_pk).after(cltv)) - .unwrap(); - let prev_tx = Transaction { - version: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey: desc.at_derivation_index(0)?.script_pubkey(), - value: Amount::ONE_BTC, - }], - }; - let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; - Ok((input, desc)) - } - - #[test] - fn test_min_locktime_height() -> anyhow::Result<()> { - let abs_locktime = absolute::LockTime::from_consensus(100_000); - - let (input, desc) = setup_cltv_input(abs_locktime)?; - - let selection = Selection::new( - vec![input], - vec![Output::with_descriptor( - desc.at_derivation_index(1)?, - Amount::from_sat(1000), - )], - ); - - struct TestCase { - name: &'static str, - psbt_params: PsbtParams, - exp_locktime: u32, - } - - 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, - }, - ]; - - 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(()) - } - - /// 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. - #[test] - fn test_min_locktime_respects_lock_type() -> anyhow::Result<()> { - let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); - - let (input, desc) = setup_cltv_input(time_locktime)?; - - let selection = Selection::new( - vec![input], - vec![Output::with_descriptor( - desc.at_derivation_index(1)?, - Amount::from_sat(1000), - )], - ); - - // 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", - ); - - 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)?), - }; - - let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; - - Ok(input) - } - - #[test] - fn test_anti_fee_sniping_disabled() -> anyhow::Result<()> { - 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]); - - // 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); - - Ok(()) - } - - #[test] - fn test_anti_fee_sniping_protection() -> anyhow::Result<()> { - let current_height = 2_500; - let tip = absolute::Height::from_consensus(current_height)?; - let input = setup_test_input(2_000)?; - - let mut used_locktime = false; - let mut used_sequence = false; - let mut loops = 0; - - 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 psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; - - let tx = psbt.unsigned_tx; - - if tx.lock_time > absolute::LockTime::ZERO { - used_locktime = true; - 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; - assert!(loops < 20, "Failed to observe both behaviors"); - } - Ok(()) - } - - #[test] - fn test_anti_fee_sniping_multiple_taproot_inputs() { - let current_height = 3_000; - let tip = absolute::Height::from_consensus(current_height).unwrap(); - let input1 = setup_test_input(2_500).unwrap(); - let input2 = setup_test_input(2_700).unwrap(); - let input3 = setup_test_input(3_000).unwrap(); - let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(18_000)); - - let mut used_locktime = false; - let mut used_sequence = false; - let mut loops = 0; - - while !used_locktime || !used_sequence { - let selection = Selection::new( - vec![input1.clone(), input2.clone(), input3.clone()], - vec![output.clone()], - ); - let psbt = selection - .create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - }) - .unwrap(); - - let tx = psbt.unsigned_tx; - - if tx.lock_time > absolute::LockTime::ZERO { - 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 - }); - assert!(has_modified_sequence); - } - - loops += 1; - assert!( - loops < 20, - "Failed to observe both behaviors within reasonable attempts" - ); - } - } - - /// Regression: pre-fix, the AFS 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 = Selection::new( - vec![input], - vec![Output::with_descriptor( - desc.at_derivation_index(1)?, - Amount::from_sat(1000), - )], - ); - - // 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() - })?; - assert_eq!( - psbt.unsigned_tx.lock_time, cltv, - "AFS must not overwrite an input's CLTV with a lower value", - ); - } - - 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." - #[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})))"); - 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), - }], - }; - 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 csv_outpoint = csv_input.prev_outpoint(); - let csv_sequence = csv_input.sequence().expect("plan-derived sequence"); - - 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 { - let selection = Selection::new( - vec![regular_input.clone(), csv_input.clone()], - vec![output.clone()], - ); - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; - let tx = psbt.unsigned_tx; - - let csv_txin = tx - .input - .iter() - .find(|t| t.previous_output == csv_outpoint) - .expect("csv input must be present"); - assert_eq!( - csv_txin.sequence, csv_sequence, - "AFS must not overwrite the sequence of a CSV-bearing Taproot input", - ); - - let regular_txin = tx - .input - .iter() - .find(|t| t.previous_output == regular_outpoint) - .expect("regular input must be present"); - if regular_txin.sequence != Sequence::ENABLE_RBF_NO_LOCKTIME { - 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)", - ); - - Ok(()) - } - - /// A time-based CLTV propagates to `tx.lock_time`; AFS only supports height-based locktimes, so - /// it must surface `UnsupportedLockTime`. - #[test] - fn test_anti_fee_sniping_rejects_time_based_locktime() -> anyhow::Result<()> { - let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); - let (input, desc) = setup_cltv_input(time_locktime)?; - let tip = absolute::Height::from_consensus(800_000)?; - - let selection = Selection::new( - vec![input], - vec![Output::with_descriptor( - desc.at_derivation_index(1)?, - Amount::from_sat(1000), - )], - ); - - let result = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - }); - - assert!(matches!( - result, - Err(CreatePsbtError::AntiFeeSniping(AntiFeeSnipingError::UnsupportedLockTime(lt))) - if lt == time_locktime - )); - - Ok(()) - } - - #[test] - fn test_anti_fee_sniping_unsupported_version_error() { - 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() - }], - output: vec![], - }; - - let result = apply_anti_fee_sniping(&mut tx, &inputs, current_height, &mut OsRng); - - assert!( - matches!(result, Err(AntiFeeSnipingError::UnsupportedVersion(_))), - "should return UnsupportedVersion error for version < 2" - ); - } - - #[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); - } -} diff --git a/CHANGELOG.md b/tx/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to tx/CHANGELOG.md diff --git a/tx/Cargo.toml b/tx/Cargo.toml new file mode 100644 index 00000000..3edd3539 --- /dev/null +++ b/tx/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "bdk_tx" +version = "0.2.0" +edition = "2021" +rust-version = "1.85.0" +homepage = "https://bitcoindevkit.org" +repository = "https://github.com/bitcoindevkit/bdk-tx" +documentation = "https://docs.rs/bdk_tx" +description = "Bitcoin transaction building library." +license = "MIT OR Apache-2.0" +readme = "README.md" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + +[dependencies] +# TODO: coin-select dependency should set no default features +bdk_coin_select = { version = "0.4.1" } +miniscript = { version = "12.3.7", default-features = false } +rand_core = { version = "0.6.4", default-features = false } + +[dev-dependencies] +anyhow = "1" +bdk_tx = { path = "." } +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"] +std = ["miniscript/std", "rand/std", "bdk_coin_select/std"] + +[[example]] +name = "synopsis" + +[[example]] +name = "common" +crate-type = ["lib"] + +[[example]] +name = "anti_fee_sniping" diff --git a/tx/README.md b/tx/README.md new file mode 100644 index 00000000..3c4fa942 --- /dev/null +++ b/tx/README.md @@ -0,0 +1,22 @@ +# `bdk_tx` + +This is a transaction building library based on `rust-miniscript` that lets you build, update, and finalize PSBTs with minimal dependencies. + + +Because the project builds upon [miniscript] we support [descriptors] natively. + +Refer to [BIP174], [BIP370], and [BIP371] to learn more about partially signed bitcoin transactions (PSBT). + +**Note:** +The library is unstable and API changes should be expected. Check the [examples] directory for detailed usage examples. + + +## Contributing +Found a bug, have an issue or a feature request? Feel free to open an issue on GitHub. This library is open source licensed under MIT. + +[miniscript]: https://github.com/bitcoin/bips/blob/master/bip-0379.md +[descriptors]: https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md +[BIP174]: https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki +[BIP370]: https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki +[BIP371]: https://github.com/bitcoin/bips/blob/master/bip-0371.mediawiki +[examples]: ./examples diff --git a/examples/anti_fee_sniping.rs b/tx/examples/anti_fee_sniping.rs similarity index 90% rename from examples/anti_fee_sniping.rs rename to tx/examples/anti_fee_sniping.rs index da318b93..b0613c0d 100644 --- a/examples/anti_fee_sniping.rs +++ b/tx/examples/anti_fee_sniping.rs @@ -1,8 +1,8 @@ #![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, - SelectorParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output, + SelectionParams, }; use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate}; use miniscript::Descriptor; @@ -72,12 +72,12 @@ fn main() -> anyhow::Result<()> { .all_candidates() .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_time))) - .into_selection( - selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), - SelectorParams { - // For waste optimization when deciding change. - change_longterm_feerate: Some(longterm_feerate), - ..SelectorParams::new( + .into_tx_template( + selection_algorithm_lowest_fee_bnb(100_000), + SelectionParams { + // Drives waste optimization for both change and the bnb metric. + longterm_feerate: Some(longterm_feerate), + ..SelectionParams::new( FeeRate::from_sat_per_vb(10).expect("valid fee rate"), vec![Output::with_script( recipient_addr.script_pubkey(), @@ -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())? + .build_psbt(BuildPsbtParams::default())?; let tx = psbt.unsigned_tx; diff --git a/examples/common.rs b/tx/examples/common.rs similarity index 100% rename from examples/common.rs rename to tx/examples/common.rs diff --git a/examples/synopsis.rs b/tx/examples/synopsis.rs similarity index 87% rename from examples/synopsis.rs rename to tx/examples/synopsis.rs index 2d629ba6..0283f79b 100644 --- a/examples/synopsis.rs +++ b/tx/examples/synopsis.rs @@ -1,7 +1,7 @@ use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams, - SelectorParams, Signer, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output, + SelectionParams, Signer, }; use bitcoin::{key::Secp256k1, Amount, FeeRate}; use miniscript::Descriptor; @@ -48,16 +48,16 @@ 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))) - .into_selection( - selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), - SelectorParams { - // For waste-optimization when deciding change. - change_longterm_feerate: Some(longterm_feerate), - ..SelectorParams::new( + .into_tx_template( + selection_algorithm_lowest_fee_bnb(100_000), + SelectionParams { + // Drives waste optimization for both change and the bnb metric. + longterm_feerate: Some(longterm_feerate), + ..SelectionParams::new( FeeRate::from_sat_per_vb(10).expect("valid fee rate"), vec![Output::with_script( recipient_addr.script_pubkey(), @@ -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(); + )? + .build_psbt(BuildPsbtParams::default())?; let _ = psbt.sign(&signer, &secp); let res = finalizer.finalize(&mut psbt); @@ -122,10 +120,10 @@ 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 { + selection_algorithm_lowest_fee_bnb(100_000), + SelectionParams { // This is just a lower-bound feerate. The actual result will be much higher to // satisfy mempool-replacement policy. target_feerate: FeeRate::from_sat_per_vb(1).expect("valid fee rate"), @@ -137,8 +135,8 @@ fn main() -> anyhow::Result<()> { change_script: bdk_tx::ChangeScript::from_descriptor( internal.at_derivation_index(1)?, ), - // For waste optimization when deciding change. - change_longterm_feerate: Some(longterm_feerate), + // Drives waste optimization for both change and the bnb metric. + longterm_feerate: Some(longterm_feerate), change_min_value: None, change_dust_relay_feerate: None, // This ensures that we satisfy mempool-replacement policy rules 4 and 6. @@ -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.build_psbt(BuildPsbtParams::default())?; psbt.sign(&signer, &secp).expect("failed to sign"); assert!( finalizer.finalize(&mut psbt).is_finalized(), diff --git a/src/afs.rs b/tx/src/afs.rs similarity index 67% rename from src/afs.rs rename to tx/src/afs.rs index 9fd2bb03..b93228dd 100644 --- a/src/afs.rs +++ b/tx/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), } @@ -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` /// @@ -73,13 +75,12 @@ 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], + template: &mut TxTemplate, tip_height: absolute::Height, rng: &mut impl RngCore, ) -> Result<(), AntiFeeSnipingError> { @@ -89,63 +90,63 @@ pub(crate) fn apply_anti_fee_sniping( 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 + .set_locktime_in_place(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,7 +156,11 @@ 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(()) diff --git a/tx/src/build_psbt.rs b/tx/src/build_psbt.rs new file mode 100644 index 00000000..6160dec3 --- /dev/null +++ b/tx/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/canonical_unspents.rs b/tx/src/canonical_unspents.rs similarity index 100% rename from src/canonical_unspents.rs rename to tx/src/canonical_unspents.rs diff --git a/src/finalizer.rs b/tx/src/finalizer.rs similarity index 50% rename from src/finalizer.rs rename to tx/src/finalizer.rs index 582494fd..779776d1 100644 --- a/src/finalizer.rs +++ b/tx/src/finalizer.rs @@ -1,6 +1,6 @@ use crate::collections::{BTreeMap, HashMap}; -use bitcoin::{OutPoint, Psbt, Witness}; -use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; +use bitcoin::{psbt::PsbtSighashType, OutPoint, Psbt, Witness}; +use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::PsbtInputSatisfier}; /// Type used to finalize inputs of a Partially Signed Bitcoin Transaction (PSBT) using /// a collection of pre-computed spending plans. @@ -12,31 +12,31 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// partially signed state to a fully signed state, making it ready for extraction into a valid /// Bitcoin [`Transaction`]. /// +/// This type fills the [BIP174] *Input Finalizer* role: it consumes signatures already present in +/// the PSBT and assembles the final witness/scriptSig. +/// /// # 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 -/// 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::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 +/// non-essential fields of the PSBT inputs and outputs. /// /// # Example /// /// ```rust,no_run -/// # use bdk_tx::PsbtParams; +/// # use bdk_tx::BuildPsbtParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); /// # let keymap = std::collections::BTreeMap::new(); -/// # let selection: bdk_tx::Selection = unimplemented!(); -/// // Create PSBT from a selection of inputs and outputs. -/// let mut psbt = selection.create_psbt(PsbtParams::default())?; +/// # let template: bdk_tx::TxTemplate = unimplemented!(); +/// let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::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()); /// @@ -46,8 +46,8 @@ use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; /// ``` /// /// [BIP174]: -/// [`Selection`]: crate::Selection -/// [`into_finalizer`]: crate::Selection::into_finalizer +/// [`TxTemplate`]: crate::TxTemplate +/// [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt /// [`Plan`]: miniscript::plan::Plan /// [`Transaction`]: bitcoin::Transaction /// [`finalize_input`]: Finalizer::finalize_input @@ -58,68 +58,136 @@ pub struct Finalizer { } impl Finalizer { - /// Create. + /// Create a [`Finalizer`] from a set of `(outpoint, plan)` pairs, mapping each input's + /// previous output to the spending [`Plan`] used to satisfy it. pub fn new(plans: impl IntoIterator) -> Self { Self { plans: plans.into_iter().collect(), } } - /// Finalize a PSBT input and return whether finalization was successful or input was already - /// finalized. + /// Finalize a single PSBT input using its registered spending [`Plan`]. + /// + /// * Returns `Ok(true)` if the input was finalized (or was already finalized). + /// * Returns `Ok(false)` if no plan is registered for the input's outpoint (in which case the + /// input is left untouched). + /// + /// On success, the signature data is consumed into `final_script_sig` /`final_script_witness` + /// and all non-essential fields are cleared. Only the UTXO, the finalized scripts, and any + /// unknown/proprietary fields are retained. /// /// # Errors /// - /// If the spending plan associated with the PSBT input cannot be satisfied, - /// then a [`miniscript::Error`] is returned. + /// Returns a [`FinalizeError`]: + /// + /// * [`SighashMismatch`] - a signature's sighash type disagrees with the input's declared + /// `PSBT_IN_SIGHASH_TYPE`. + /// * [`SighashNotAllowed`] - no type is declared and a signature is neither `DEFAULT` nor `ALL`. + /// * [`SignatureTooLarge`] - a satisfied witness is larger than the plan committed to. + /// * [`Satisfaction`] - the plan cannot be satisfied from the data present in the PSBT. + /// + /// Only [`SighashMismatch`] is mandated by [BIP174]; [`SighashNotAllowed`] and + /// [`SignatureTooLarge`] are stricter-than-spec safeguards this finalizer adds. + /// + /// [BIP174]: /// /// # Panics /// - /// - If `input_index` is outside the bounds of the PSBT input vector. + /// - If `input_index` is out of bounds for the PSBT's input vector. + /// + /// [`SighashMismatch`]: FinalizeError::SighashMismatch + /// [`SighashNotAllowed`]: FinalizeError::SighashNotAllowed + /// [`SignatureTooLarge`]: FinalizeError::SignatureTooLarge + /// [`Satisfaction`]: FinalizeError::Satisfaction pub fn finalize_input( &self, psbt: &mut Psbt, input_index: usize, - ) -> Result { + ) -> Result { + let psbt_in = &psbt.inputs[input_index]; + let outpoint = psbt.unsigned_tx.input[input_index].previous_output; + // return true if already finalized. - { - let psbt_input = &psbt.inputs[input_index]; - if psbt_input.final_script_sig.is_some() || psbt_input.final_script_witness.is_some() { - return Ok(true); - } + if psbt_in.final_script_sig.is_some() || psbt_in.final_script_witness.is_some() { + return Ok(true); } - let mut finalized = false; - let outpoint = psbt - .unsigned_tx - .input - .get(input_index) - .expect("index out of range") - .previous_output; - if let Some(plan) = self.plans.get(&outpoint) { - let stfr = PsbtInputSatisfier::new(psbt, input_index); - let (stack, script) = plan.satisfy(&stfr)?; - // clearing all fields and setting back the utxo, final scriptsig and witness - let original = core::mem::take(&mut psbt.inputs[input_index]); - let psbt_input = &mut psbt.inputs[input_index]; - psbt_input.non_witness_utxo = original.non_witness_utxo; - psbt_input.witness_utxo = original.witness_utxo; - if !script.is_empty() { - psbt_input.final_script_sig = Some(script); + // We cannot finalize inputs which have no registered plan. + let plan = match self.plans.get(&outpoint) { + Some(plan) => plan, + None => return Ok(false), + }; + + // Ensure `PSBT_IN_SIGHASH_TYPE` is respected (as per BIP174). + // If unset, only permit ALL/DEFAULT (stricter-than-spec safeguard). + let mut psbt_in_sighashes = { + let partial_sigs = psbt_in.partial_sigs.values().map(|s| s.sighash_type as u32); + let tap_key_sig = psbt_in.tap_key_sig.iter().map(|s| s.sighash_type as u32); + let tap_script_sigs = psbt_in + .tap_script_sigs + .values() + .map(|s| s.sighash_type as u32); + partial_sigs.chain(tap_key_sig).chain(tap_script_sigs) + }; + if let Some(in_sighash_type) = psbt_in.sighash_type { + let exp_sighash_type = in_sighash_type.to_u32(); + if let Some(sighash_mismatch) = psbt_in_sighashes.find(|&t| t != exp_sighash_type) { + return Err(FinalizeError::SighashMismatch { + expected: PsbtSighashType::from_u32(exp_sighash_type), + got: PsbtSighashType::from_u32(sighash_mismatch), + }); } - if !stack.is_empty() { - psbt_input.final_script_witness = Some(Witness::from_slice(&stack)); + } else if let Some(sighash_mismatch) = psbt_in_sighashes.find(|&t| t > 0x01 /*ALL*/) { + return Err(FinalizeError::SighashNotAllowed { + got: PsbtSighashType::from_u32(sighash_mismatch), + }); + } + + // Ensure input can be satisfied. + let stfr = PsbtInputSatisfier::new(psbt, input_index); + let (stack, script) = plan.satisfy(&stfr).map_err(FinalizeError::Satisfaction)?; + + // Compare signature sizes against plan. + // + // Only schnorr placeholders are checked, because schnorr is the only signature type whose + // size is a plan-time choice: 64 bytes for SIGHASH_DEFAULT vs 65 for an explicit sighash. + // + // TODO: Add ECDSA checks once upstream adds them. + for (temp, stack_item) in plan.witness_template().iter().zip(&stack) { + if let Placeholder::SchnorrSigPk(_, _, size) + | Placeholder::SchnorrSigPkHash(_, _, size) = temp + { + // Only a witness *larger* than the plan is dangerous. + if stack_item.len() > *size { + return Err(FinalizeError::SignatureTooLarge { + expected: *size, + got: stack_item.len(), + }); + } } - finalized = true; } - Ok(finalized) + // Clear all fields and set back the utxo, final scriptsig, witness and unknown fields. + let original = core::mem::take(&mut psbt.inputs[input_index]); + let psbt_input = &mut psbt.inputs[input_index]; + psbt_input.non_witness_utxo = original.non_witness_utxo; + psbt_input.witness_utxo = original.witness_utxo; + psbt_input.unknown = original.unknown; + psbt_input.proprietary = original.proprietary; + if !script.is_empty() { + psbt_input.final_script_sig = Some(script); + } + if !stack.is_empty() { + psbt_input.final_script_witness = Some(Witness::from_slice(&stack)); + } + + Ok(true) } /// Attempt to finalize all of the inputs. /// - /// This method returns a [`FinalizeMap`] that contains the result of finalization - /// for each input. + /// Inputs that are already finalized are skipped. Returns a [`FinalizeMap`] holding the + /// per-input result. pub fn finalize(&self, psbt: &mut Psbt) -> FinalizeMap { let mut result = FinalizeMap(BTreeMap::new()); @@ -148,7 +216,7 @@ impl Finalizer { /// Holds the results of finalization #[derive(Debug)] -pub struct FinalizeMap(BTreeMap>); +pub struct FinalizeMap(BTreeMap>); impl FinalizeMap { /// Whether all inputs were finalized @@ -157,17 +225,84 @@ impl FinalizeMap { } /// Get the results as a map of `input_index` to `finalize_input` result. - pub fn results(self) -> BTreeMap> { + pub fn results(self) -> BTreeMap> { self.0 } } +/// Error returned when finalizing a PSBT input. +#[derive(Debug, PartialEq)] +#[non_exhaustive] +pub enum FinalizeError { + /// One of the input's signatures uses a sighash type that disagrees with the input's declared + /// `PSBT_IN_SIGHASH_TYPE`. + /// + /// [BIP174] requires finalizers to fail in this case rather than produce a transaction whose + /// signatures commit to a different sighash type than was declared. + /// + /// [BIP174]: + SighashMismatch { + /// The sighash type declared by the input's `PSBT_IN_SIGHASH_TYPE` field. + expected: PsbtSighashType, + /// The sighash type found on the offending signature. + got: PsbtSighashType, + }, + /// A signature sighash is not `ALL` or `DEFAULT` while `PSBT_IN_SIGHASH_TYPE` is unset. + /// + /// When an input omits `PSBT_IN_SIGHASH_TYPE`, the finalizer assumes the default signing + /// behavior and accepts only `DEFAULT` or `ALL`. A signature committing to anything else would + /// silently change the transaction's signing semantics. + SighashNotAllowed { + /// The sighash type found on the offending signature. + got: PsbtSighashType, + }, + /// A satisfied signature is larger than the size the spending [`Plan`] committed to (e.g. a + /// 65-byte `SIGHASH_ALL` sig where 64-byte `SIGHASH_DEFAULT` was planned). + /// + /// A heavier witness makes the finalized transaction undershoot its target feerate, + /// potentially leaving it unbroadcastable. Finalization fails rather than emit such a + /// transaction. A *smaller* witness is permitted, as it would only overpay the fee and stays + /// broadcastable. + SignatureTooLarge { + /// The witness-item size the plan committed to. + expected: usize, + /// The actual (larger) size of the satisfied witness item. + got: usize, + }, + /// The input's spending [`Plan`] cannot be satisfied with the data present in the PSBT. + Satisfaction(miniscript::Error), +} + +impl core::fmt::Display for FinalizeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + FinalizeError::SighashMismatch { expected, got } => write!( + f, + "signature has sighash type ({got}) when ({expected}) is declared in PSBT_IN_SIGHASH_TYPE" + ), + FinalizeError::SighashNotAllowed { got } => write!( + f, + "signature has sighash type ({got}); when PSBT_IN_SIGHASH_TYPE is not declared, only ALL or DEFAULT are permitted" + ), + FinalizeError::SignatureTooLarge { expected, got } => write!( + f, + "satisfied signature has size {got} but the plan committed to {expected}; finalizing would undershoot the plan's feerate estimate" + ), + FinalizeError::Satisfaction(error) => { + write!(f, "failed to satisfy spending plan: {error}") + } + } + } +} + +impl core::error::Error for FinalizeError {} + #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { - use crate::{Finalizer, Output, PsbtParams, Selection, Signer}; + use crate::{BuildPsbtParams, FinalizeError, Finalizer, Output, Signer, TxTemplate}; use bitcoin::secp256k1::Secp256k1; - use bitcoin::{absolute, transaction, Amount, ScriptBuf, TxIn, TxOut}; + use bitcoin::{absolute, transaction, Amount, ScriptBuf, TapSighashType, TxIn, TxOut}; use miniscript::bitcoin; use miniscript::bitcoin::Transaction; use miniscript::plan::Assets; @@ -217,10 +352,9 @@ 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::new(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -237,10 +371,9 @@ 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::new(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -260,7 +393,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::new( vec![input_0, input_1, input_2], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -268,8 +401,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + 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()); @@ -315,7 +447,7 @@ mod tests { input_0.plan().cloned().expect("plan must exist"), )]); - let selection = Selection::new( + let selection = TxTemplate::new( vec![input_0, input_1], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -323,7 +455,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::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; @@ -355,7 +487,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::new( vec![input], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -363,8 +495,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + 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; @@ -389,10 +520,9 @@ 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::new(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -418,4 +548,48 @@ mod tests { Ok(()) } + + #[test] + fn test_finalize_sighash_mismatch() -> 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 template = TxTemplate::new(vec![input], vec![output]); + + let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; + psbt.sign(&Signer(keymap), &Secp256k1::new()) + .expect("signing failed"); + + // The signature commits to DEFAULT, but we declare ALL, so the two disagree. + psbt.inputs[0].sighash_type = Some(TapSighashType::All.into()); + + let err = finalizer.finalize_input(&mut psbt, 0).unwrap_err(); + assert!(matches!(err, FinalizeError::SighashMismatch { .. })); + + Ok(()) + } + + #[test] + fn test_finalize_sighash_not_allowed() -> 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 template = TxTemplate::new(vec![input], vec![output]); + + let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; + psbt.sign(&Signer(keymap), &Secp256k1::new()) + .expect("signing failed"); + + // Selection now always declares PSBT_IN_SIGHASH_TYPE; clear it so this exercises the + // "no declaration, yet the signature uses neither DEFAULT nor ALL" path. + psbt.inputs[0].sighash_type = None; + psbt.inputs[0] + .tap_key_sig + .as_mut() + .expect("tap key sig") + .sighash_type = TapSighashType::Single; + + let err = finalizer.finalize_input(&mut psbt, 0).unwrap_err(); + assert!(matches!(err, FinalizeError::SighashNotAllowed { .. })); + + Ok(()) + } } diff --git a/src/input.rs b/tx/src/input.rs similarity index 98% rename from src/input.rs rename to tx/src/input.rs index dfe632cb..6f6b33df 100644 --- a/src/input.rs +++ b/tx/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 the template's 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/tx/src/input_candidates.rs b/tx/src/input_candidates.rs new file mode 100644 index 00000000..a9f4d02a --- /dev/null +++ b/tx/src/input_candidates.rs @@ -0,0 +1,596 @@ +use alloc::{vec, vec::Vec}; +use core::fmt; + +use bdk_coin_select::{ + metrics::LowestFee, Candidate, CoinSelector, InsufficientFunds, NoBnbSolution, +}; +use bitcoin::{absolute, Amount, OutPoint}; +use miniscript::bitcoin; +use rand_core::RngCore; + +use crate::collections::{BTreeMap, HashSet}; +use crate::{ + ChangePolicyError, FeeRateExt, Input, InputGroup, Output, SelectionContext, SelectionParams, + TxTemplate, +}; + +/// Input candidates. +#[must_use] +#[derive(Debug, Clone)] +pub struct InputCandidates { + /// Pre-selected input group that is included before optional candidates. + must_select: Option, + /// Optional input groups that coin selection may add. + can_select: Vec, + /// Cached coin-select candidate metadata, kept in the same order as [`Self::groups`]. + cs_candidates: Vec, + /// Cached outpoints used for deduplication and O(1) membership checks. + contains: HashSet, +} + +impl InputCandidates { + /// Construct [`InputCandidates`] with a list of inputs that must be selected as well as + /// those that may additionally be selected. If the same outpoint occurs in both `must_select` and + /// `can_select`, the one in `must_select` is retained. + pub fn new(must_select: A, can_select: B) -> Self + where + A: IntoIterator, + B: IntoIterator, + { + let mut contains = HashSet::::new(); + let must_select = InputGroup::from_inputs( + must_select + .into_iter() + .filter(|input| contains.insert(input.prev_outpoint())), + ); + let can_select = can_select + .into_iter() + .filter(|input| contains.insert(input.prev_outpoint())) + .map(InputGroup::from_input) + .collect::>(); + let cs_candidates = Self::build_cs_candidates(&must_select, &can_select); + InputCandidates { + must_select, + can_select, + cs_candidates, + contains, + } + } + + fn build_cs_candidates( + must_select: &Option, + can_select: &[InputGroup], + ) -> Vec { + must_select + .iter() + .chain(can_select) + .map(|group| Candidate { + value: group.value().to_sat(), + weight: group.weight(), + input_count: group.input_count(), + is_segwit: group.is_segwit(), + }) + .collect() + } + + /// Iterate over all contained inputs of all groups. + pub fn inputs(&self) -> impl Iterator + '_ { + self.groups().flat_map(InputGroup::inputs) + } + + /// Consume and iterate over all contained inputs of all groups. + pub fn into_inputs(self) -> impl Iterator { + self.into_groups().flat_map(InputGroup::into_inputs) + } + + /// Iterate over all contained groups. + pub fn groups(&self) -> impl Iterator + '_ { + self.must_select.iter().chain(&self.can_select) + } + + /// Consume and iterate over all contained groups. + pub fn into_groups(self) -> impl Iterator { + self.must_select.into_iter().chain(self.can_select) + } + + /// Inputs that coin selection may choose from. + pub fn can_select(&self) -> &[InputGroup] { + &self.can_select + } + + /// Inputs that must be selected, if any. + pub fn must_select(&self) -> Option<&InputGroup> { + self.must_select.as_ref() + } + + /// Cached candidate metadata used by `bdk_coin_select`. + pub fn coin_select_candidates(&self) -> &Vec { + &self.cs_candidates + } + + /// Whether the outpoint is an input candidate. + pub fn contains(&self, outpoint: OutPoint) -> bool { + self.contains.contains(&outpoint) + } + + /// Regroup inputs with given `policy`. + /// + /// Anything grouped with `must_select` inputs also becomes `must_select`. + pub fn regroup(self, mut policy: P) -> Self + where + P: FnMut(&Input) -> G, + G: Ord + Clone, + { + let mut order = Vec::::with_capacity(self.contains.len()); + let mut groups = BTreeMap::>::new(); + for input in self + .can_select + .into_iter() + .flat_map(InputGroup::into_inputs) + { + let group_id = policy(&input); + use crate::collections::btree_map::Entry; + let entry = match groups.entry(group_id.clone()) { + Entry::Vacant(entry) => { + order.push(group_id.clone()); + entry.insert(vec![]) + } + Entry::Occupied(entry) => entry.into_mut(), + }; + entry.push(input); + } + + let mut must_select = self.must_select.map_or(vec![], |g| g.into_inputs()); + let must_select_order = must_select.iter().map(&mut policy).collect::>(); + for g_id in must_select_order { + if let Some(inputs) = groups.remove(&g_id) { + must_select.extend(inputs); + } + } + let must_select = InputGroup::from_inputs(must_select); + + let mut can_select = Vec::::new(); + for g_id in order { + if let Some(inputs) = groups.remove(&g_id) { + if let Some(group) = InputGroup::from_inputs(inputs) { + can_select.push(group); + } + } + } + + let cs_candidates = Self::build_cs_candidates(&must_select, &can_select); + let no_dup = self.contains; + + Self { + must_select, + can_select, + cs_candidates, + contains: no_dup, + } + } + + /// Filters out inputs. + /// + /// If a filtered-out input is part of a group, the group will also be filtered out. + /// Does not filter `must_select` inputs. + pub fn filter

(mut self, mut policy: P) -> Self + where + P: FnMut(&Input) -> bool, + { + let mut to_rm = Vec::::new(); + self.can_select.retain(|group| { + let retain = group.all(&mut policy); + if !retain { + for input in group.inputs() { + to_rm.push(input.prev_outpoint()); + } + } + retain + }); + for op in to_rm { + self.contains.remove(&op); + } + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + 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 *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(); + 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 *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(); + 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 `params`, returning a [`TxTemplate`]. + /// + /// This drives the whole selection lifecycle: it resolves `params`, validates the candidates, + /// runs the provided `algorithm` against a [`CoinSelector`], then finalizes the result into a + /// [`TxTemplate`]. The `algorithm` is handed the [`CoinSelector`] to drive and a + /// [`SelectionContext`] describing the resolved target, change policy and long-term feerate. + /// + /// # Errors + /// + /// - [`IntoTxTemplateError::ChangePolicy`] if the change policy cannot be built from the params. + /// - [`IntoTxTemplateError::LockTypeMismatch`] if the candidates have incompatible absolute + /// timelock units. + /// - [`IntoTxTemplateError::CannotMeetTarget`] if the target is unreachable even when selecting + /// every effective input at the target feerate - i.e. genuinely impossible. + /// - [`IntoTxTemplateError::Algorithm`] if the `algorithm` itself errors. + /// - [`IntoTxTemplateError::AlgorithmFellShort`] if the `algorithm` returns successfully but + /// its selection still falls short of the target. + pub fn into_tx_template( + self, + algorithm: A, + params: SelectionParams, + ) -> Result> + where + A: FnOnce(&mut CoinSelector, SelectionContext) -> Result<(), E>, + { + let target = params.to_cs_target(); + let change_policy = params + .to_cs_change_policy() + .map_err(IntoTxTemplateError::ChangePolicy)?; + let longterm_feerate = params + .longterm_feerate + .unwrap_or(params.target_feerate) + .into_cs_feerate(); + let change_script = params.change_script.source(); + let target_outputs = params.target_outputs; + + // Verify that all inputs agree on absolute timelock unit (height vs time). Downstream + // stages (create_psbt, apply_anti_fee_sniping) rely on this invariant. + let mut unit: Option = None; + for lt in self.inputs().filter_map(Input::absolute_timelock) { + match unit { + Some(existing_unit) => { + if !existing_unit.is_same_unit(lt) { + return Err(IntoTxTemplateError::LockTypeMismatch); + } + } + None => unit = Some(lt), + } + } + + let mut cs = CoinSelector::new(self.coin_select_candidates()); + if self.must_select().is_some() { + cs.select_next(); + } + + // Reachability pre-check. + { + let mut check = cs.clone(); + check.select_all_effective(target.fee.rate); + let max_excess = check.excess(target, bdk_coin_select::Drain::NONE); + if max_excess < 0 { + return Err(IntoTxTemplateError::CannotMeetTarget { + missing: max_excess.unsigned_abs(), + }); + } + } + + algorithm( + &mut cs, + SelectionContext { + target, + change_policy, + longterm_feerate, + }, + ) + .map_err(IntoTxTemplateError::Algorithm)?; + + // Ensure target is actually met after selection. The target was already proven reachable, + // so a shortfall here is the algorithm under-selecting. + let drain = cs.drain(target, change_policy); + if cs.excess(target, drain) < 0 { + return Err(IntoTxTemplateError::AlgorithmFellShort); + } + + let to_apply = self.groups().collect::>(); + let inputs = cs + .apply_selection(&to_apply) + .copied() + .flat_map(InputGroup::inputs) + .cloned() + .collect(); + let mut outputs = target_outputs; + if drain.is_some() { + outputs.push(Output::from((change_script, Amount::from_sat(drain.value)))); + } + Ok(TxTemplate::new(inputs, outputs)) + } +} + +/// Error returned by [`InputCandidates::into_tx_template`]. +/// +/// Covers every way the lifecycle can fail: an unbuildable change policy, incompatible candidate +/// timelocks, an impossible target, a failing algorithm, or an algorithm that finished without +/// meeting the target. +#[derive(Debug)] +pub enum IntoTxTemplateError { + /// The change policy could not be built from the params (see [`ChangePolicyError`]). + ChangePolicy(ChangePolicyError), + /// Input candidates have absolute timelocks of mixed units (some height-based, others + /// time-based), which is unbuildable since `nLockTime` is a single field on a transaction. + LockTypeMismatch, + /// The target is impossible: unreachable even when selecting every effective input at the + /// target feerate. + CannotMeetTarget { + /// The shortfall in satoshis at best-case (all effective inputs selected). + missing: u64, + }, + /// The selection algorithm itself returned an error. + Algorithm(E), + /// The algorithm returned successfully but its selection still falls short of the target. + /// + /// This is an algorithm contract violation as the target *was* reachable — the algorithm simply + /// did not select enough inputs. A correct algorithm either meets the target or returns its own + /// error. + AlgorithmFellShort, +} + +impl fmt::Display for IntoTxTemplateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IntoTxTemplateError::ChangePolicy(error) => write!(f, "{error}"), + IntoTxTemplateError::LockTypeMismatch => { + write!(f, "input candidates have absolute timelocks of mixed units") + } + IntoTxTemplateError::CannotMeetTarget { missing } => write!( + f, + "meeting the target is not possible with the input candidates; {missing} sats missing" + ), + IntoTxTemplateError::Algorithm(error) => { + write!(f, "selection algorithm failed: {error}") + } + IntoTxTemplateError::AlgorithmFellShort => write!( + f, + "the selection algorithm returned successfully but did not meet the target" + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for IntoTxTemplateError {} + +/// Select for lowest fee with bnb. +/// +/// The long-term feerate is taken from the [`SelectionContext`] (resolved from +/// [`SelectionParams::longterm_feerate`](crate::SelectionParams::longterm_feerate)), so the same +/// estimate drives both this metric and the change policy. +pub fn selection_algorithm_lowest_fee_bnb( + max_rounds: usize, +) -> impl FnOnce(&mut CoinSelector, SelectionContext) -> Result<(), NoBnbSolution> { + move |cs, cx| { + cs.run_bnb( + LowestFee { + target: cx.target, + long_term_feerate: cx.longterm_feerate, + change_policy: cx.change_policy, + }, + max_rounds, + ) + .map(|_| ()) + } +} + +/// 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 +/// [`InputCandidates::into_tx_template`]. +pub fn selection_algorithm_single_random_draw( + rng: &mut impl RngCore, +) -> impl FnOnce(&mut CoinSelector, SelectionContext) -> Result<(), InsufficientFunds> + '_ { + move |cs, cx| { + // 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 = cs.candidates().len(); + let keys: Vec = (0..n).map(|_| rng.next_u64()).collect(); + cs.sort_candidates_by_key(|(i, _)| keys[i]); + cs.select_until_target_met(cx.target) + } +} + +/// Default group policy. +pub fn group_by_spk() -> impl Fn(&Input) -> bitcoin::ScriptBuf { + |input| input.prev_txout().script_pubkey.clone() +} + +/// Filter out inputs that cannot be spent now. +/// +/// If an input's spendability cannot be determined, it will also be filtered out. +pub fn filter_unspendable( + tip_height: absolute::Height, + tip_mtp: Option, +) -> impl Fn(&Input) -> bool { + move |input| input.is_spendable(tip_height, tip_mtp).unwrap_or(false) +} + +/// No filtering. +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); + } +} diff --git a/src/lib.rs b/tx/src/lib.rs similarity index 92% rename from src/lib.rs rename to tx/src/lib.rs index b60312ec..696a1f27 100644 --- a/src/lib.rs +++ b/tx/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; @@ -19,11 +20,12 @@ mod input_candidates; mod no_std_rand; mod output; mod rbf; -mod selection; -mod selector; +mod selection_params; mod signer; +mod tx_template; pub use afs::*; +pub use build_psbt::*; pub use canonical_unspents::*; pub use finalizer::*; pub use input::*; @@ -34,9 +36,9 @@ use miniscript::{DefiniteDescriptorKey, Descriptor}; use no_std_rand::*; pub use output::*; pub use rbf::*; -pub use selection::*; -pub use selector::*; +pub use selection_params::*; pub use signer::*; +pub use tx_template::*; #[cfg(feature = "std")] pub(crate) mod collections { diff --git a/src/no_std_rand.rs b/tx/src/no_std_rand.rs similarity index 100% rename from src/no_std_rand.rs rename to tx/src/no_std_rand.rs diff --git a/src/output.rs b/tx/src/output.rs similarity index 100% rename from src/output.rs rename to tx/src/output.rs diff --git a/src/rbf.rs b/tx/src/rbf.rs similarity index 100% rename from src/rbf.rs rename to tx/src/rbf.rs diff --git a/src/selector.rs b/tx/src/selection_params.rs similarity index 60% rename from src/selector.rs rename to tx/src/selection_params.rs index 4c98ec86..39483aca 100644 --- a/src/selector.rs +++ b/tx/src/selection_params.rs @@ -1,24 +1,29 @@ -use bdk_coin_select::{InsufficientFunds, Replace, Target, TargetFee, TargetOutputs}; +use bdk_coin_select::{Replace, Target, TargetFee, TargetOutputs}; use bitcoin::{Amount, FeeRate, ScriptBuf, Transaction, Weight}; use miniscript::bitcoin; -use crate::{ - DefiniteDescriptor, FeeRateExt, Input, InputCandidates, InputGroup, Output, ScriptSource, - Selection, -}; +use crate::{DefiniteDescriptor, FeeRateExt, Output, ScriptSource}; use alloc::boxed::Box; use alloc::vec::Vec; -use core::fmt::{self, Debug}; +use core::fmt; -/// A coin selector -#[derive(Debug, Clone)] -pub struct Selector<'c> { - candidates: &'c InputCandidates, - target_outputs: Vec, - target: Target, - change_policy: bdk_coin_select::ChangePolicy, - change_script: ScriptSource, - inner: bdk_coin_select::CoinSelector<'c>, +/// Context handed to a selection algorithm. +/// +/// This is pure data describing the resolved coin-selection target and the parameters an algorithm +/// needs to make change/waste decisions. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct SelectionContext { + /// The resolved coin-selection target (recipient value, weight and feerate). + pub target: Target, + /// The change policy derived from the params, used to decide whether to add a change output. + pub change_policy: bdk_coin_select::ChangePolicy, + /// Long-term feerate used for waste calculations, e.g. by metrics such as + /// [`LowestFee`](bdk_coin_select::metrics::LowestFee). + /// + /// Resolved from [`SelectionParams::longterm_feerate`]; when that is `None` it defaults to the + /// target feerate (i.e. "assume future conditions match the present"). + pub longterm_feerate: bdk_coin_select::FeeRate, } /// Parameters for creating tx. @@ -30,7 +35,7 @@ pub struct Selector<'c> { /// If the caller wants to create non-mempool-policy conforming txs, they can just fill in the /// fields directly. #[derive(Debug)] -pub struct SelectorParams { +pub struct SelectionParams { /// Target feerate. /// /// The actual feerate of the resulting transaction may be higher due to RBF requirements or @@ -56,11 +61,13 @@ pub struct SelectorParams { /// A change value below this is forgone as fee. `None` means only the dust threshold applies. pub change_min_value: Option, - /// Long-term feerate for waste optimization when deciding whether to include change. + /// Long-term feerate used for waste optimization across the whole selection - both the change + /// policy and metrics such as [`LowestFee`](bdk_coin_select::metrics::LowestFee). /// - /// `None` means no waste optimization - just enforce `change_min_value` (if specified) and the - /// dust threshold. - pub change_longterm_feerate: Option, + /// Represents the feerate at which the resulting coins are expected to be spent in the future. + /// `None` means "assume it matches `target_feerate`", the neutral default when no estimate is + /// available. + pub longterm_feerate: Option, /// Params for replacing tx(s). pub replace: Option, @@ -69,8 +76,8 @@ pub struct SelectorParams { /// Source of the change output script and its spending cost. /// /// For a [`DefiniteDescriptor`], the satisfaction weight is derived automatically. For a raw -/// script (e.g. silent payments), the caller may provide it. It can be omitted if the change -/// policy does not require waste calculations. +/// script (e.g. silent payments), the caller must provide it: the change policy is always +/// waste-aware, so an accurate spend cost is needed to decide whether a change output is worth it. #[derive(Debug)] pub enum ChangeScript { /// A raw script pubkey. @@ -84,7 +91,8 @@ pub enum ChangeScript { /// [`Plan::satisfaction_weight`](miniscript::plan::Plan::satisfaction_weight) and is used /// by coin selection to estimate the cost of spending the change output. /// - /// Can be `Weight::ZERO` if `SelectorParams::change_longterm_feerate` is unspecified. + /// This always feeds the waste calculation, so it should reflect the real spend cost; a + /// `Weight::ZERO` here will skew the change/no-change decision. satisfaction_weight: Weight, }, /// A definite descriptor from which the script and satisfaction weight are both derived. @@ -144,7 +152,7 @@ impl ChangeScript { } } - fn satisfaction_weight(&self) -> Result { + fn satisfaction_weight(&self) -> Result { match &self { ChangeScript::Script { satisfaction_weight, @@ -158,10 +166,10 @@ impl ChangeScript { .clone() .plan(assets) .map(|p| Weight::from_wu_usize(p.satisfaction_weight())) - .map_err(|_| SelectorError::InsufficientAssets), + .map_err(|_| ChangePolicyError::InsufficientAssets), None => descriptor .max_weight_to_satisfy() - .map_err(SelectorError::Miniscript), + .map_err(ChangePolicyError::Miniscript), }, } } @@ -246,7 +254,7 @@ impl RbfParams { } } -impl SelectorParams { +impl SelectionParams { /// With default params. pub fn new( target_feerate: FeeRate, @@ -258,7 +266,7 @@ impl SelectorParams { target_outputs, change_script, change_min_value: None, - change_longterm_feerate: None, + longterm_feerate: None, replace: None, change_dust_relay_feerate: None, } @@ -287,11 +295,12 @@ impl SelectorParams { /// /// # Errors /// - /// Returns [`SelectorError::InsufficientAssets`] if the provided assets cannot satisfy the + /// Returns [`ChangePolicyError::InsufficientAssets`] if the provided assets cannot satisfy the /// change descriptor. /// - /// Returns [`SelectorError::Miniscript`] if the change descriptor is inherently unsatisfiable. - pub fn to_cs_change_policy(&self) -> Result { + /// Returns [`ChangePolicyError::Miniscript`] if the change descriptor is inherently + /// unsatisfiable. + pub fn to_cs_change_policy(&self) -> Result { let change_script = self.change_script.source().script(); let min_non_dust = self.change_dust_relay_feerate.map_or_else( || change_script.minimal_non_dust(), @@ -316,199 +325,46 @@ impl SelectorParams { .max(self.change_min_value.unwrap_or(Amount::ZERO)) .to_sat(); - Ok( - if let Some(longterm_feerate) = self.change_longterm_feerate { - bdk_coin_select::ChangePolicy::min_value_and_waste( - change_weights, - min_value, - self.target_feerate.into_cs_feerate(), - longterm_feerate.into_cs_feerate(), - ) - } else { - bdk_coin_select::ChangePolicy::min_value(change_weights, min_value) - }, - ) + // The change policy is always waste-aware. When no long-term feerate is configured we fall + // back to the target feerate, i.e. "assume the change will be spent under today's + // conditions". Note this does not collapse to a plain dust threshold: the change output + // still has to clear its own lifetime cost (creation now plus spending later). + Ok(bdk_coin_select::ChangePolicy::min_value_and_waste( + change_weights, + min_value, + self.target_feerate.into_cs_feerate(), + self.longterm_feerate + .unwrap_or(self.target_feerate) + .into_cs_feerate(), + )) } } -/// Error when the selection is impossible with the input candidates -#[derive(Debug)] -pub struct CannotMeetTarget; - -impl fmt::Display for CannotMeetTarget { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "meeting the target is not possible with the input candidates" - ) - } -} - -#[cfg(feature = "std")] -impl std::error::Error for CannotMeetTarget {} - -/// Selector error +/// Error building the change policy from [`SelectionParams`]. +/// +/// Returned by [`SelectionParams::to_cs_change_policy`]; every variant stems from the change +/// descriptor being unsatisfiable with the available assets. #[derive(Debug)] -pub enum SelectorError { +pub enum ChangePolicyError { /// Miniscript error (e.g. the change descriptor is inherently unsatisfiable). Miniscript(miniscript::Error), - /// Meeting the target is not possible with the input candidates. - CannotMeetTarget(CannotMeetTarget), /// The provided assets cannot satisfy the change descriptor. InsufficientAssets, - /// Input candidates have absolute timelocks of mixed units (some height-based, others - /// time-based). - /// - /// Such a set is unbuildable since `nLockTime` is a single field on a transaction. - /// Filter the [`InputCandidates`] down to a single-unit subset before constructing the - /// [`Selector`]. - LockTypeMismatch, } -impl fmt::Display for SelectorError { +impl fmt::Display for ChangePolicyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Miniscript(err) => write!(f, "{err}"), - Self::CannotMeetTarget(err) => write!(f, "{err}"), Self::InsufficientAssets => { write!(f, "provided assets cannot satisfy the change descriptor") } - Self::LockTypeMismatch => { - write!(f, "input candidates have absolute timelocks of mixed units") - } } } } #[cfg(feature = "std")] -impl std::error::Error for SelectorError {} - -impl<'c> Selector<'c> { - /// Create new input selector. - /// - /// # Errors - /// - /// - If we are unable to create a change policy from the `params`. - /// - If the target is unreachable given the total input value. - pub fn new( - candidates: &'c InputCandidates, - params: SelectorParams, - ) -> Result { - let target = params.to_cs_target(); - let change_policy = params.to_cs_change_policy()?; - let target_outputs = params.target_outputs; - let change_script = params.change_script.source(); - - if target.value() > candidates.groups().map(|grp| grp.value().to_sat()).sum() { - return Err(SelectorError::CannotMeetTarget(CannotMeetTarget)); - } - - // Verify that all inputs agree on absolute timelock unit (height vs time). - // Downstream stages (create_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 { - Some(existing_unit) => { - if !existing_unit.is_same_unit(lt) { - return Err(SelectorError::LockTypeMismatch); - } - } - None => unit = Some(lt), - } - } - - let mut inner = bdk_coin_select::CoinSelector::new(candidates.coin_select_candidates()); - if candidates.must_select().is_some() { - inner.select_next(); - } - Ok(Self { - candidates, - target, - target_outputs, - change_policy, - change_script, - inner, - }) - } - - /// Get the inner coin selector. - pub fn inner(&self) -> &bdk_coin_select::CoinSelector<'c> { - &self.inner - } - - /// Get a mutable reference to the inner coin selector. - pub fn inner_mut(&mut self) -> &mut bdk_coin_select::CoinSelector<'c> { - &mut self.inner - } - - /// Coin selection target. - pub fn target(&self) -> Target { - self.target - } - - /// Coin selection change policy. - pub fn cs_change_policy(&self) -> bdk_coin_select::ChangePolicy { - self.change_policy - } - - /// Select with the provided `algorithm`. - pub fn select_with_algorithm(&mut self, mut algorithm: F) -> Result<(), E> - where - F: FnMut(&mut Selector) -> Result<(), E>, - { - algorithm(self) - } - - /// Select all. - pub fn select_all(&mut self) { - self.inner.select_all(); - } - - /// Select in order until target is met. - pub fn select_until_target_met(&mut self) -> Result<(), InsufficientFunds> { - self.inner.select_until_target_met(self.target) - } - - /// Whether we added the change output to the selection. - /// - /// Return `None` if target is not met yet. - pub fn has_change(&self) -> Option { - if !self.inner.is_target_met(self.target) { - return None; - } - let has_drain = self - .inner - .drain_value(self.target, self.change_policy) - .is_some(); - Some(has_drain) - } - - /// Try get final selection. - /// - /// Return `None` if target is not met yet. - pub fn try_finalize(&self) -> Option { - if !self.inner.is_target_met(self.target) { - return None; - } - let maybe_change = self.inner.drain(self.target, self.change_policy); - let to_apply = self.candidates.groups().collect::>(); - let inputs = self - .inner - .apply_selection(&to_apply) - .copied() - .flat_map(InputGroup::inputs) - .cloned() - .collect(); - let mut outputs = self.target_outputs.clone(); - if maybe_change.is_some() { - outputs.push(Output::from(( - self.change_script.clone(), - Amount::from_sat(maybe_change.value), - ))); - } - Some(Selection::new(inputs, outputs)) - } -} +impl std::error::Error for ChangePolicyError {} #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] @@ -545,19 +401,20 @@ mod tests { } #[test] - fn test_selector_rejects_mixed_absolute_locktime_units() -> anyhow::Result<()> { + fn test_selection_rejects_mixed_absolute_locktime_units() -> anyhow::Result<()> { let height_locked_input = setup_cltv_input(absolute::LockTime::from_consensus(10_000))?; let time_locked_input = setup_cltv_input(absolute::LockTime::from_consensus(500_000_001))?; let candidates = InputCandidates::new([], [height_locked_input, time_locked_input]); - let params = SelectorParams::new( + let params = SelectionParams::new( FeeRate::ZERO, vec![], ChangeScript::from_script(ScriptBuf::new(), Weight::ZERO), ); - assert!(matches!( - Selector::new(&candidates, params), - Err(SelectorError::LockTypeMismatch) - )); + let result = candidates.into_tx_template( + |_cs, _cx| Result::<(), core::convert::Infallible>::Ok(()), + params, + ); + assert!(matches!(result, Err(IntoTxTemplateError::LockTypeMismatch))); Ok(()) } } diff --git a/src/signer.rs b/tx/src/signer.rs similarity index 58% rename from src/signer.rs rename to tx/src/signer.rs index d0866c3d..f371a096 100644 --- a/src/signer.rs +++ b/tx/src/signer.rs @@ -1,6 +1,4 @@ use alloc::collections::BTreeMap; -use alloc::string::ToString; -use alloc::vec::Vec; use bitcoin::{ psbt::{GetKey, GetKeyError, KeyRequest}, @@ -34,28 +32,30 @@ impl GetKey for Signer { } (_, desc_sk) => { for desc_sk in desc_sk.clone().into_single_keys() { - if let KeyRequest::Bip32((fingerprint, derivation)) = &key_request { - if let DescriptorSecretKey::XPrv(k) = desc_sk { - // We have the xprv for the request - if let Ok(Some(prv)) = - GetKey::get_key(&k.xkey, key_request.clone(), secp) - { - return Ok(Some(prv)); - } - // The key origin is a strict prefix of the request derivation - if let Some((fp, path)) = &k.origin { - if fingerprint == fp - && derivation.to_string().starts_with(&path.to_string()) - { - let to_derive = derivation - .into_iter() - .skip(path.len()) - .cloned() - .collect::>(); - let derived = k.xkey.derive_priv(secp, &to_derive)?; - return Ok(Some(derived.to_priv())); - } - } + if let (DescriptorSecretKey::XPrv(k), KeyRequest::Bip32(key_source)) = + (desc_sk, &key_request) + { + // We may hold the xprv for the request directly. + if let Ok(Some(prv)) = + GetKey::get_key(&k.xkey, key_request.clone(), secp) + { + return Ok(Some(prv)); + } + // Otherwise let miniscript's `matches` confirm this key + // represents the request (handling the key origin and + // wildcard); a raw prefix check would also derive paths the + // descriptor never declares, e.g. a sibling of the wildcard. + if k.matches(key_source, secp).is_some() { + // `xkey` is anchored at the origin, so strip the origin + // prefix from the master-relative request path; with no + // origin the whole path is relative to `xkey`. + let (_, derivation) = key_source; + let to_derive = match &k.origin { + Some((_, origin)) => &derivation[origin.len()..], + None => derivation.as_ref(), + }; + let derived = k.xkey.derive_priv(secp, &to_derive)?; + return Ok(Some(derived.to_priv())); } } } @@ -70,6 +70,7 @@ impl GetKey for Signer { #[cfg(test)] mod test { use crate::bitcoin::bip32::ChildNumber; + use alloc::string::ToString; use core::str::FromStr; use std::string::String; @@ -147,11 +148,6 @@ mod test { desc: format!("tr([{fp}/{path}]{derived}/0/*)"), derivation: format!("{path}/0/7"), }, - TestCase { - name: "key origin matches request derivation", - desc: format!("tr([{fp}/{path}]{derived}/0/*)"), - derivation: path.to_string(), - }, ]; for test in cases { @@ -172,6 +168,33 @@ mod test { Ok(()) } + // `matches` only signs for keys the descriptor actually represents. Requests + // that share the origin as a prefix but fall outside the declared derivation + // are rejected: the account-level key itself (request == origin) and a sibling + // of the wildcard branch (`/9/*` when the descriptor declares `/0/*`). + #[test] + fn get_key_bip32_rejects_paths_outside_descriptor() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let xprv: Xpriv = "tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L".parse()?; + let fp = xprv.fingerprint(&secp); + let path: DerivationPath = "86h/1h/0h".parse()?; + let derived = xprv.derive_priv(&secp, &path)?; + let desc = format!("tr([{fp}/{path}]{derived}/0/*)"); + + for derivation in [path.to_string(), format!("{path}/9/7")] { + let deriv: DerivationPath = derivation.parse()?; + let req = KeyRequest::Bip32((fp, deriv)); + let (_, keymap) = Descriptor::parse_descriptor(&secp, &desc)?; + let res = Signer(keymap).get_key(req, &secp); + assert!( + matches!(res, Ok(None)), + "expected None for {derivation}: {res:?}" + ); + } + + Ok(()) + } + #[test] fn get_key_xpriv_with_key_origin() -> anyhow::Result<()> { let secp = Secp256k1::new(); @@ -202,4 +225,26 @@ mod test { Ok(()) } + + // The origin "84h/1h/0h/1" is a string prefix of "84h/1h/0h/10" even though + // m/84'/1'/0'/1 is NOT a derivation-path prefix of m/84'/1'/0'/10. The signer + // must reject this request instead of leaking the key at the origin path. + #[test] + fn get_key_bip32_string_prefix_not_path_prefix() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let xprv: Xpriv = "tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L".parse()?; + let fp = xprv.fingerprint(&secp); + + let origin_path: DerivationPath = "84h/1h/0h/1".parse()?; + let derived = xprv.derive_priv(&secp, &origin_path)?; + let desc = format!("wpkh([{fp}/{origin_path}]{derived}/*)"); + let (_, keymap) = Descriptor::parse_descriptor(&secp, &desc)?; + + let request_path: DerivationPath = "84h/1h/0h/10".parse()?; + let req = KeyRequest::Bip32((fp, request_path)); + let res = Signer(keymap).get_key(req, &secp); + + assert!(matches!(res, Ok(None)), "expected None, got: {res:?}"); + Ok(()) + } } diff --git a/tx/src/tx_template.rs b/tx/src/tx_template.rs new file mode 100644 index 00000000..79a5d72e --- /dev/null +++ b/tx/src/tx_template.rs @@ -0,0 +1,1075 @@ +//! Tx-shaping stage between coin selection and the final [`Psbt`] or [`Transaction`]. +//! +//! A [`TxTemplate`] is obtained from [`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. +//! +//! [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template +//! [`Transaction`]: bitcoin::Transaction + +use alloc::boxed::Box; +use alloc::vec::Vec; +use bitcoin::{EcdsaSighashType, TapSighashType}; +use core::cmp::Ordering; +use core::fmt::{Debug, Display}; +use miniscript::bitcoin; +use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence, Transaction, TxIn}; +use miniscript::psbt::PsbtExt; +use rand_core::RngCore; + +use crate::{ + 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. +/// +/// 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 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(), + ))); + } + } + // Safety auto-lock: any 64B Schnorr placeholder forces `Default`, independent + // of `declare_sighash`. A 64B-budgeted Plan signed with a 65B sig would + // silently under-fund the tx, and there is no caller scenario where that's + // intended - so this fires even when declaration is opted out. + use miniscript::miniscript::satisfy::Placeholder; + let any_64b_schnorr = plan + .witness_template() + .iter() + .filter_map(|p| match p { + Placeholder::SchnorrSigPk(_, _, size) + | Placeholder::SchnorrSigPkHash(_, _, size) => Some(*size == 64), + _ => None, + }) + .reduce(|a, b| a || b); + psbt_input.sighash_type = match any_64b_schnorr { + Some(true) => Some(TapSighashType::Default.into()), + Some(false) => Some(TapSighashType::All.into()), + None => Some(EcdsaSighashType::All.into()), + }; + + 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 [`InputCandidates::into_tx_template`]. Exposes the operations that *shape* the resulting +/// 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`. +/// +/// [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template +#[derive(Debug, Clone)] +#[must_use] +pub struct TxTemplate(SealedTxTemplate); + +impl core::ops::Deref for TxTemplate { + type Target = SealedTxTemplate; + + fn deref(&self) -> &SealedTxTemplate { + &self.0 + } +} + +impl TxTemplate { + 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, + lock_time, + fallback_sequence: FALLBACK_SEQUENCE, + inputs, + outputs, + }) + } + + /// 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 + .0 + .inputs + .iter() + .any(|i| i.relative_timelock().is_some()) + { + return Err(SetVersionError::RelativeTimelockRequiresV2 { attempted: version }); + } + self.0.version = version; + Ok(self) + } + + /// Set the fallback `nSequence` used for inputs that don't specify their own. + /// + /// 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.0.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 { + 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; + }; + 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.0.lock_time = lock_time; + Ok(()) + } + + /// Mutable handle to the input spending `outpoint`, if any. + /// + /// 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.0 + .inputs + .iter_mut() + .find(|input| input.prev_outpoint() == outpoint) + .map(InputMut::new) + } + + /// Iterator yielding a mutable handle to every input in this template. + /// + /// 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.0.inputs.iter_mut().map(InputMut::new) + } + + /// Reorder inputs using `compare`. Uses a stable sort. + /// + /// Typical use is BIP-69 lexicographic ordering by previous outpoint. + pub fn sort_inputs_by(mut self, compare: F) -> Self + where + F: FnMut(&Input, &Input) -> Ordering, + { + self.0.inputs.sort_by(compare); + self + } + + /// 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) -> Self { + fisher_yates_shuffle(&mut self.0.inputs, rng); + self + } + + /// Reorder outputs using `compare`. Uses a stable sort. + /// + /// Typical use is BIP-69 (ascending by amount, then by `script_pubkey`). + pub fn sort_outputs_by(mut self, compare: F) -> Self + where + F: FnMut(&Output, &Output) -> Ordering, + { + self.0.outputs.sort_by(compare); + self + } + + /// 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) -> Self { + fisher_yates_shuffle(&mut self.0.outputs, rng); + self + } + + /// 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. + /// + /// 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( + mut self, + tip_height: absolute::Height, + rng: &mut R, + ) -> 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> { + self.0.build_psbt(params) + } +} + +/// 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). +/// `into_tx_template` 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), + "into_tx_template should reject mixed-unit candidates", + ); + if a.is_implied_by(b) { + b + } else { + a + } + }) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{ + absolute::{self, LockTime, Time}, + relative, + secp256k1::Secp256k1, + transaction::Version, + Amount, ScriptBuf, Transaction, TxIn, TxOut, + }; + use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + use rand::thread_rng; + use rand_core::OsRng; + + const TEST_KEY_HEX: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; + const TEST_KEY_TR: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; + const TEST_KEY_TR_2: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/1/*"; + const TEST_KEY_TR_3: &str = "[44444444/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/2/*"; + const TEST_KEY_WPKH: &str = "[83737d5e/84h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; + + fn setup_cltv_input( + cltv: absolute::LockTime, + ) -> anyhow::Result<(Input, Descriptor)> { + let secp = Secp256k1::new(); + let desc_str = format!("wsh(and_v(v:pk({TEST_KEY_HEX}),after({cltv})))"); + let desc_pk: DescriptorPublicKey = TEST_KEY_HEX.parse()?; + let (desc, _) = Descriptor::parse_descriptor(&secp, &desc_str)?; + let plan = desc + .at_derivation_index(0)? + .plan(&Assets::new().add(desc_pk).after(cltv)) + .unwrap(); + let prev_tx = Transaction { + version: Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: desc.at_derivation_index(0)?.script_pubkey(), + value: Amount::ONE_BTC, + }], + }; + let input = Input::from_prev_tx(plan, prev_tx, 0, None)?; + Ok((input, desc)) + } + + fn setup_test_input(confirmation_height: u32) -> anyhow::Result { + let secp = Secp256k1::new(); + let desc = Descriptor::parse_descriptor(&secp, &format!("tr({TEST_KEY_TR})")) + .unwrap() + .0; + let def_desc = desc.at_derivation_index(0).unwrap(); + let script_pubkey = def_desc.script_pubkey(); + let desc_pk: DescriptorPublicKey = TEST_KEY_TR.parse()?; + let assets = Assets::new().add(desc_pk); + let plan = def_desc.plan(&assets).expect("failed to create plan"); + + 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::new( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); + + let template = selection.clone(); + assert_eq!( + template.lock_time(), + cltv, + "construction takes lock_time from input CLTV" + ); + + let higher = absolute::LockTime::from_consensus(100_100); + let bumped = selection.set_locktime(higher)?; + assert_eq!(bumped.lock_time(), higher); + + 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::new( + 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(()) + } + + /// A time-based input CLTV propagates to the template's lock_time at construction. + #[test] + 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::new( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); + + assert_eq!(selection.lock_time(), time_locktime); + + Ok(()) + } + + /// `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::new( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); + + let time_attempt = absolute::LockTime::from_consensus(1_734_230_218); + let result = selection.set_locktime(time_attempt); + + 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 set_locktime_propagates_to_psbt() -> anyhow::Result<()> { + 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::new(vec![input], vec![output]); + + let (psbt, _) = selection + .set_locktime(absolute::LockTime::from_consensus(current_height))? + .build_psbt(BuildPsbtParams::default())?; + assert_eq!( + psbt.unsigned_tx.lock_time.to_consensus_u32(), + current_height + ); + Ok(()) + } + + #[test] + fn test_anti_fee_sniping_protection() -> anyhow::Result<()> { + let current_height = 2_500; + let tip = absolute::Height::from_consensus(current_height)?; + let input = setup_test_input(2_000)?; + + let mut used_locktime = false; + let mut used_sequence = false; + let mut loops = 0; + + while !used_locktime || !used_sequence { + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); + let selection = TxTemplate::new(vec![input.clone()], vec![output]); + + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .build_psbt(BuildPsbtParams::default())?; + + let tx = psbt.unsigned_tx; + + if tx.lock_time > absolute::LockTime::ZERO { + used_locktime = true; + 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)); + } 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"); + } + + loops += 1; + assert!(loops < 20, "Failed to observe both behaviors"); + } + Ok(()) + } + + #[test] + fn test_anti_fee_sniping_multiple_taproot_inputs() { + let current_height = 3_000; + let tip = absolute::Height::from_consensus(current_height).unwrap(); + let input1 = setup_test_input(2_500).unwrap(); + let input2 = setup_test_input(2_700).unwrap(); + let input3 = setup_test_input(3_000).unwrap(); + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(18_000)); + + let mut used_locktime = false; + let mut used_sequence = false; + let mut loops = 0; + + while !used_locktime || !used_sequence { + let selection = TxTemplate::new( + vec![input1.clone(), input2.clone(), input3.clone()], + vec![output.clone()], + ); + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng()) + .unwrap() + .build_psbt(BuildPsbtParams::default()) + .unwrap(); + + let tx = psbt.unsigned_tx; + + if tx.lock_time > absolute::LockTime::ZERO { + used_locktime = true; + } else { + used_sequence = true; + let has_modified_sequence = tx.input.iter().any(|txin| { + let seq = txin.sequence.to_consensus_u32(); + seq > 0 && seq < 65_535 + }); + assert!(has_modified_sequence); + } + + loops += 1; + assert!( + loops < 20, + "Failed to observe both behaviors within reasonable attempts" + ); + } + } + + /// 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)?; + let tip = absolute::Height::from_consensus(50_000)?; + + let selection = TxTemplate::new( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); + + for _ in 0..100 { + let (psbt, _) = selection + .clone() + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .build_psbt(BuildPsbtParams::default())?; + assert_eq!( + psbt.unsigned_tx.lock_time, cltv, + "AFS must not overwrite an input's CLTV with a lower value", + ); + } + + Ok(()) + } + + /// 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; + + let regular_input = setup_test_input(2_500)?; + let regular_outpoint = regular_input.prev_outpoint(); + + let secp = Secp256k1::new(); + let desc_str = format!("tr({TEST_KEY_HEX},and_v(v:pk({TEST_KEY_TR}),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), + }], + }; + let assets = Assets::new() + .add(TEST_KEY_TR.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 csv_outpoint = csv_input.prev_outpoint(); + let csv_sequence = csv_input.sequence().expect("plan-derived sequence"); + + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(18_000)); + + let mut observed_sequence_path = false; + + for _ in 0..100 { + let selection = TxTemplate::new( + vec![regular_input.clone(), csv_input.clone()], + vec![output.clone()], + ); + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .build_psbt(BuildPsbtParams::default())?; + let tx = psbt.unsigned_tx; + + let csv_txin = tx + .input + .iter() + .find(|t| t.previous_output == csv_outpoint) + .expect("csv input must be present"); + assert_eq!( + csv_txin.sequence, csv_sequence, + "AFS must not overwrite the sequence of a CSV-bearing Taproot input", + ); + + let regular_txin = tx + .input + .iter() + .find(|t| t.previous_output == regular_outpoint) + .expect("regular input must be present"); + 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", + ); + + Ok(()) + } + + /// 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); + let (input, desc) = setup_cltv_input(time_locktime)?; + let tip = absolute::Height::from_consensus(800_000)?; + + let selection = TxTemplate::new( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); + + let result = selection.apply_anti_fee_sniping(tip, &mut thread_rng()); + + assert!(matches!( + result, + Err(AntiFeeSnipingError::UnsupportedLockTime(lt)) if lt == time_locktime + )); + + Ok(()) + } + + #[test] + fn test_anti_fee_sniping_unsupported_version_error() -> anyhow::Result<()> { + let confirmation_height = 800_000; + let input = setup_test_input(confirmation_height)?; + let current_height = absolute::Height::from_consensus(confirmation_height + 50)?; + + let selection = TxTemplate::new( + 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_KEY_HEX},and_v(v:pk({TEST_KEY_TR}),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), + }], + }; + let assets = Assets::new() + .add(TEST_KEY_TR.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 selection = TxTemplate::new( + vec![csv_input], + vec![Output::with_script( + ScriptBuf::new(), + Amount::from_sat(9_000), + )], + ); + + assert_eq!( + selection.version(), + Version::TWO, + "construction defaults to v2", + ); + + let result = selection.set_version(Version::ONE); + assert!(matches!( + result, + Err(SetVersionError::RelativeTimelockRequiresV2 { attempted }) + if attempted == Version::ONE + )); + + Ok(()) + } + + fn input_with_assets(desc_str: &str, assets: Assets) -> anyhow::Result { + let secp = Secp256k1::new(); + let (desc, _) = Descriptor::parse_descriptor(&secp, desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + let script_pubkey = def_desc.script_pubkey(); + let plan = def_desc.plan(&assets).expect("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(100_000), + }], + }; + Ok(Input::from_prev_tx(plan, prev_tx, 0, None)?) + } + + fn non_default_taproot_assets(key: &DescriptorPublicKey) -> Assets { + use miniscript::plan::{CanSign, TaprootCanSign}; + let mut assets = Assets::default(); + for deriv_path in key.full_derivation_paths() { + let can_sign = CanSign { + ecdsa: true, + taproot: TaprootCanSign { + sighash_default: false, + ..TaprootCanSign::default() + }, + }; + assets + .keys + .insert(((key.master_fingerprint(), deriv_path), can_sign)); + } + assets + } + + fn run_sighash_case(input: Input, params: BuildPsbtParams) -> anyhow::Result { + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); + let selection = TxTemplate::new(vec![input], vec![output]); + let (psbt, _finalizer) = selection.build_psbt(params)?; + Ok(psbt) + } + + /// `create_psbt` writes the correct `sighash_type` on Plan-derived inputs across every + /// (witness-template, `declare_sighash`) combination: + /// + /// - 64B Schnorr Plan -> `Default`. + /// - 65B Schnorr Plan -> `All`. + /// - Mixed 64B+65B Schnorr Plan -> `Default`. + /// - ECDSA Plan -> `EcdsaSighashType::All`. + #[test] + fn test_sighash_policy() -> anyhow::Result<()> { + use miniscript::plan::CanSign; + + let tr_key: DescriptorPublicKey = TEST_KEY_TR.parse()?; + let wpkh_key: DescriptorPublicKey = TEST_KEY_WPKH.parse()?; + + // Mixed-Assets Plan: one key budgeted 64B, one key budgeted 65B. + let mixed_assets = { + let key_non_default: DescriptorPublicKey = TEST_KEY_TR_3.parse()?; + let key_default: DescriptorPublicKey = TEST_KEY_TR_2.parse()?; + + let mut assets = non_default_taproot_assets(&key_non_default); + for deriv_path in key_default.full_derivation_paths() { + assets.keys.insert(( + (key_default.master_fingerprint(), deriv_path), + CanSign::default(), + )); + } + assets + }; + + type Expected = Option; + let cases: Vec<(&str, Input, Expected)> = vec![ + ( + "64B Tap", + input_with_assets( + &format!("tr({TEST_KEY_TR})"), + Assets::new().add(tr_key.clone()), + )?, + Some(TapSighashType::Default.into()), + ), + ( + "65B Tap", + input_with_assets( + &format!("tr({TEST_KEY_TR})"), + non_default_taproot_assets(&tr_key), + )?, + Some(TapSighashType::All.into()), + ), + ( + "ECDSA", + input_with_assets( + &format!("wpkh({TEST_KEY_WPKH})"), + Assets::new().add(wpkh_key.clone()), + )?, + Some(EcdsaSighashType::All.into()), + ), + ( + "Mixed Tap (64B + 65B)", + input_with_assets( + &format!("tr({TEST_KEY_TR},multi_a(2,{TEST_KEY_TR_2},{TEST_KEY_TR_3}))"), + mixed_assets, + )?, + Some(TapSighashType::Default.into()), + ), + ]; + + for (name, input, expected) in cases { + let psbt = run_sighash_case(input, BuildPsbtParams::default())?; + assert_eq!(psbt.inputs[0].sighash_type, expected, "{name}"); + } + Ok(()) + } +} diff --git a/tests/psbt.rs b/tx/tests/psbt.rs similarity index 100% rename from tests/psbt.rs rename to tx/tests/psbt.rs diff --git a/wallet_tx/Cargo.toml b/wallet_tx/Cargo.toml new file mode 100644 index 00000000..dd06373a --- /dev/null +++ b/wallet_tx/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bdk_wallet_tx" +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +homepage = "https://bitcoindevkit.org" +repository = "https://github.com/bitcoindevkit/bdk-tx" +description = "Bridge between bdk_wallet and bdk_tx: drive multi-stage transaction building from a bdk_wallet::Wallet." +license = "MIT OR Apache-2.0" + +[dependencies] +bdk_tx = { path = "../tx" } +bdk_wallet = { git = "https://github.com/bitcoindevkit/bdk_wallet", rev = "58fe631177047ef1c49947ff453110f47138adcd" } +bitcoin = { version = "0.32" } +miniscript = { version = "12.3" } + +[dev-dependencies] +anyhow = "1" +bdk_wallet = { git = "https://github.com/bitcoindevkit/bdk_wallet", rev = "58fe631177047ef1c49947ff453110f47138adcd", features = ["test-utils"] } diff --git a/wallet_tx/README.md b/wallet_tx/README.md new file mode 100644 index 00000000..118b5876 --- /dev/null +++ b/wallet_tx/README.md @@ -0,0 +1,59 @@ +# bdk_wallet_tx + +A bridge crate between [`bdk_wallet`](https://github.com/bitcoindevkit/bdk_wallet) and +[`bdk_tx`](https://github.com/bitcoindevkit/bdk-tx). + +`bdk_wallet` is the stable, batteries-included wallet; `bdk_tx` is a fast-moving, low-level +transaction-building library. This crate depends on **both** and exposes their integration as an +extension trait, `WalletTxExt`, implemented for `bdk_wallet::Wallet`. + +Keeping the integration in a separate crate means neither base crate depends on the other: +`bdk_wallet` stays stable, `bdk_tx` stays free to move, and every breaking `bdk_tx` release is +absorbed here rather than forcing a `bdk_wallet` major. (See +[bitcoindevkit/bdk_wallet#297](https://github.com/bitcoindevkit/bdk_wallet/pull/297#issuecomment-4810411011).) + +## Three-stage pipeline + +```rust,ignore +use bdk_tx::BuildPsbtParams; +use bdk_wallet_tx::{WalletTxExt, SelectParams, SelectionStrategy}; + +// 1. Candidates -- resolve the wallet's spendable inputs. +let coins = wallet.candidates()?; + +// 2. Select -- coin selection (a pure read), yielding a `bdk_tx::TxTemplate` and the auto-derived +// change address. The template is unshuffled with no anti-fee-sniping; shape it here if desired. +let (template, change) = wallet.select(&coins, SelectParams { + recipients: vec![(recipient_spk, amount)], + coin_selection: SelectionStrategy::LowestFee { max_rounds: 210_000 }, + feerate, + longterm_feerate: None, + change_script: None, +}, &mut rng)?; +// Reserve the change address so a later `select` won't reuse it (reveal + mark used; then persist +// the change set). Skip it to leave the wallet untouched; release later with `unmark_used`. +if let Some(change) = &change { wallet.reserve_change(change); } + +// 3. Emit the PSBT directly via bdk_tx (no wallet needed)... +let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; +// ...optionally fill the wallet's global xpubs (the only emission step that needs the wallet). +wallet.add_global_xpubs(&mut psbt)?; +``` + +Sign the PSBT however you like, then `finalizer.finalize(&mut psbt)`. + +See `examples/three_stage.rs` for a complete, runnable flow. + +## Notes + +- **Anti-fee-sniping / MTP.** `select` returns an *unshuffled* template with *no* anti-fee-sniping -- + apply `template.apply_anti_fee_sniping(tip_height, rng)` and `template.shuffle_outputs(rng)` + yourself before `build_psbt`. `bdk_wallet` checkpoints carry no median-time-past, so per-input + `prev_mtp` is taken from the optional `CandidateParams::fetch_mtp` oracle (never fabricated) and + left `None` without one; supply `tip_mtp` / `fetch_mtp` for time-based (CSV/CLTV-time) timelock + filtering. +- **Change address.** `select` is a pure read: when no change script is supplied it *peeks* the + next unused internal address and returns it, without mutating the wallet. Reserve it with + `reserve_change` (reveal + mark used; then persist the change set) so a later `select` won't + reuse it -- handy across several maybe-broadcast txs. Release an unused one with `unmark_used`; + reserve nothing and the wallet is left untouched. diff --git a/wallet_tx/examples/three_stage.rs b/wallet_tx/examples/three_stage.rs new file mode 100644 index 00000000..0d86caa9 --- /dev/null +++ b/wallet_tx/examples/three_stage.rs @@ -0,0 +1,93 @@ +//! Build, sign and finalize a transaction from a `bdk_wallet::Wallet` using the three-stage +//! `bdk_wallet_tx` bridge: candidates -> select -> build_psbt. +//! +//! Run with: `cargo run -p bdk_wallet_tx --example three_stage` +//! +//! The wallet here is funded deterministically via `bdk_wallet`'s `test-utils` so the example +//! needs no network or `bitcoind`. A real application would sync the wallet from its chain source +//! instead; everything from `candidates()` onward is identical. + +use bdk_tx::BuildPsbtParams; +use bdk_wallet::test_utils::{get_funded_wallet, get_test_tr_single_sig_xprv_and_change_desc}; +use bdk_wallet::{KeychainKind, SignOptions}; +use bdk_wallet_tx::{SelectParams, SelectionStrategy, WalletTxExt}; +use bitcoin::secp256k1::rand; +use bitcoin::{absolute, Amount, FeeRate}; + +fn main() -> anyhow::Result<()> { + let (descriptor, change_descriptor) = get_test_tr_single_sig_xprv_and_change_desc(); + let (mut wallet, _funding_txid) = get_funded_wallet(descriptor, change_descriptor); + println!("balance: {}", wallet.balance().total()); + + // A destination (here, a far-future address of our own wallet just for demonstration). + let recipient = wallet + .peek_address(KeychainKind::External, 42) + .script_pubkey(); + + // Stage 1 -- resolve the spendable candidate set. + let coins = wallet.candidates()?; + println!("candidates: {}", coins.inputs().count()); + + // The caller supplies the RNG (here used by the input/output shuffling and anti-fee-sniping + // below; SingleRandomDraw selection would use it too, but this example uses LowestFee). + let mut rng = rand::thread_rng(); + + // Stage 2 -- run coin selection (a pure read), yielding a `bdk_tx::TxTemplate` and the + // auto-derived change address (peeked, not yet revealed). + let (template, change) = wallet.select( + &coins, + SelectParams { + recipients: vec![(recipient.clone(), Amount::from_sat(10_000))], + coin_selection: SelectionStrategy::LowestFee { + max_rounds: 210_000, + }, + feerate: FeeRate::from_sat_per_vb(4).expect("valid feerate"), + longterm_feerate: Some(FeeRate::from_sat_per_vb(1).expect("valid feerate")), + change_script: None, + }, + &mut rng, + )?; + + // Reserve this selection's change address: reveal + mark it used (then persist the change set), + // so a later `select` (e.g. when batching several txs before broadcasting) won't hand out the + // same change address. Skip it to leave the wallet untouched; if you reserve but then drop this + // tx, release the address with `unmark_used`. + if let Some(change) = &change { + wallet.reserve_change(change); + } + + // Stage 3 -- shape the template and emit, all in one chain: shuffle inputs/outputs (so the + // change output isn't in a predictable position), apply anti-fee-sniping to bind the tx to the + // chain tip (this seals the template), then build the PSBT + finalizer directly via `bdk_tx` + // (no wallet needed for emission). + let tip_height = absolute::Height::from_consensus(wallet.latest_checkpoint().height())?; + let (mut psbt, finalizer) = template + .shuffle_inputs(&mut rng) + .shuffle_outputs(&mut rng) + .apply_anti_fee_sniping(tip_height, &mut rng)? + .build_psbt(BuildPsbtParams::default())?; + // ...then optionally fill the wallet's global xpubs (the one emission step that needs it). + wallet.add_global_xpubs(&mut psbt)?; + + // Sign with the wallet's keys, then finalize with the `bdk_tx` finalizer. + let _ = wallet.sign( + &mut psbt, + SignOptions { + try_finalize: false, + ..Default::default() + }, + )?; + assert!( + finalizer.finalize(&mut psbt).is_finalized(), + "must finalize" + ); + + let tx = psbt.extract_tx()?; + println!( + "built tx {}: {} input(s), {} output(s)", + tx.compute_txid(), + tx.input.len(), + tx.output.len(), + ); + Ok(()) +} diff --git a/wallet_tx/src/candidates.rs b/wallet_tx/src/candidates.rs new file mode 100644 index 00000000..5e35e6a4 --- /dev/null +++ b/wallet_tx/src/candidates.rs @@ -0,0 +1,148 @@ +//! The resolved spendable candidate set (PSBT-building stage 1 output). + +use bdk_tx::{Input, InputCandidates, RbfParams}; +use bdk_wallet::LocalOutput; +use bitcoin::Txid; +use std::collections::HashSet; + +use crate::ConflictingInput; + +/// A resolved set of spendable input candidates (output of PSBT-building stage 1). +/// +/// Produced by [`WalletTxExt::candidates_with`] from [`CandidateParams`]: every owned UTXO has been +/// planned against the wallet's descriptors and spendability filters applied. It owns its inputs +/// (no wallet borrow), so it can be held as a snapshot and used to build one or more PSBTs via +/// [`WalletTxExt::select`]. +/// +/// Add foreign (non-wallet) inputs with [`push_must_select`](Self::push_must_select) / +/// [`push_can_select`](Self::push_can_select), and apply your own post-resolution filters with +/// [`filter`](Self::filter) / [`regroup`](Self::regroup). +/// +/// If the [`CandidateParams`] had a non-empty [`replace`](crate::CandidateParams::replace) list, +/// the set carries the [`RbfParams`] (replaced-tx fee statistics) forward so stage 2 applies the +/// correct fee floor, and exposes the wallet-owned outputs being stripped by the replacement via +/// [`replaced_unspent`](Self::replaced_unspent). +/// +/// [`WalletTxExt::candidates_with`]: crate::WalletTxExt::candidates_with +/// [`WalletTxExt::select`]: crate::WalletTxExt::select +/// [`CandidateParams`]: crate::CandidateParams +/// [`CandidateParams::replace`]: crate::CandidateParams::replace +#[derive(Debug, Clone)] +pub struct CandidateSet { + pub(crate) candidates: InputCandidates, + pub(crate) rbf: Option, + /// Txids being replaced/evicted (direct conflicts + descendants). A pushed input may not spend + /// an output of any of these. + pub(crate) replaced: HashSet, + /// Wallet-owned UTXOs stripped from the canonical view by the replacement. + pub(crate) replaced_unspent: Vec, +} + +impl CandidateSet { + /// Iterate over all resolved input candidates (both must-select and optional). + pub fn inputs(&self) -> impl Iterator + '_ { + self.candidates.inputs() + } + + /// Whether the set contains no candidates at all. + pub fn is_empty(&self) -> bool { + self.candidates.inputs().next().is_none() + } + + /// Whether this set is a Replace-By-Fee set (built from a non-empty + /// [`CandidateParams::replace`](crate::CandidateParams::replace) list). + pub fn is_rbf(&self) -> bool { + self.rbf.is_some() + } + + /// Wallet-owned UTXOs that the replacement strips out of the canonical view -- the outputs of + /// the replaced (and descendant) txs that were unspent in the wallet's view before the replace. + /// + /// These are the still-live payments of the txs being replaced; a caller batching several txs + /// into one replacement can use them to decide which payments to re-create. Empty for a + /// non-Replace-By-Fee set. + pub fn replaced_unspent(&self) -> &[LocalOutput] { + &self.replaced_unspent + } + + /// Add a foreign [`Input`] to the must-select group (always spent). + /// + /// Use this for a UTXO that did not originate from the wallet, supplied with a pre-built + /// plan -- its validity (UTXO existence, satisfaction weight, ...) relies on the + /// caller-supplied values, so only push inputs you trust. + /// + /// If the outpoint is already a candidate (must- or can-select), it is **upserted**: the + /// existing entry is replaced with `input` and ends up in the must-select group (a can-select + /// one is promoted). + /// + /// # Errors + /// + /// Returns [`ConflictingInput`] if the input spends an output of a transaction being replaced + /// (RBF) -- it would be evicted with that transaction. + pub fn push_must_select(mut self, input: Input) -> Result { + self.ensure_not_replaced(&input)?; + self.candidates = self.candidates.push_must_select(input); + Ok(self) + } + + /// Add a foreign [`Input`] as an optional (can-select) candidate. + /// + /// If the outpoint is already a candidate, it is **upserted** (replaced with `input`). + /// Must-select takes precedence: an outpoint already in the must-select group stays there (its + /// data replaced) rather than being demoted. + /// + /// # Errors + /// + /// Returns [`ConflictingInput`] if the input spends an output of a transaction being replaced + /// (RBF) -- it would be evicted with that transaction. + pub fn push_can_select(mut self, input: Input) -> Result { + self.ensure_not_replaced(&input)?; + self.candidates = self.candidates.push_can_select(input); + Ok(self) + } + + /// Reject an input that spends an output of a transaction in the replaced (RBF) set. + fn ensure_not_replaced(&self, input: &Input) -> Result<(), ConflictingInput> { + let op = input.prev_outpoint(); + if self.replaced.contains(&op.txid) { + return Err(ConflictingInput { outpoint: op }); + } + Ok(()) + } + + /// Keep only the optional candidates for which `policy` returns `true`. + /// + /// Forwards to [`bdk_tx::InputCandidates::filter`], which filters only the can-select group: + /// must-select inputs (manually-selected and foreign-pushed) are **always retained**, whatever + /// `policy` returns. + pub fn filter

(mut self, policy: P) -> Self + where + P: FnMut(&Input) -> bool, + { + self.candidates = self.candidates.filter(policy); + self + } + + /// Regroup the candidates by the group key returned by `policy`. + /// + /// Forwards to [`bdk_tx::InputCandidates::regroup`]. + pub fn regroup(mut self, policy: P) -> Self + where + P: FnMut(&Input) -> G, + G: Ord + Clone, + { + self.candidates = self.candidates.regroup(policy); + self + } + + /// Consume into the underlying `bdk_tx` parts: the [`InputCandidates`] and, if this is a + /// Replace-By-Fee set (see [`is_rbf`](Self::is_rbf)), the [`RbfParams`] carrying the + /// replaced-tx fee floor. + /// + /// Pass both on to `bdk_tx` (e.g. via + /// [`SelectionParams::replace`](bdk_tx::SelectionParams::replace)) to build a `TxTemplate` + /// directly while still enforcing the RBF minimum fee. + pub fn into_parts(self) -> (InputCandidates, Option) { + (self.candidates, self.rbf) + } +} diff --git a/wallet_tx/src/error.rs b/wallet_tx/src/error.rs new file mode 100644 index 00000000..be713506 --- /dev/null +++ b/wallet_tx/src/error.rs @@ -0,0 +1,168 @@ +//! Error types for the three PSBT-building stages. + +use core::fmt; + +use bdk_tx::bdk_coin_select; +use bitcoin::{bip32::Xpub, OutPoint, Txid}; + +/// Error when resolving the spendable [`CandidateSet`] (PSBT-building stage 1). +/// +/// [`CandidateSet`]: crate::CandidateSet +#[derive(Debug)] +#[non_exhaustive] +pub enum CandidatesError { + /// A manually-selected outpoint the wallet can't spend: untracked, spent by a transaction that + /// isn't being replaced, or (in an RBF) an output of a transaction being replaced -- which the + /// replacement evicts. + CannotSpend(OutPoint), + /// Failed to create a spending plan for a manually selected output. + Plan(OutPoint), + /// A transaction being replaced (RBF) could not be found. + MissingTransaction(Txid), + /// The wallet controls none of the inputs of a transaction to be replaced (RBF), so it cannot + /// build a replacement that conflicts with (and therefore evicts) it. + CannotReplace(Txid), + /// Failed to compute the fee of a transaction being replaced (RBF). + PreviousFee(bdk_wallet::chain::tx_graph::CalculateFeeError), +} + +impl fmt::Display for CandidatesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CannotSpend(op) => { + write!(f, "cannot spend outpoint {op}: not a spendable wallet UTXO") + } + Self::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"), + Self::MissingTransaction(txid) => write!(f, "missing transaction: {txid}"), + Self::CannotReplace(txid) => write!( + f, + "cannot replace transaction {txid}: the wallet controls none of its inputs" + ), + Self::PreviousFee(e) => write!(f, "{e}"), + } + } +} + +impl core::error::Error for CandidatesError {} + +/// Error from [`CandidateSet::push_must_select`](crate::CandidateSet::push_must_select) / +/// [`push_can_select`](crate::CandidateSet::push_can_select): the pushed input spends an output of +/// a transaction in the replaced (RBF) set, which the replacement evicts -- so the input would be +/// invalid. +#[derive(Debug)] +#[non_exhaustive] +pub struct ConflictingInput { + /// The pushed input's outpoint, which spends an output of the replaced (RBF) set. + pub outpoint: OutPoint, +} + +impl fmt::Display for ConflictingInput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "pushed input {} spends an output of a transaction being replaced", + self.outpoint + ) + } +} + +impl core::error::Error for ConflictingInput {} + +/// Error when running coin selection (PSBT-building stage 2, +/// [`select`](crate::WalletTxExt::select)). +#[derive(Debug)] +#[non_exhaustive] +pub enum SelectError { + /// No Bnb solution. + Bnb(bdk_coin_select::NoBnbSolution), + /// No recipients were configured with a non-sweep coin selection. A [`select`] requires at + /// least one recipient; to send all funds to a single destination, use + /// [`SelectionStrategy::SweepEffective`] / [`SweepAll`] with no recipients. + /// + /// [`select`]: crate::WalletTxExt::select + /// [`SelectionStrategy::SweepEffective`]: crate::SelectionStrategy::SweepEffective + /// [`SweepAll`]: crate::SelectionStrategy::SweepAll + NoRecipients, + /// The transaction would have no outputs: its sole change/drain output fell below the dust + /// threshold and was dropped to fees. Arises from a no-recipient sweep whose remaining amount + /// is dust after fees -- reachable cleanly via [`SweepEffective`]. (A [`SweepAll`] that also + /// drags in *uneconomical* inputs -- ones costing more to spend than they're worth -- can + /// instead surface as [`CannotMeetTarget`](Self::CannotMeetTarget).) + /// + /// [`SweepEffective`]: crate::SelectionStrategy::SweepEffective + /// [`SweepAll`]: crate::SelectionStrategy::SweepAll + ChangeBelowDust, + /// The change policy could not be built from the selection params. + ChangePolicy(bdk_tx::ChangePolicyError), + /// The input candidates have absolute timelocks of mixed units (height vs time). + LockTypeMismatch, + /// Not enough funds: the target is unreachable even when selecting every effective input at the + /// target feerate. This is the single "insufficient funds" error, covering an empty candidate + /// set as the degenerate case. + CannotMeetTarget { + /// The shortfall in satoshis at best-case (all effective inputs selected). + missing: u64, + }, + /// The selection algorithm returned successfully but its selection still falls short. + AlgorithmFellShort, +} + +impl fmt::Display for SelectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bnb(e) => write!(f, "{e}"), + Self::NoRecipients => write!(f, "no output destinations were configured"), + Self::ChangeBelowDust => { + write!(f, "the change output is below the dust threshold, leaving no outputs") + } + Self::ChangePolicy(e) => write!(f, "{e}"), + Self::LockTypeMismatch => { + write!(f, "input candidates have absolute timelocks of mixed units") + } + Self::CannotMeetTarget { missing } => write!( + f, + "meeting the target is not possible with the input candidates; {missing} sats missing" + ), + Self::AlgorithmFellShort => write!( + f, + "the selection algorithm returned successfully but did not meet the target" + ), + } + } +} + +impl core::error::Error for SelectError {} + +/// Error from [`add_global_xpubs`](crate::WalletTxExt::add_global_xpubs): an extended key in a +/// descriptor is neither a master key (depth 0) nor carries an explicit origin, so its global-xpub +/// key source cannot be determined. +#[derive(Debug)] +#[non_exhaustive] +pub struct MissingKeyOrigin { + /// The extended key whose global-xpub key source couldn't be determined. + pub xpub: Xpub, +} + +impl fmt::Display for MissingKeyOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "missing key origin for xpub: {}", self.xpub) + } +} + +impl core::error::Error for MissingKeyOrigin {} + +/// Map an [`IntoTxTemplateError`](bdk_tx::IntoTxTemplateError) into a [`SelectError`], routing the +/// algorithm-specific error variant through `on_algorithm`. +pub(crate) fn map_into_tx_template_error( + err: bdk_tx::IntoTxTemplateError, + on_algorithm: impl FnOnce(E) -> SelectError, +) -> SelectError { + use bdk_tx::IntoTxTemplateError as E2; + match err { + E2::ChangePolicy(e) => SelectError::ChangePolicy(e), + E2::LockTypeMismatch => SelectError::LockTypeMismatch, + E2::CannotMeetTarget { missing } => SelectError::CannotMeetTarget { missing }, + E2::Algorithm(e) => on_algorithm(e), + E2::AlgorithmFellShort => SelectError::AlgorithmFellShort, + } +} diff --git a/wallet_tx/src/lib.rs b/wallet_tx/src/lib.rs new file mode 100644 index 00000000..d0a52ae9 --- /dev/null +++ b/wallet_tx/src/lib.rs @@ -0,0 +1,676 @@ +//! `bdk_wallet_tx` -- a bridge between [`bdk_wallet`] and [`bdk_tx`]. +//! +//! `bdk_wallet` is the stable, batteries-included wallet; `bdk_tx` is a fast-moving, +//! low-level transaction-building library. This crate depends on **both** and exposes their +//! integration as an extension trait, [`WalletTxExt`], implemented for [`bdk_wallet::Wallet`]. That +//! keeps `bdk_tx`'s pre-stable types out of `bdk_wallet`'s public API: neither base crate depends +//! on the other, and every breaking `bdk_tx` release is absorbed here rather than forcing a +//! `bdk_wallet` major. +//! +//! PSBT building is a three-stage pipeline: +//! +//! 1. [`candidates`](WalletTxExt::candidates) / [`candidates_with`](WalletTxExt::candidates_with) / +//! [`rbf_candidates`](WalletTxExt::rbf_candidates) -> a [`CandidateSet`]. +//! 2. [`select`](WalletTxExt::select) -> a [`bdk_tx::TxTemplate`] (shape it: version, locktime, +//! anti-fee-sniping, input/output ordering). +//! 3. Emit the PSBT directly with [`bdk_tx::TxTemplate::build_psbt`] -> `(Psbt, Finalizer)`, +//! optionally filling global xpubs from the wallet via +//! [`add_global_xpubs`](WalletTxExt::add_global_xpubs). +//! +//! # Anti-fee-sniping and MTP +//! +//! `select` returns an **unshuffled** template with **no** anti-fee-sniping. Apply it yourself with +//! `template.apply_anti_fee_sniping(tip_height, rng)` before emitting the PSBT, and +//! shuffle outputs (`template.shuffle_outputs(rng)`) for change-output privacy. Note that +//! `bdk_wallet` checkpoints carry no median-time-past: a per-input [`ConfirmationStatus`] takes its +//! `prev_mtp` from the optional [`CandidateParams::fetch_mtp`] oracle (never a fabricated value), +//! and is left `None` when no oracle is supplied -- so `bdk_tx` reports time-based +//! relative-timelock spendability as "unknown" only then. + +#![warn(missing_docs)] + +mod candidates; +mod error; +mod params; + +pub use candidates::*; +pub use error::*; +pub use params::*; + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use bdk_tx::{ + bdk_coin_select::CoinSelector, selection_algorithm_lowest_fee_bnb, + selection_algorithm_single_random_draw, ChangeScript, ConfirmationStatus, Input, + InputCandidates, OriginalTxStats, Output, RbfParams, SelectionContext, SelectionParams, + TxTemplate, +}; +use bdk_wallet::chain::{Anchor, ChainPosition, ConfirmationBlockTime, FullTxOut}; +use bdk_wallet::{AddressInfo, KeychainKind, Wallet}; +use bitcoin::bip32::{DerivationPath, Xpub}; +use bitcoin::secp256k1::rand::RngCore; +use bitcoin::{absolute, relative, Amount, FeeRate, OutPoint, Psbt, ScriptBuf, Txid}; +use miniscript::descriptor::{DescriptorPublicKey, DescriptorXKey}; +use miniscript::plan::Assets; +use miniscript::{Descriptor, ForEachKey}; + +use error::map_into_tx_template_error; +use params::merge_assets_secrets; + +/// Extension trait that drives [`bdk_tx`]'s multi-stage transaction building from a +/// [`bdk_wallet::Wallet`]. +/// +/// See the [crate-level docs](crate) for the three-stage pipeline. +pub trait WalletTxExt { + /// **Stage 1.** Resolve the wallet's spendable [`CandidateSet`] for `opts`. + /// + /// Plans manually-selected UTXOs, gathers and filters the wallet's spendable coins, and -- when + /// [`opts.replace`](CandidateParams::replace) is non-empty -- sets up the Replace-By-Fee + /// context. The returned [`CandidateSet`] is an owned snapshot suitable for + /// [`select`](Self::select). + /// + /// This is the single stage-1 primitive; [`candidates`](Self::candidates) and + /// [`rbf_candidates`](Self::rbf_candidates) are convenience wrappers over it. + fn candidates_with(&self, opts: &CandidateParams) -> Result; + + /// **Stage 1.** Resolve the wallet's spendable [`CandidateSet`] with default options. + fn candidates(&self) -> Result { + self.candidates_with(&CandidateParams::default()) + } + + /// **Stage 1.** Resolve a Replace-By-Fee [`CandidateSet`] replacing the given `txids`. + /// + /// Shortcut for [`candidates_with`](Self::candidates_with) with a [`CandidateParams`] whose + /// [`replace`](CandidateParams::replace) list is `txids`. The replacement conflicts with these + /// txs (forcing their wallet-owned inputs) and must beat their fee; see + /// [`replace`](CandidateParams::replace) for how a replaced tx's foreign inputs are handled. + fn rbf_candidates(&self, txids: &[Txid]) -> Result { + self.candidates_with(&CandidateParams { + replace: txids.to_vec(), + ..Default::default() + }) + } + + /// **Stage 2.** Run coin selection over a resolved [`CandidateSet`], returning a [`TxTemplate`] + /// that pays the given recipients. + /// + /// The returned template is **unshuffled** with **no** anti-fee-sniping; shape it (version, + /// locktime, anti-fee-sniping, ordering) before emitting the PSBT with + /// [`bdk_tx::TxTemplate::build_psbt`]. + /// + /// This is a pure read -- it does **not** mutate the wallet. When no [`ChangeScript`] is + /// supplied via [`SelectParams`] and the selection produces a change output, the auto-derived + /// change [`AddressInfo`] is returned (otherwise `None`). It is **peeked, not reserved** -- + /// [`reserve_change`](Self::reserve_change) it to keep a later selection from reusing that + /// change address; otherwise no wallet state is touched: + /// + /// ```rust,ignore + /// let (template, change) = wallet.select(&coins, params, &mut rng)?; + /// if let Some(c) = &change { + /// wallet.reserve_change(c); // reveal + mark used; then persist the staged change set + /// } + /// ``` + /// + /// `coins` is borrowed, so the same (expensive-to-build) [`CandidateSet`] can be selected over + /// repeatedly with different [`SelectParams`]. The RNG is consumed only by + /// [`SelectionStrategy::SingleRandomDraw`]; other strategies ignore it. + fn select( + &self, + coins: &CandidateSet, + params: SelectParams, + rng: &mut impl RngCore, + ) -> Result<(TxTemplate, Option), SelectError>; + + /// Reserve the change [`AddressInfo`] from a [`select`](Self::select), so a later + /// [`select`](Self::select) won't reuse that change address. + /// + /// It **reveals** the address (so the wallet tracks the change output) and **marks it used** + /// (so it isn't handed out again). + /// + /// # Persistence + /// + /// Revealing stages a change set; **you must persist it** (e.g. via [`Wallet::take_staged`]), + /// or the reservation is lost on restart and the wallet won't detect the change output when it + /// syncs. + /// + /// # When to use + /// + /// Reserve when several candidate transactions might each be broadcast and you don't want them + /// to share a change address: reserve up front, before you know which go out, then release any + /// you don't use with [`Wallet::unmark_used`] (`change.keychain` / `change.index`). Reserve + /// nothing and the wallet stays untouched -- the peeked address is reused next time. + fn reserve_change(&mut self, change: &AddressInfo); + + /// Fill in the PSBT's global xpubs from the wallet's descriptors. + /// + /// Optional helper for **stage 3**: after emitting a PSBT from the [`select`](Self::select) + /// template via [`bdk_tx::TxTemplate::build_psbt`], call this to add the + /// [`global xpubs`](bitcoin::Psbt::xpub) (the only emission step that needs the wallet). + /// + /// # Errors + /// + /// [`MissingKeyOrigin`] if an extended key in a descriptor is neither a master key (depth 0) + /// nor carries an explicit origin. + fn add_global_xpubs(&self, psbt: &mut Psbt) -> Result<(), MissingKeyOrigin>; +} + +impl WalletTxExt for Wallet { + fn candidates_with(&self, opts: &CandidateParams) -> Result { + build_candidates(self, opts) + } + + fn select( + &self, + coins: &CandidateSet, + mut params: SelectParams, + rng: &mut impl RngCore, + ) -> Result<(TxTemplate, Option), SelectError> { + // A sweep (`SweepAll` / `SweepEffective`) selects candidates and sends the remainder to + // change, so it may have no recipients. Any other strategy requires at least one recipient + // -- guarding against accidentally draining the whole wallet by passing empty recipients. + let sweep = matches!( + params.coin_selection, + SelectionStrategy::SweepAll | SelectionStrategy::SweepEffective + ); + if params.recipients.is_empty() && !sweep { + return Err(SelectError::NoRecipients); + } + + // Resolve change: the caller's script, or *peek* the next unused internal address without + // revealing it. Revelation is deferred until after the template is built -- see below. + let (change_info, change_script) = peek_change_info(self, params.change_script.take()); + let target_outputs = target_outputs(self, ¶ms.recipients); + + // `coins` is borrowed so callers can re-select on the same snapshot; clone the parts + // `into_tx_template` consumes. (The clone goes away once `bdk_tx` selection borrows.) + let (input_candidates, rbf) = (coins.candidates.clone(), coins.rbf.clone()); + + // `longterm_feerate` is a selection-wide waste parameter (it also drives the change + // policy), so it applies to every strategy -- not just `LowestFee`. + let select_params = SelectionParams { + replace: rbf, + longterm_feerate: params.longterm_feerate, + ..SelectionParams::new(params.feerate, target_outputs, change_script) + }; + + let template = match params.coin_selection { + SelectionStrategy::SweepAll => input_candidates + .into_tx_template( + |cs: &mut CoinSelector, _cx: SelectionContext| { + cs.select_all(); + Ok::<(), core::convert::Infallible>(()) + }, + select_params, + ) + .map_err(|e| map_into_tx_template_error(e, |never| match never {}))?, + SelectionStrategy::SweepEffective => input_candidates + .into_tx_template( + |cs: &mut CoinSelector, cx: SelectionContext| { + cs.select_all_effective(cx.target.fee.rate); + Ok::<(), core::convert::Infallible>(()) + }, + select_params, + ) + .map_err(|e| map_into_tx_template_error(e, |never| match never {}))?, + SelectionStrategy::SingleRandomDraw => input_candidates + .into_tx_template(selection_algorithm_single_random_draw(rng), select_params) + .map_err(|e| { + map_into_tx_template_error(e, |e| SelectError::CannotMeetTarget { + missing: e.missing, + }) + })?, + SelectionStrategy::LowestFee { max_rounds } => input_candidates + .into_tx_template( + selection_algorithm_lowest_fee_bnb(max_rounds), + select_params, + ) + .map_err(|e| map_into_tx_template_error(e, SelectError::Bnb))?, + }; + + // The sole change/drain output fell below the dust threshold and was dropped to fees, + // leaving no outputs (a no-recipient sweep of dust). + if template.outputs().is_empty() { + return Err(SelectError::ChangeBelowDust); + } + + // Surface the auto-derived change address, but *only* if it actually ended up in the + // template's outputs. A caller-supplied change script, or a selection that produced no + // change (exact-amount / sweep), yields `None`. + let change = change_info.filter(|info| { + let spk = info.address.script_pubkey(); + template.outputs().iter().any(|o| o.script_pubkey() == spk) + }); + + Ok((template, change)) + } + + fn reserve_change(&mut self, change: &AddressInfo) { + // `change.keychain` is already mapped: `reveal_addresses_to` maps it too (idempotent), and + // `mark_used` does *not* map -- so feeding it the mapped keychain is what makes this + // correct on single-descriptor wallets. + let _ = self.reveal_addresses_to(change.keychain, change.index); + self.mark_used(change.keychain, change.index); + } + + fn add_global_xpubs(&self, psbt: &mut Psbt) -> Result<(), MissingKeyOrigin> { + // Resolve every key origin first, so a `MissingKeyOrigin` leaves the PSBT untouched rather + // than partially populated. + let mut entries = Vec::new(); + for (_, desc) in self.spk_index().keychains() { + for xpub in extended_keys(desc) { + let origin = match xpub.origin.clone() { + Some(origin) => origin, + // A depth-0 key is its own master, so its fingerprint is the root. + None if xpub.xkey.depth == 0 => { + (xpub.xkey.fingerprint(), DerivationPath::default()) + } + _ => return Err(MissingKeyOrigin { xpub: xpub.xkey }), + }; + entries.push((xpub.xkey, origin)); + } + } + psbt.xpub.extend(entries); + Ok(()) + } +} + +/// Peek at the change script for a selection **without** revealing/mutating wallet state. +/// +/// Returns the resolved [`ChangeScript`], plus the change [`AddressInfo`] when it was auto-derived +/// (so the caller can reveal/track it later, if the selection ends up used). A caller-supplied +/// script is passed through with `None`. The auto-derived script is the next unused change address: +/// the first revealed-but-unused one, or the next index to reveal if there is none. +/// +/// The peek goes through the *mapped* change keychain (single-descriptor wallets fall back to the +/// external one), so the returned `AddressInfo.keychain` is mapped too -- safe to feed to +/// `Wallet::mark_used` / `unmark_used`, which do not map it themselves. +fn peek_change_info( + wallet: &Wallet, + override_script: Option, +) -> (Option, ChangeScript) { + match override_script { + Some(cs) => (None, cs), + None => { + // Replicate the (private) `Wallet::map_keychain` rule: a single-descriptor wallet has + // no internal keychain, so change falls back to the external one. + let keychain = if wallet.spk_index().keychains().count() == 1 { + KeychainKind::External + } else { + KeychainKind::Internal + }; + // These accessors map the keychain internally too, so passing the mapped one is fine. + let info = wallet + .list_unused_addresses(keychain) + .next() + .unwrap_or_else(|| { + let index = wallet.next_derivation_index(keychain); + wallet.peek_address(keychain, index) + }); + let descriptor = wallet + .public_descriptor(keychain) + .at_derivation_index(info.index) + .expect("derivation index from the wallet is valid"); + (Some(info), ChangeScript::from_descriptor(descriptor)) + } + } +} + +/// Maps a chain position to tx confirmation status, if `pos` is the confirmed variant. +/// +/// `prev_mtp` comes from the caller's [`mtp`](MtpOracle) oracle, queried with the input's +/// confirmation block (`bdk_wallet` retains no median-time-past, so we never fabricate one). It is +/// `None` when no oracle is supplied or the oracle has no value for that block, leaving time-based +/// relative-timelock spendability "unknown". Returns `None` if the confirmation height is not a +/// valid absolute height. +fn status_from_position( + pos: ChainPosition, + mtp: Option<&MtpOracle>, +) -> Option { + if let ChainPosition::Confirmed { anchor, .. } = pos { + let conf_height = anchor.confirmation_height_upper_bound(); + let height = absolute::Height::from_consensus(conf_height).ok()?; + let prev_mtp = mtp.and_then(|f| f(anchor.block_id)); + Some(ConfirmationStatus { height, prev_mtp }) + } else { + None + } +} + +/// Extended keys of a descriptor (replicates `bdk_wallet`'s private `get_extended_keys`). +fn extended_keys(desc: &Descriptor) -> Vec> { + let mut answer = Vec::new(); + desc.for_each_key(|pk| { + // Expand multipath keys into their single-path xpubs (as `parse_params` does), so a + // multi-xpub descriptor isn't silently dropped from the global-xpub set. + for single in pk.clone().into_single_keys() { + if let DescriptorPublicKey::XPub(xpub) = single { + answer.push(xpub); + } + } + true + }); + answer +} + +/// Parse the common params used during candidate construction: the spend assets and the map of +/// indexed tx outputs. +fn parse_params( + wallet: &Wallet, + opts: &CandidateParams, +) -> (Assets, HashMap>) { + // The caller's `opts.assets` are authoritative. Copy in its keys/preimages and carry its + // timelocks over verbatim. If the caller supplied no signing keys, assume all wallet keys are + // available so wallet-controlled outputs can still be planned. + let mut assets = Assets::new(); + merge_assets_secrets(&mut assets, &opts.assets); + assets.absolute_timelock = opts.assets.absolute_timelock; + assets.relative_timelock = opts.assets.relative_timelock; + if assets.keys.is_empty() { + let mut pks = vec![]; + for (_, desc) in wallet.spk_index().keychains() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + merge_assets_secrets(&mut assets, &Assets::new().add(pks)); + } + + let txouts = wallet + .tx_graph() + .filter_chain_txouts( + wallet.local_chain(), + wallet.latest_checkpoint().block_id(), + opts.canonical_params.clone(), + wallet.spk_index().outpoints().iter().cloned(), + ) + .map(|(_, txo)| (txo.outpoint, txo)) + .collect(); + + (assets, txouts) +} + +/// Map the recipients to target [`Output`]s, deriving a descriptor for wallet-owned scripts. +fn target_outputs(wallet: &Wallet, recipients: &[(ScriptBuf, Amount)]) -> Vec { + recipients + .iter() + .cloned() + .map( + |(script, value)| match wallet.spk_index().index_of_spk(script.clone()) { + Some(&(keychain, index)) => { + let descriptor = wallet + .public_descriptor(keychain) + .at_derivation_index(index) + .expect("should be valid derivation index"); + Output::with_descriptor(descriptor, value) + } + None => Output::with_script(script, value), + }, + ) + .collect() +} + +/// Resolve a [`CandidateSet`] from `opts`, handling both the normal and Replace-By-Fee paths. +fn build_candidates( + wallet: &Wallet, + opts: &CandidateParams, +) -> Result { + let (assets, mut txouts) = parse_params(wallet, opts); + + // Per-block MTP oracle (if any); queried per input to fill `prev_mtp`, and for the tip below. + let mtp = opts.fetch_mtp.as_deref(); + + // Height axis for spendability -- coinbase maturity and height-based CLTV/CSV timelocks, in + // both `plan_input` and the post-planning filter. (Time-based locks use `tip_mtp`, at the tip.) + let at_height = opts.maturity_height.unwrap_or_else(|| { + absolute::Height::from_consensus(wallet.latest_checkpoint().height()) + .expect("a chain tip height is a valid absolute height") + }); + let eval_height = at_height.to_consensus_u32(); + + let is_rbf = !opts.replace.is_empty(); + + let mut rbf_must_spend: Vec = vec![]; + let mut to_replace: HashSet = HashSet::new(); + let mut rbf_params: Option = None; + if is_rbf { + // Build the set of replaced txids, dropping any tx that is a coinbase or whose ancestors + // are also being replaced (replacing an ancestor invalidates the descendant). + let candidate_replace: HashSet = opts.replace.iter().copied().collect(); + let mut direct_conflicts: HashSet = HashSet::new(); + let mut replace_outpoints: Vec = vec![]; + for &txid in opts.replace.iter() { + let tx = wallet + .tx_graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + // A descendant is covered (skip it) only by an ancestor that is *genuinely* replaced -- + // i.e. in the replace list and not a coinbase (coinbases are skipped, so they cover + // nothing). + let has_replaced_ancestor = wallet + .tx_graph() + .walk_ancestors(Arc::clone(&tx), |_, ancestor| { + Some((ancestor.compute_txid(), ancestor.is_coinbase())) + }) + .any(|(atxid, is_coinbase)| !is_coinbase && candidate_replace.contains(&atxid)); + if tx.is_coinbase() || has_replaced_ancestor { + continue; + } + if direct_conflicts.insert(txid) { + // The wallet must control at least one of the tx's inputs, otherwise it can't build + // a replacement that double-spends (and therefore evicts) it. + if !tx + .input + .iter() + .any(|txin| txouts.contains_key(&txin.previous_output)) + { + return Err(CandidatesError::CannotReplace(txid)); + } + replace_outpoints.extend(tx.input.iter().map(|txin| txin.previous_output)); + } + } + + // The must-spend inputs are the wallet-owned inputs of the (sanitized) replaced txs. + for outpoint in &replace_outpoints { + if opts.must_spend.contains(outpoint) { + continue; + } + let Some(txo) = txouts.get(outpoint) else { + continue; + }; + let input = plan_input(wallet, txo, &assets, eval_height, mtp) + .ok_or(CandidatesError::Plan(*outpoint))?; + rbf_must_spend.push(input); + } + + // Replaced txs and their descendants are excluded from coin selection. + let descendants: HashSet = direct_conflicts + .iter() + .flat_map(|&txid| { + wallet + .tx_graph() + .walk_descendants(txid, |_, txid| Some(txid)) + }) + .filter(|txid| !direct_conflicts.contains(txid)) + .collect(); + to_replace = direct_conflicts + .iter() + .chain(descendants.iter()) + .copied() + .collect(); + + // Drop the replaced set's own outputs from the considered UTXOs: the replacement evicts + // those txs, so their outputs won't exist (selecting one then yields `CannotSpend`). + txouts.retain(|outpoint, _| !to_replace.contains(&outpoint.txid)); + + let original_txs: Vec = direct_conflicts + .iter() + .map(|&txid| -> Result<_, CandidatesError> { + let tx = wallet + .tx_graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + let fee = wallet + .calculate_fee(&tx) + .map_err(CandidatesError::PreviousFee)?; + Ok(OriginalTxStats { + weight: tx.weight(), + fee, + }) + }) + .collect::>()?; + + // Sum fees from all descendants (each assumed to be in the mempool, so evicted too). + let mut descendant_fee = Amount::ZERO; + for &txid in descendants.iter() { + let tx = wallet + .tx_graph() + .get_tx(txid) + .ok_or(CandidatesError::MissingTransaction(txid))?; + descendant_fee += wallet + .calculate_fee(&tx) + .map_err(CandidatesError::PreviousFee)?; + } + + rbf_params = Some(RbfParams { + original_txs, + descendant_fee, + incremental_relay_feerate: FeeRate::BROADCAST_MIN, + }); + } + + // Combine the RBF-derived must-spend inputs with the user-supplied ones. + let mut must_spend = rbf_must_spend; + for &outpoint in &opts.must_spend { + let txo = txouts + .get(&outpoint) + .ok_or(CandidatesError::CannotSpend(outpoint))?; + // A spent coin can't be spent again -- unless the only thing spending it is a tx we're + // replacing, which the replacement evicts (freeing the coin). + if let Some((_, spender)) = &txo.spent_by { + if !to_replace.contains(spender) { + return Err(CandidatesError::CannotSpend(outpoint)); + } + } + let input = plan_input(wallet, txo, &assets, eval_height, mtp) + .ok_or(CandidatesError::Plan(outpoint))?; + must_spend.push(input); + } + + let may_spend: Vec = if opts.manually_selected_only { + vec![] + } else { + txouts + .into_values() + .filter(|txo| { + // Skip manually-selected (added separately as must-spend) and locked outputs. + if opts.must_spend.contains(&txo.outpoint) + || wallet.is_outpoint_locked(txo.outpoint) + { + return false; + } + // A spent coin is unavailable -- unless the only tx spending it is one we're + // replacing (the replacement evicts it, freeing the coin). + if let Some((_, spender)) = &txo.spent_by { + if !to_replace.contains(spender) { + return false; + } + } + // In the RBF case, only spend confirmed outputs (the replaced set's outputs were + // already removed from `txouts` above). + !is_rbf || txo.chain_position.is_confirmed() + }) + .flat_map(|txo| plan_input(wallet, &txo, &assets, eval_height, mtp)) + .collect() + }; + + // Build the candidate set, then drop immature / time-locked *optional* candidates unless + // allowed. `InputCandidates::filter` never drops must-select inputs, so manually-selected coins + // are spent regardless of maturity or timelocks. + let mut candidates = InputCandidates::new(must_spend, may_spend); + if !opts.allow_immature || !opts.allow_timelocked { + // Tip MTP for time-based locks at spend time (each input's `prev_mtp` was filled during + // planning via `fetch_mtp`). Height-based locks need neither, so absent values only leave + // time-based locks unresolved (`None` -> conservatively excluded). + let tip_mtp = opts.tip_mtp; + candidates = candidates.filter(|input| { + if !opts.allow_immature && input.is_immature(at_height) { + return false; + } + if !opts.allow_timelocked && input.is_timelocked(at_height, tip_mtp).unwrap_or(true) { + return false; + } + true + }); + } + + // Wallet-owned UTXOs the replacement strips from the canonical view. + let replaced_unspent = if is_rbf { + wallet + .list_unspent() + .filter(|utxo| to_replace.contains(&utxo.outpoint.txid)) + .collect() + } else { + Vec::new() + }; + + Ok(CandidateSet { + candidates, + rbf: rbf_params, + replaced: to_replace, + replaced_unspent, + }) +} + +/// Build a planned [`Input`] that spends `txo`, or `None` if it can't be planned with the +/// available assets (not wallet-owned, or insufficient keys/preimages). +fn plan_input( + wallet: &Wallet, + txo: &FullTxOut, + spend_assets: &Assets, + eval_height: u32, + mtp: Option<&MtpOracle>, +) -> Option { + let op = txo.outpoint; + let txid = op.txid; + + // Pin timelocks to `eval_height` (the maturity/evaluation height, defaulting to the chain tip) + // so a coin spendable as of that height actually plans -- matching the post-planning filter. + // Afford the output with as many assets as we can; the plan uses only the ones needed. + let abs_locktime = spend_assets + .absolute_timelock + .unwrap_or(absolute::LockTime::from_consensus(eval_height)); + + let rel_locktime = spend_assets.relative_timelock.unwrap_or_else(|| { + let age = match txo.chain_position.confirmation_height_upper_bound() { + Some(conf_height) => eval_height + .saturating_add(1) + .saturating_sub(conf_height) + .try_into() + .unwrap_or(u16::MAX), + None => 0, + }; + relative::LockTime::from_height(age) + }); + + // Keep the caller's keys/preimages, but pin the timelocks to the values derived above. + let mut assets = Assets::new(); + merge_assets_secrets(&mut assets, spend_assets); + let assets = assets.after(abs_locktime).older(rel_locktime); + + // Plan the spend (None if the outpoint isn't indexed or the assets are insufficient). + let indexer = wallet.spk_index(); + let ((keychain, index), _) = indexer.txout(op)?; + let plan = indexer + .get_descriptor(keychain)? + .at_derivation_index(index) + .expect("must be valid derivation index") + .plan(&assets) + .ok()?; + + let tx = wallet.tx_graph().get_tx(txid)?; + let tx_status = status_from_position(txo.chain_position, mtp); + + Input::from_prev_tx(plan, tx, op.vout as usize, tx_status).ok() +} diff --git a/wallet_tx/src/params.rs b/wallet_tx/src/params.rs new file mode 100644 index 00000000..4bc33054 --- /dev/null +++ b/wallet_tx/src/params.rs @@ -0,0 +1,276 @@ +//! Parameters for the three PSBT-building stages. +//! +//! 1. **Candidate construction** -- [`CandidateParams`] configures which coins may fund the +//! transaction; [`WalletTxExt::candidates_with`] resolves them into a [`CandidateSet`]. A +//! replacement (RBF) is a candidate set built from options whose [`replace`] list is non-empty +//! (or via the [`WalletTxExt::rbf_candidates`] shortcut). +//! 2. **Selection** -- [`SelectParams`] describes the recipients, fee rate and coin-selection +//! strategy; passed alongside a [`CandidateSet`] to [`WalletTxExt::select`], which runs coin +//! selection and returns a [`bdk_tx::TxTemplate`]. To sweep, use no recipients with +//! [`SelectionStrategy::SweepEffective`] (or [`SweepAll`](SelectionStrategy::SweepAll)). +//! 3. **Emission** -- the caller shapes the [`bdk_tx::TxTemplate`] (version, locktime, ordering, +//! anti-fee-sniping) using its own methods, then emits the final [`Psbt`](bitcoin::Psbt) +//! directly with [`bdk_tx::TxTemplate::build_psbt`] (optionally filling global xpubs from the +//! wallet via [`WalletTxExt::add_global_xpubs`]). +//! +//! [`replace`]: CandidateParams::replace +//! [`CandidateSet`]: crate::CandidateSet +//! [`WalletTxExt::candidates_with`]: crate::WalletTxExt::candidates_with +//! [`WalletTxExt::rbf_candidates`]: crate::WalletTxExt::rbf_candidates +//! [`WalletTxExt::select`]: crate::WalletTxExt::select +//! [`WalletTxExt::add_global_xpubs`]: crate::WalletTxExt::add_global_xpubs + +use bdk_tx::ChangeScript; +use bdk_wallet::chain::{BlockId, CanonicalizationParams}; +use bitcoin::{absolute, Amount, FeeRate, OutPoint, ScriptBuf, Txid}; +use miniscript::plan::Assets; +use std::collections::BTreeSet; + +/// A function mapping a block to its median-time-past (MTP); see [`CandidateParams::fetch_mtp`]. +/// +/// `bdk_wallet` retains no MTP, so the caller computes it from their chain backend. Used boxed in +/// [`CandidateParams::fetch_mtp`] (`Option>`). +/// +/// # Caveats +/// +/// This is a deliberate stopgap, with two rough edges worth knowing before you reach for it: +/// +/// - **Not sans-I/O.** The closure is called *during* candidate resolution +/// ([`candidates_with`](crate::WalletTxExt::candidates_with)), so any lookup it does runs inline +/// and blocking. Answer from an in-memory map of already-synced block times -- don't make a +/// network round-trip per call. +/// - **No error channel.** It returns `Option`, so a *failed* lookup is indistinguishable from "no +/// MTP for this block": the affected input is then conservatively treated as time-locked and +/// silently excluded, with no way to surface why. +/// +/// Both go away with the proper upstream fix -- once `bdk_wallet` keeps block headers (e.g. a +/// `CheckPoint

` chain), MTP is derivable from wallet state and this oracle is unnecessary. +/// Treat `MtpOracle` as the stopgap until then. +pub type MtpOracle = dyn Fn(BlockId) -> Option + Send + Sync; + +/// Parameters for building the set of spendable input candidates (PSBT-building stage 1). +/// +/// Configures how candidates are derived **from the wallet** -- manually selected ("must spend") +/// UTXOs, the spend [`Assets`], canonicalization, and Replace-By-Fee. Pass it to +/// [`WalletTxExt::candidates_with`] to resolve a [`CandidateSet`](crate::CandidateSet). +/// +/// All fields are public; construct with [`new`](Self::new) (or [`Default`]) and set what you need. +/// +/// To spend a UTXO that did not originate from this wallet (a pre-built foreign +/// [`Input`](bdk_tx::Input)), don't configure it here -- push it onto the resolved +/// [`CandidateSet`](crate::CandidateSet) with +/// [`push_must_select`](crate::CandidateSet::push_must_select) / +/// [`push_can_select`](crate::CandidateSet::push_can_select). +/// +/// [`WalletTxExt::candidates_with`]: crate::WalletTxExt::candidates_with +#[derive(Default)] +pub struct CandidateParams { + /// Manually-selected UTXO outpoints that must be spent. + /// + /// Each outpoint must be a wallet-tracked output that is still spendable -- unspent, or (in an + /// RBF) spent only by a transaction being replaced, which the replacement frees. An unknown or + /// genuinely-spent outpoint yields [`CannotSpend`](crate::CandidatesError::CannotSpend). + pub must_spend: BTreeSet, + /// Only include inputs selected manually via [`must_spend`](Self::must_spend) (plus any foreign + /// inputs pushed onto the resolved [`CandidateSet`](crate::CandidateSet)); skip coin selection + /// for additional candidates. + pub manually_selected_only: bool, + /// Txids to replace (Replace-By-Fee). + /// + /// Each replaced transaction's **wallet-owned** inputs become must-spend inputs of the + /// resulting [`CandidateSet`](crate::CandidateSet) -- so the replacement conflicts with (and + /// evicts) them -- and the set carries the replaced-tx fee floor forward. There should be no + /// ancestry linking these txids (replacing an ancestor invalidates the descendant); such + /// ancestry is sanitized away during resolution. + /// + /// Only *owned* inputs are forced: a replaced tx's foreign inputs (not controlled by this + /// wallet -- e.g. a collaborative/coinjoin tx) can't be planned and are dropped from the + /// must-spend set. Re-add them as pre-built [`Input`](bdk_tx::Input)s via + /// [`push_must_select`](crate::CandidateSet::push_must_select) if the replacement needs them. + pub replace: Vec, + + /// Spend [`Assets`] used to create spending plans for the wallet's own outputs. + /// + /// An empty value (the default) means no signing keys are provided, in which case all of the + /// wallet's keys are assumed available so wallet-controlled outputs can still be planned. + pub assets: Assets, + /// Parameters for modifying the wallet's view of canonical transactions. + pub canonical_params: CanonicalizationParams, + + /// Chain **height** at which height-based spendability is evaluated -- coinbase maturity and + /// height-based CLTV/CSV timelocks, in both planning and the post-planning filter. Defaults to + /// the chain tip when `None`. + /// + /// This is the *height* axis only. Time-based (CLTV-time / CSV-time) locks are governed by + /// [`tip_mtp`](Self::tip_mtp), which is always evaluated as of the chain tip -- so a future + /// `maturity_height` looks ahead for height-based locks while time-based locks stay at the tip + /// (a future MTP isn't knowable). + pub maturity_height: Option, + /// Include immature coinbase outputs (still within the 100-block maturity window) among the + /// auto-gathered candidates. Defaults to `false` -- immature coins are excluded. + pub allow_immature: bool, + /// Include outputs whose CLTV/CSV timelock is not yet satisfied at the evaluation height among + /// the auto-gathered candidates. Defaults to `false` -- time-locked coins are excluded. + /// + /// Height-based locks resolve exactly. Time-based locks need median-time-past (which + /// `bdk_wallet` doesn't retain): [`tip_mtp`](Self::tip_mtp) for absolute (CLTV-time) locks and + /// the tip side of relative locks, plus [`fetch_mtp`](Self::fetch_mtp) for the per-input side + /// of relative (CSV-time) locks. Without the needed value, such inputs stay unresolved and are + /// excluded. + pub allow_timelocked: bool, + /// The chain tip's median-time-past (MTP) -- the **time** axis for evaluating time-based + /// timelocks, always as of the chain tip (the only knowable MTP). + /// + /// `bdk_wallet` retains no MTP, so supply the tip's value -- a single, cheap-to-obtain number + /// from your chain backend. Every time-based lock needs it: absolute (CLTV-time), and the tip + /// side of relative (CSV-time). `None` leaves them unresolved (excluded when + /// [`allow_timelocked`](Self::allow_timelocked) is `false`). Unlike + /// [`maturity_height`](Self::maturity_height) (the height axis), this can't follow a future + /// evaluation height -- a future MTP isn't knowable. + pub tip_mtp: Option, + /// Per-block MTP oracle filling each confirmed input's + /// [`prev_mtp`](bdk_tx::ConfirmationStatus), queried with the input's confirmation block. + /// + /// Needed **only** for relative (CSV-time) locks, which compare [`tip_mtp`](Self::tip_mtp) + /// against the input's confirmation MTP. Absolute (CLTV-time) locks need only `tip_mtp`; + /// height-based locks need neither -- so most callers leave this `None`. The caller computes + /// MTP from their backend; `None` for a block (or leaving this `None`) leaves that input's + /// `prev_mtp` unset. + pub fetch_mtp: Option>, +} + +impl core::fmt::Debug for CandidateParams { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CandidateParams") + .field("must_spend", &self.must_spend) + .field("manually_selected_only", &self.manually_selected_only) + .field("replace", &self.replace) + .field("assets", &self.assets) + .field("canonical_params", &self.canonical_params) + .field("maturity_height", &self.maturity_height) + .field("allow_immature", &self.allow_immature) + .field("allow_timelocked", &self.allow_timelocked) + .field("tip_mtp", &self.tip_mtp) + .field("fetch_mtp", &self.fetch_mtp.as_ref().map(|_| "")) + .finish() + } +} + +impl CandidateParams { + /// Create new, empty [`CandidateParams`]. + pub fn new() -> Self { + Self::default() + } +} + +/// Parameters to create a PSBT that pays a set of recipients (PSBT-building stage 2). +/// +/// Built with [`SelectParams::new`], passed alongside a [`CandidateSet`](crate::CandidateSet) to +/// [`WalletTxExt::select`], which runs coin selection and returns a [`bdk_tx::TxTemplate`]. The +/// caller then shapes the template (version, locktime, anti-fee-sniping, input/output ordering) +/// using the template's own methods before emitting the PSBT via +/// [`bdk_tx::TxTemplate::build_psbt`]. +/// +/// [`WalletTxExt::select`]: crate::WalletTxExt::select +#[derive(Debug)] +pub struct SelectParams { + /// List of recipient script/amount pairs. + pub recipients: Vec<(ScriptBuf, Amount)>, + /// Optional script or descriptor designated for change. When `None`, the wallet's next unused + /// internal address is revealed and used. + pub change_script: Option, + /// Coin selection strategy to use. + /// + /// Defaults to [`SelectionStrategy::LowestFee`] (a waste-minimizing BnB). Use + /// [`SelectionStrategy::SweepEffective`] (with no recipients) to sweep the spendable balance. + pub coin_selection: SelectionStrategy, + /// Target feerate. + pub feerate: FeeRate, + /// Long-term feerate for waste optimization -- the hypothetical feerate at which the resulting + /// coins are later spent. + /// + /// Feeds the waste-aware change policy for **every** strategy, and the bnb metric of + /// [`SelectionStrategy::LowestFee`]. `None` (the default) means "assume it matches + /// [`feerate`](Self::feerate)". + pub longterm_feerate: Option, +} + +impl Default for SelectParams { + fn default() -> Self { + Self::new() + } +} + +impl SelectParams { + /// Create `SelectParams` with no recipients, default coin selection, and the + /// `FeeRate::BROADCAST_MIN` feerate. + pub fn new() -> Self { + Self { + recipients: Vec::new(), + change_script: None, + coin_selection: SelectionStrategy::default(), + feerate: FeeRate::BROADCAST_MIN, + longterm_feerate: None, + } + } +} + +/// Coin selection strategy. +/// +/// Defaults to [`LowestFee`](Self::LowestFee) with a generous round budget -- a waste-minimizing +/// Branch and Bound search that, allowing change, finds a target-meeting solution for any fundable +/// candidate set (it only gives up after `max_rounds` branches without one, which needs a candidate +/// count on the order of `max_rounds` itself). +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum SelectionStrategy { + /// Single random draw. + SingleRandomDraw, + /// Lowest fee, a variation of Branch 'n Bound that allows for change while minimizing + /// transaction fees. Refer to the [`LowestFee`] metric for more. + /// + /// `max_rounds` is the search budget, not a success/failure threshold: a solution is found + /// early, and the search keeps refining toward the lowest-waste one until it proves optimality + /// (stopping early) or hits the cap. Higher values only help in the large-candidate-set tail. + /// + /// [`LowestFee`]: bdk_tx::bdk_coin_select::metrics::LowestFee + LowestFee { + /// How many BnB branches to explore before returning the best solution found so far. + max_rounds: usize, + }, + /// Spend **every** available candidate -- including *uneconomical* ones (inputs that cost more + /// in fees to spend than they add in value) -- ignoring any target amount. + /// + /// The remainder (everything minus fees) goes to change: with no recipients this sweeps the + /// whole candidate set to a single output; with recipients it pays them and sends the rest to + /// change. Use it to fully empty a wallet (e.g. closing it), accepting that uneconomical inputs + /// reduce the swept amount. See [`SweepEffective`](Self::SweepEffective) to skip those inputs. + SweepAll, + /// Spend every **economical** candidate -- those with positive *effective value* (their value + /// minus the fee to spend them at the target `feerate`) -- ignoring any target amount. + /// + /// Like [`SweepAll`](Self::SweepAll), but drops inputs that would cost more to spend than they + /// are worth, maximizing the swept amount. This is the usual "sweep my spendable balance". + SweepEffective, +} + +impl Default for SelectionStrategy { + fn default() -> Self { + Self::LowestFee { + max_rounds: 210_000, + } + } +} + +/// Merge the available signing keys and hash preimages from `src` into `dst`. +/// +/// Only these additive (set-union) secrets are merged. The absolute/relative timelocks are +/// deliberately left untouched -- they are single-valued ceilings with no unambiguous merge. +pub(crate) fn merge_assets_secrets(dst: &mut Assets, src: &Assets) { + dst.keys.extend(src.keys.clone()); + dst.sha256_preimages.extend(src.sha256_preimages.clone()); + dst.hash256_preimages.extend(src.hash256_preimages.clone()); + dst.ripemd160_preimages + .extend(src.ripemd160_preimages.clone()); + dst.hash160_preimages.extend(src.hash160_preimages.clone()); +} diff --git a/wallet_tx/tests/three_stage.rs b/wallet_tx/tests/three_stage.rs new file mode 100644 index 00000000..9d997e29 --- /dev/null +++ b/wallet_tx/tests/three_stage.rs @@ -0,0 +1,332 @@ +//! End-to-end tests of the three-stage pipeline over a deterministically-funded `bdk_wallet`. + +use bdk_tx::{BuildPsbtParams, ChangeScript}; +use bdk_wallet::test_utils::{get_funded_wallet, get_test_tr_single_sig_xprv_and_change_desc}; +use bdk_wallet::{KeychainKind, SignOptions}; +use bdk_wallet_tx::{SelectError, SelectParams, SelectionStrategy, WalletTxExt}; +use bitcoin::secp256k1::rand; +use bitcoin::{Amount, FeeRate}; + +/// Sign the PSBT with the wallet's keys (without finalizing), then finalize with the `bdk_tx` +/// finalizer returned by stage 3. +fn sign_and_finalize( + wallet: &bdk_wallet::Wallet, + psbt: &mut bitcoin::Psbt, + finalizer: &bdk_tx::Finalizer, +) { + // `Wallet::sign` returns the PSBT's *finalization* status (false here, since we leave + // finalization to the `bdk_tx` finalizer); it still fills in the signatures. + let _ = wallet + .sign( + psbt, + SignOptions { + try_finalize: false, + ..Default::default() + }, + ) + .expect("signing failed"); + assert!(finalizer.finalize(psbt).is_finalized(), "must finalize"); +} + +#[test] +fn select_and_build_psbt_pays_recipient() -> anyhow::Result<()> { + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (wallet, _txid) = get_funded_wallet(desc, change); + + // Stage 1. + let coins = wallet.candidates()?; + assert!(!coins.is_empty(), "funded wallet must have candidates"); + + // Stage 2. + let recipient = wallet + .peek_address(KeychainKind::External, 99) + .script_pubkey(); + let params = SelectParams { + recipients: vec![(recipient.clone(), Amount::from_sat(10_000))], + coin_selection: SelectionStrategy::SingleRandomDraw, + feerate: FeeRate::from_sat_per_vb(2).unwrap(), + change_script: None, + longterm_feerate: None, + }; + let (template, _change) = wallet.select(&coins, params, &mut rand::thread_rng())?; + + // Stage 3. + let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; + sign_and_finalize(&wallet, &mut psbt, &finalizer); + + let tx = psbt.extract_tx()?; + assert!( + tx.output + .iter() + .any(|o| o.script_pubkey == recipient && o.value == Amount::from_sat(10_000)), + "recipient output must be present with the exact amount" + ); + // recipient + change. + assert_eq!(tx.output.len(), 2); + Ok(()) +} + +#[test] +fn sweep_all_to_single_output() -> anyhow::Result<()> { + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (wallet, _txid) = get_funded_wallet(desc, change); + + let coins = wallet.candidates()?; + let params = SelectParams { + coin_selection: SelectionStrategy::SweepAll, + feerate: FeeRate::from_sat_per_vb(1).unwrap(), + ..Default::default() + }; + let (template, _change) = wallet.select(&coins, params, &mut rand::thread_rng())?; + let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; + sign_and_finalize(&wallet, &mut psbt, &finalizer); + + let tx = psbt.extract_tx()?; + assert_eq!(tx.output.len(), 1, "a sweep has a single (change) output"); + Ok(()) +} + +#[test] +fn lowest_fee_selection_pays_recipient() -> anyhow::Result<()> { + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (wallet, _txid) = get_funded_wallet(desc, change); + + let coins = wallet.candidates()?; + let recipient = wallet + .peek_address(KeychainKind::External, 7) + .script_pubkey(); + let params = SelectParams { + recipients: vec![(recipient.clone(), Amount::from_sat(10_000))], + coin_selection: SelectionStrategy::LowestFee { + max_rounds: 100_000, + }, + feerate: FeeRate::from_sat_per_vb(2).unwrap(), + longterm_feerate: Some(FeeRate::from_sat_per_vb(1).unwrap()), + change_script: None, + }; + let (template, _change) = wallet.select(&coins, params, &mut rand::thread_rng())?; + let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; + sign_and_finalize(&wallet, &mut psbt, &finalizer); + + let tx = psbt.extract_tx()?; + assert!(tx + .output + .iter() + .any(|o| o.script_pubkey == recipient && o.value == Amount::from_sat(10_000))); + Ok(()) +} + +#[test] +fn add_global_xpubs_populates_psbt() -> anyhow::Result<()> { + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (wallet, _txid) = get_funded_wallet(desc, change); + + let coins = wallet.candidates()?; + let recipient = wallet + .peek_address(KeychainKind::External, 1) + .script_pubkey(); + let (template, _change) = wallet.select( + &coins, + SelectParams { + recipients: vec![(recipient, Amount::from_sat(10_000))], + feerate: FeeRate::from_sat_per_vb(2).unwrap(), + ..Default::default() + }, + &mut rand::thread_rng(), + )?; + let (mut psbt, _finalizer) = template.build_psbt(BuildPsbtParams::default())?; + + assert!(psbt.xpub.is_empty(), "no global xpubs before"); + wallet.add_global_xpubs(&mut psbt)?; + assert!(!psbt.xpub.is_empty(), "global xpubs should be populated"); + Ok(()) +} + +#[test] +fn select_is_a_pure_read_change_reserve_is_explicit() -> anyhow::Result<()> { + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (mut wallet, _txid) = get_funded_wallet(desc, change); + + let before = wallet.next_derivation_index(KeychainKind::Internal); + let coins = wallet.candidates()?; + let recipient = wallet + .peek_address(KeychainKind::External, 5) + .script_pubkey(); + let (_template, change) = wallet.select( + &coins, + SelectParams { + recipients: vec![(recipient, Amount::from_sat(10_000))], + feerate: FeeRate::from_sat_per_vb(2).unwrap(), + ..Default::default() + }, + &mut rand::thread_rng(), + )?; + + // `select` reports the change output but does not touch wallet state. + let change = change.expect("a change output was produced"); + assert_eq!(change.keychain, KeychainKind::Internal); + assert_eq!( + wallet.next_derivation_index(KeychainKind::Internal), + before, + "select must not reveal/mutate" + ); + + // Committing reveals it (keychain advances) and marks it used (no longer offered as unused -- + // i.e. the next select won't reuse it). + wallet.reserve_change(&change); + assert!( + wallet.next_derivation_index(KeychainKind::Internal) > before, + "reserve_change reveals the change address" + ); + assert!( + !wallet + .list_unused_addresses(KeychainKind::Internal) + .any(|a| a.index == change.index), + "reserve_change marks the change address used (prevents reuse)" + ); + Ok(()) +} + +#[test] +fn no_change_output_yields_none() -> anyhow::Result<()> { + // A caller-supplied change script auto-derives nothing. + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (wallet, _txid) = get_funded_wallet(desc, change); + let coins = wallet.candidates()?; + let recipient = wallet + .peek_address(KeychainKind::External, 6) + .script_pubkey(); + let explicit_change = ChangeScript::from_descriptor( + wallet + .public_descriptor(KeychainKind::External) + .at_derivation_index(50)?, + ); + let (_template, change) = wallet.select( + &coins, + SelectParams { + recipients: vec![(recipient, Amount::from_sat(10_000))], + change_script: Some(explicit_change), + feerate: FeeRate::from_sat_per_vb(2).unwrap(), + ..Default::default() + }, + &mut rand::thread_rng(), + )?; + assert!( + change.is_none(), + "a caller-supplied change script yields no auto-derived change address" + ); + Ok(()) +} + +#[test] +fn locked_outpoints_are_excluded_from_candidates() -> anyhow::Result<()> { + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (mut wallet, _txid) = get_funded_wallet(desc, change); + + let utxo = wallet + .list_unspent() + .next() + .expect("funded wallet has a utxo") + .outpoint; + assert!( + wallet + .candidates()? + .inputs() + .any(|i| i.prev_outpoint() == utxo), + "utxo is a candidate before locking" + ); + + wallet.lock_outpoint(utxo); + assert!( + !wallet + .candidates()? + .inputs() + .any(|i| i.prev_outpoint() == utxo), + "locked outpoint must not be a candidate" + ); + Ok(()) +} + +#[test] +fn select_without_recipients_errors() { + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (wallet, _txid) = get_funded_wallet(desc, change); + + let coins = wallet.candidates().unwrap(); + // The default strategy (LowestFee) is a non-sweep one, which requires at least one recipient. + let err = wallet + .select(&coins, SelectParams::new(), &mut rand::thread_rng()) + .unwrap_err(); + assert!(matches!(err, SelectError::NoRecipients)); +} + +#[test] +fn immature_coinbase_is_excluded_unless_allowed() -> anyhow::Result<()> { + use bdk_wallet::chain::{ConfirmationBlockTime, TxUpdate}; + use bdk_wallet::Update; + use bdk_wallet_tx::CandidateParams; + use bitcoin::{absolute, transaction, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut}; + use std::sync::Arc; + + let (desc, change) = get_test_tr_single_sig_xprv_and_change_desc(); + let (mut wallet, _txid) = get_funded_wallet(desc, change); + let tip = wallet.latest_checkpoint().block_id(); + + // A coinbase output paying the wallet, confirmed *at the tip* -- so it is still immature. + let spk = wallet + .peek_address(KeychainKind::External, 0) + .script_pubkey(); + let coinbase = Transaction { + version: transaction::Version::ONE, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_000), + script_pubkey: spk, + }], + }; + assert!(coinbase.is_coinbase()); + let cb = OutPoint::new(coinbase.compute_txid(), 0); + // Insert the coinbase confirmed, with no mempool `seen_at` (coinbase txs can't be in mempool). + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(coinbase)]; + tx_update.anchors = [( + ConfirmationBlockTime { + block_id: tip, + confirmation_time: 0, + }, + cb.txid, + )] + .into(); + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .expect("apply update"); + + // Default: the immature coinbase is excluded. + assert!( + !wallet + .candidates()? + .inputs() + .any(|i| i.prev_outpoint() == cb), + "immature coinbase must be excluded by default" + ); + + // `allow_immature`: it is included. + let coins = wallet.candidates_with(&CandidateParams { + allow_immature: true, + ..Default::default() + })?; + assert!( + coins.inputs().any(|i| i.prev_outpoint() == cb), + "allow_immature must include the immature coinbase" + ); + Ok(()) +}