From 3deb321066abcf5c230a47bd49a1699222b23989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 15 Jun 2026 19:13:09 +0000 Subject: [PATCH 01/24] fix(signer): match BIP32 origin via `matches`, not string prefix `get_key` decided whether a key origin covered a `KeyRequest::Bip32` derivation by stringifying both paths and using `starts_with`. Since `DerivationPath` renders components as `/`-joined decimals, this matched on character boundaries rather than components: `m/1` spuriously matched `m/10`, and an unhardened origin matched its hardened sibling. The signer could then return a private key for a derivation the descriptor never meant to expose. Delegate the check to miniscript's `DescriptorXKey::matches`, which confirms the key actually represents the request (handling the key origin and wildcard), then strip the origin prefix and derive the remainder. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/signer.rs | 103 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/src/signer.rs b/src/signer.rs index d0866c3d..f371a096 100644 --- a/src/signer.rs +++ b/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(()) + } } From 7459be2aba311753adbed09e4ad543f7d9531d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 28 May 2026 07:37:41 +0000 Subject: [PATCH 02/24] test(selection): Refactor tests to reuse key strings Previously, we had a string constants for test descriptors and test keys where the test keys were also hardcoded in the test descriptors. We remove the hard coded test descriptor and rename variables for clarity. --- src/selection.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/selection.rs b/src/selection.rs index e1dd4100..8f1437c8 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -345,16 +345,15 @@ mod tests { 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"; + const TEST_KEY_HEX: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; + const TEST_KEY_TR: &str = "[83737d5e/86h/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_HEX_PK}),after({cltv})))"); - let desc_pk: DescriptorPublicKey = TEST_HEX_PK.parse()?; + 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)? @@ -472,12 +471,12 @@ mod tests { pub fn setup_test_input(confirmation_height: u32) -> anyhow::Result { let secp = Secp256k1::new(); - let desc = Descriptor::parse_descriptor(&secp, TEST_DESCRIPTOR) + 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_DESCRIPTOR_PK.parse()?; + 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"); @@ -663,8 +662,7 @@ mod tests { // `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_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)?; @@ -678,7 +676,7 @@ mod tests { }], }; let assets = Assets::new() - .add(TEST_DESCRIPTOR_PK.parse::()?) + .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 { From 3047b8719b9efece6955de50cd248b504a5f85ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 28 May 2026 07:56:52 +0000 Subject: [PATCH 03/24] feat(selection): always declare psbt input sighash type Any Plan whose Schnorr witness template includes a 64B signature gets a DEFAULT sighash type (this includes mixed-size plans). All other plans require ALL sighash. --- src/selection.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/src/selection.rs b/src/selection.rs index 8f1437c8..b0e13ab7 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -1,8 +1,8 @@ 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}; use miniscript::psbt::PsbtExt; @@ -306,6 +306,25 @@ impl Selection { ))); } } + // 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; } @@ -347,6 +366,9 @@ mod tests { 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, @@ -796,4 +818,118 @@ mod tests { shuffled.sort(); assert_eq!(shuffled, original); } + + 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: PsbtParams) -> anyhow::Result { + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); + let selection = Selection::new(vec![input], vec![output]); + Ok(selection.create_psbt(params)?) + } + + /// `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, PsbtParams::default())?; + assert_eq!(psbt.inputs[0].sighash_type, expected, "{name}"); + } + Ok(()) + } } From 2211b74ac03d36606c23f373e3bf7dac753ce338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 2 Jun 2026 07:53:01 +0000 Subject: [PATCH 04/24] fix(finalizer): Retain unknown input fields when finalizing **BIP174:** All other data except the UTXO and unknown fields (including PSBT_IN_PROPRIETARY fields the Input Finalizer does not understand) in the input key-value map should be cleared from the PSBT. --- src/finalizer.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index 582494fd..46eee48b 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -99,11 +99,14 @@ impl Finalizer { 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 + // clearing all fields and setting back the utxo, final scriptsig, witness and unkwown + // 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); } From 7f91b8315cc781db8ec787ff796cb06ae64c94a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 13 Jun 2026 19:45:17 +0000 Subject: [PATCH 05/24] fix(finalizer)!: validate sighash types and signature sizes Finalization can now fail for reasons miniscript does not model, so `finalize_input` and `FinalizeMap` return a dedicated `FinalizeError` instead of `miniscript::Error`. The finalizer now rejects an input when: - a signature's sighash type disagrees with the declared `PSBT_IN_SIGHASH_TYPE` (mandated by BIP174); - no type is declared yet a signature is neither DEFAULT nor ALL; - a satisfied schnorr witness is larger than the plan committed to (e.g. a 65-byte SIGHASH_ALL sig where 64-byte DEFAULT was planned), which would make the transaction undershoot its target feerate and risk being unbroadcastable. BREAKING CHANGE: `Finalizer::finalize_input`, `FinalizeMap`, and `FinalizeMap::results` now use `FinalizeError` in place of `miniscript::Error`. Co-Authored-By: Claude Opus 4.8 --- src/finalizer.rs | 219 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 177 insertions(+), 42 deletions(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index 46eee48b..4b131ad9 100644 --- a/src/finalizer.rs +++ b/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,6 +12,9 @@ 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 @@ -58,71 +61,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, witness and unkwown - // 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); + // 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()); @@ -151,7 +219,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 @@ -160,11 +228,78 @@ 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}) but no PSBT_IN_SIGHASH_TYPE is 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 { From b9966cc22246e35601ea3992744a407db546757c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 13 Jun 2026 19:56:49 +0000 Subject: [PATCH 06/24] test(finalizer): cover sighash-type finalization errors Add tests for the two sighash checks `finalize_input` now performs: `SighashMismatch` (declared PSBT_IN_SIGHASH_TYPE disagrees with the signature) and `SighashNotAllowed` (no type declared, signature is neither DEFAULT nor ALL). `SignatureTooLarge` is left uncovered: it is only reachable in release builds, since in debug miniscript's `satisfy_self` panics on a `debug_assert!` of the signature size before the check is reached. Co-Authored-By: Claude Opus 4.8 --- src/finalizer.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index 4b131ad9..9b4a07a4 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -303,9 +303,9 @@ 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::{FinalizeError, Finalizer, Output, PsbtParams, Selection, Signer}; 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; @@ -556,4 +556,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 selection = Selection::new(vec![input], vec![output]); + + let mut psbt = selection.create_psbt(PsbtParams::default())?; + let finalizer = selection.into_finalizer(); + 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 selection = Selection::new(vec![input], vec![output]); + + let mut psbt = selection.create_psbt(PsbtParams::default())?; + let finalizer = selection.into_finalizer(); + psbt.sign(&Signer(keymap), &Secp256k1::new()) + .expect("signing failed"); + + // No PSBT_IN_SIGHASH_TYPE declared, yet the signature uses neither DEFAULT nor ALL. + 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(()) + } } From a64acf3d5fdd2263789bd7f20f3f7684bbdfbe21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 24 Jun 2026 04:48:14 +0000 Subject: [PATCH 07/24] fix(finalizer): clarify SighashNotAllowed error message --- src/finalizer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index 9b4a07a4..72811af4 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -285,7 +285,7 @@ impl core::fmt::Display for FinalizeError { ), FinalizeError::SighashNotAllowed { got } => write!( f, - "signature has sighash type ({got}) but no PSBT_IN_SIGHASH_TYPE is declared; only ALL or DEFAULT are permitted" + "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, From e97ef0206f7e49a3f10a0f1252fe29349fe765a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 27 Jun 2026 03:14:21 +0000 Subject: [PATCH 08/24] test(finalizer): clear declared sighash type for SighashNotAllowed case #74 makes selection always declare PSBT_IN_SIGHASH_TYPE, so the test must clear it to exercise the no-declaration path that yields SighashNotAllowed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/finalizer.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index 72811af4..a6ab1cfd 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -588,7 +588,9 @@ mod tests { psbt.sign(&Signer(keymap), &Secp256k1::new()) .expect("signing failed"); - // No PSBT_IN_SIGHASH_TYPE declared, yet the signature uses neither DEFAULT nor ALL. + // 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() From 651785aba58925ee8b7e45233e12db23c8cfc1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 20 May 2026 05:19:14 +0000 Subject: [PATCH 09/24] refactor!: rename Selection to TxTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure rename — same struct, same methods, same parameters. No behaviour change. The next commit adds the resolved tx-shape fields (version, lock_time, fallback_sequence), the corresponding setters, and the PSBT/AFS pipeline that consumes them. Selection -> TxTemplate Selection::new -> TxTemplate::from_parts (still pub(crate)) IntoSelectionError -> IntoTxTemplateError InputCandidates::into_selection -> into_tx_template Selector::try_finalize() -> Option Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/anti_fee_sniping.rs | 2 +- examples/synopsis.rs | 4 ++-- src/finalizer.rs | 26 +++++++++++++------------- src/input.rs | 12 ++++++------ src/input_candidates.rs | 28 ++++++++++++++-------------- src/lib.rs | 4 ++-- src/selector.rs | 6 +++--- src/{selection.rs => tx_template.rs} | 28 ++++++++++++++-------------- 8 files changed, 55 insertions(+), 55 deletions(-) rename src/{selection.rs => tx_template.rs} (97%) diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index da318b93..1c0a57e3 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -72,7 +72,7 @@ fn main() -> anyhow::Result<()> { .all_candidates() .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_time))) - .into_selection( + .into_tx_template( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams { // For waste optimization when deciding change. diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 2d629ba6..c2685e8c 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -52,7 +52,7 @@ fn main() -> anyhow::Result<()> { .all_candidates() .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_mtp))) - .into_selection( + .into_tx_template( selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams { // For waste-optimization when deciding change. @@ -122,7 +122,7 @@ fn main() -> anyhow::Result<()> { let selection = rbf_candidates // Do coin selection. - .into_selection( + .into_tx_template( // Coin selection algorithm. selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), SelectorParams { diff --git a/src/finalizer.rs b/src/finalizer.rs index a6ab1cfd..6f2f64ce 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -18,7 +18,7 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// # Usage /// /// Construct a [`Finalizer`] from a list of `(outpoint, plan)` pairs, or by calling -/// [`into_finalizer`] on a particular [`Selection`]. Use [`finalize_input`] to finalize a single +/// [`into_finalizer`] on a particular [`TxTemplate`]. Use [`finalize_input`] to finalize a single /// input, or [`finalize`] to finalize every input and return a map containing the result of /// finalization at each index. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata /// from non-essential fields of the PSBT inputs and outputs, ensuring that only the necessary @@ -30,7 +30,7 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// # use bdk_tx::PsbtParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); /// # let keymap = std::collections::BTreeMap::new(); -/// # let selection: bdk_tx::Selection = unimplemented!(); +/// # let template: bdk_tx::TxTemplate = unimplemented!(); /// // Create PSBT from a selection of inputs and outputs. /// let mut psbt = selection.create_psbt(PsbtParams::default())?; /// @@ -49,8 +49,8 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// ``` /// /// [BIP174]: -/// [`Selection`]: crate::Selection -/// [`into_finalizer`]: crate::Selection::into_finalizer +/// [`TxTemplate`]: crate::TxTemplate +/// [`into_finalizer`]: crate::TxTemplate::into_finalizer /// [`Plan`]: miniscript::plan::Plan /// [`Transaction`]: bitcoin::Transaction /// [`finalize_input`]: Finalizer::finalize_input @@ -303,7 +303,7 @@ impl core::error::Error for FinalizeError {} #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { - use crate::{FinalizeError, Finalizer, Output, PsbtParams, Selection, Signer}; + use crate::{FinalizeError, Finalizer, Output, PsbtParams, Signer, TxTemplate}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{absolute, transaction, Amount, ScriptBuf, TapSighashType, TxIn, TxOut}; use miniscript::bitcoin; @@ -355,7 +355,7 @@ mod tests { fn test_finalize_single_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); let mut psbt = selection.create_psbt(PsbtParams::default())?; let finalizer = selection.into_finalizer(); @@ -375,7 +375,7 @@ mod tests { fn test_finalize_sets_final_script_sig() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(PKH_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); let mut psbt = selection.create_psbt(PsbtParams::default())?; let finalizer = selection.into_finalizer(); @@ -398,7 +398,7 @@ mod tests { let taproot_output_descriptor = derive_descriptor_at(TR_XPRV, 10)?; let wpkh_output_descriptor = derive_descriptor_at(WPKH_XPRV, 11)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input_0, input_1, input_2], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -453,7 +453,7 @@ mod tests { input_0.plan().cloned().expect("plan must exist"), )]); - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input_0, input_1], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -493,7 +493,7 @@ mod tests { let (input, _) = create_input_from_descriptor_at(TR_XPRV, 0)?; let taproot_output_descriptor = derive_descriptor_at(TR_XPRV, 10)?; let wpkh_output_descriptor = derive_descriptor_at(WPKH_XPRV, 11)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -527,7 +527,7 @@ mod tests { fn test_already_finalized_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); let mut psbt = selection.create_psbt(PsbtParams::default())?; let finalizer = selection.into_finalizer(); @@ -561,7 +561,7 @@ mod tests { 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 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(); @@ -581,7 +581,7 @@ mod tests { 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 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(); diff --git a/src/input.rs b/src/input.rs index dfe632cb..c50358d0 100644 --- a/src/input.rs +++ b/src/input.rs @@ -665,16 +665,16 @@ impl Input { } } -/// Mutable handle to an [`Input`] held inside a [`Selection`]. +/// Mutable handle to an [`Input`] held inside a [`TxTemplate`]. /// -/// Returned by [`Selection::input_mut`] and [`Selection::inputs_mut`]. This wrapper restricts -/// mutation to operations that preserve [`Selection`]'s coin-selection invariants. +/// Returned by [`TxTemplate::input_mut`] and [`TxTemplate::inputs_mut`]. This wrapper restricts +/// mutation to operations that preserve [`TxTemplate`]'s coin-selection invariants. /// /// Read-only access to the underlying [`Input`] is available via [`Deref`]. /// -/// [`Selection`]: crate::Selection -/// [`Selection::input_mut`]: crate::Selection::input_mut -/// [`Selection::inputs_mut`]: crate::Selection::inputs_mut +/// [`TxTemplate`]: crate::TxTemplate +/// [`TxTemplate::input_mut`]: crate::TxTemplate::input_mut +/// [`TxTemplate::inputs_mut`]: crate::TxTemplate::inputs_mut /// [`Deref`]: core::ops::Deref #[derive(Debug)] pub struct InputMut<'a>(&'a mut Input); diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 131f6af8..75f90851 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -7,8 +7,8 @@ use miniscript::bitcoin; use crate::collections::{BTreeMap, HashSet}; use crate::{ - CannotMeetTarget, FeeRateExt, Input, InputGroup, Selection, Selector, SelectorError, - SelectorParams, + CannotMeetTarget, FeeRateExt, Input, InputGroup, Selector, SelectorError, SelectorParams, + TxTemplate, }; /// Input candidates. @@ -191,30 +191,30 @@ impl InputCandidates { self } - /// Attempt to convert the input candidates into a valid [`Selection`] with a given + /// Attempt to convert the input candidates into a valid [`TxTemplate`] with a given /// `algorithm` and selector `params`. - pub fn into_selection( + pub fn into_tx_template( self, algorithm: A, params: SelectorParams, - ) -> Result> + ) -> Result> where A: FnMut(&mut Selector) -> Result<(), E>, { - let mut selector = Selector::new(&self, params).map_err(IntoSelectionError::Selector)?; + let mut selector = Selector::new(&self, params).map_err(IntoTxTemplateError::Selector)?; selector .select_with_algorithm(algorithm) - .map_err(IntoSelectionError::SelectionAlgorithm)?; + .map_err(IntoTxTemplateError::SelectionAlgorithm)?; let selection = selector .try_finalize() - .ok_or(IntoSelectionError::CannotMeetTarget(CannotMeetTarget))?; + .ok_or(IntoTxTemplateError::CannotMeetTarget(CannotMeetTarget))?; Ok(selection) } } /// Occurs when we cannot find a solution for selection. #[derive(Debug)] -pub enum IntoSelectionError { +pub enum IntoTxTemplateError { /// Coin selector returned an error Selector(SelectorError), /// Selection algorithm failed. @@ -223,22 +223,22 @@ pub enum IntoSelectionError { CannotMeetTarget(CannotMeetTarget), } -impl fmt::Display for IntoSelectionError { +impl fmt::Display for IntoTxTemplateError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - IntoSelectionError::Selector(error) => { + IntoTxTemplateError::Selector(error) => { write!(f, "{error}") } - IntoSelectionError::SelectionAlgorithm(error) => { + IntoTxTemplateError::SelectionAlgorithm(error) => { write!(f, "selection algorithm failed: {error}") } - IntoSelectionError::CannotMeetTarget(error) => write!(f, "{error}"), + IntoTxTemplateError::CannotMeetTarget(error) => write!(f, "{error}"), } } } #[cfg(feature = "std")] -impl std::error::Error for IntoSelectionError {} +impl std::error::Error for IntoTxTemplateError {} /// Select for lowest fee with bnb pub fn selection_algorithm_lowest_fee_bnb( diff --git a/src/lib.rs b/src/lib.rs index b60312ec..990d527c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,9 +19,9 @@ mod input_candidates; mod no_std_rand; mod output; mod rbf; -mod selection; mod selector; mod signer; +mod tx_template; pub use afs::*; pub use canonical_unspents::*; @@ -34,9 +34,9 @@ use miniscript::{DefiniteDescriptorKey, Descriptor}; use no_std_rand::*; pub use output::*; pub use rbf::*; -pub use selection::*; pub use selector::*; pub use signer::*; +pub use tx_template::*; #[cfg(feature = "std")] pub(crate) mod collections { diff --git a/src/selector.rs b/src/selector.rs index 4c98ec86..ef4e3ff6 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -4,7 +4,7 @@ use miniscript::bitcoin; use crate::{ DefiniteDescriptor, FeeRateExt, Input, InputCandidates, InputGroup, Output, ScriptSource, - Selection, + TxTemplate, }; use alloc::boxed::Box; use alloc::vec::Vec; @@ -486,7 +486,7 @@ impl<'c> Selector<'c> { /// Try get final selection. /// /// Return `None` if target is not met yet. - pub fn try_finalize(&self) -> Option { + pub fn try_finalize(&self) -> Option { if !self.inner.is_target_met(self.target) { return None; } @@ -506,7 +506,7 @@ impl<'c> Selector<'c> { Amount::from_sat(maybe_change.value), ))); } - Some(Selection::new(inputs, outputs)) + Some(TxTemplate::from_parts(inputs, outputs)) } } diff --git a/src/selection.rs b/src/tx_template.rs similarity index 97% rename from src/selection.rs rename to src/tx_template.rs index b0e13ab7..10ae1fe6 100644 --- a/src/selection.rs +++ b/src/tx_template.rs @@ -16,7 +16,7 @@ use crate::{ /// Final selection of inputs and outputs. #[derive(Debug, Clone)] #[must_use] -pub struct Selection { +pub struct TxTemplate { inputs: Vec, outputs: Vec, } @@ -49,7 +49,7 @@ pub struct PsbtParams { /// /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining the /// transaction to only be valid at or after the chain tip. When enabled, - /// [`Selection::create_psbt`] sets either the transaction's `nLockTime` or the `nSequence` of + /// [`TxTemplate::create_psbt`] sets either the transaction's `nLockTime` or the `nSequence` of /// one Taproot input to a value derived from `tip_height`. /// /// AFS only operates on a height-based `tx.lock_time`. If [`min_locktime`] or any input's @@ -61,7 +61,7 @@ pub struct PsbtParams { /// /// # Errors /// - /// When `Some(..)`, [`Selection::create_psbt`] returns [`CreatePsbtError::AntiFeeSniping`] if: + /// When `Some(..)`, [`TxTemplate::create_psbt`] returns [`CreatePsbtError::AntiFeeSniping`] if: /// - the transaction version is less than 2 /// ([`AntiFeeSnipingError::UnsupportedVersion`]) — v2 is required for relative locktimes; or /// - a time-based (MTP) locktime is in effect @@ -130,8 +130,8 @@ impl core::fmt::Display for CreatePsbtError { #[cfg(feature = "std")] impl std::error::Error for CreatePsbtError {} -impl Selection { - pub(crate) fn new(inputs: Vec, outputs: Vec) -> Self { +impl TxTemplate { + pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { Self { inputs, outputs } } @@ -400,7 +400,7 @@ mod tests { let (input, desc) = setup_cltv_input(abs_locktime)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -460,7 +460,7 @@ mod tests { let (input, desc) = setup_cltv_input(time_locktime)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -527,7 +527,7 @@ mod tests { let current_height = 2_500; let input = setup_test_input(2_000).unwrap(); let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input], vec![output]); + let selection = TxTemplate::from_parts(vec![input], vec![output]); // Disabled - default behavior is disable let psbt = selection.create_psbt(PsbtParams { @@ -552,7 +552,7 @@ mod tests { while !used_locktime || !used_sequence { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = Selection::new(vec![input.clone()], vec![output]); + let selection = TxTemplate::from_parts(vec![input.clone()], vec![output]); let psbt = selection.create_psbt(PsbtParams { anti_fee_sniping: Some(tip), @@ -601,7 +601,7 @@ mod tests { let mut loops = 0; while !used_locktime || !used_sequence { - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input1.clone(), input2.clone(), input3.clone()], vec![output.clone()], ); @@ -643,7 +643,7 @@ mod tests { // Tip is well below the input's CLTV requirement. let tip = absolute::Height::from_consensus(50_000)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -716,7 +716,7 @@ mod tests { let mut observed_sequence_path = false; for _ in 0..100 { - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![regular_input.clone(), csv_input.clone()], vec![output.clone()], ); @@ -763,7 +763,7 @@ mod tests { let (input, desc) = setup_cltv_input(time_locktime)?; let tip = absolute::Height::from_consensus(800_000)?; - let selection = Selection::new( + let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -857,7 +857,7 @@ mod tests { fn run_sighash_case(input: Input, params: PsbtParams) -> anyhow::Result { 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]); Ok(selection.create_psbt(params)?) } From 351b086dfb72169fb1d1db4f2dc887829f0e7cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 20 May 2026 05:20:46 +0000 Subject: [PATCH 10/24] feat(tx-template)!: route tx-shape decisions through TxTemplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #57. TxTemplate now owns the resolved tx-shape fields and the methods that mutate them. The selector hands you a TxTemplate already configured with sensible defaults; everything else is method calls on it. New fields on TxTemplate: - version (default V2) - lock_time (= max(input CLTV) or ZERO) - fallback_sequence (default ENABLE_RBF_NO_LOCKTIME) New setters with validation: - set_version -> SetVersionError::RelativeTimelockRequiresV2 - set_locktime -> SetLockTimeError::{BelowInputCltv, UnitMismatch} - set_fallback_sequence The PSBT/AFS pipeline is restructured around these fields: - PsbtParams -> PsbtBuildParams (PSBT-only knobs; version/locktime /AFS removed) - CreatePsbtError -> BuildPsbtError - create_psbt(params) -> (Psbt, Finalizer) (was just Psbt) - anti-fee-sniping moves off PsbtParams::anti_fee_sniping into TxTemplate::apply_anti_fee_sniping(tip, &mut rng), a separate chainable step that composes the public set_locktime / Input::set_sequence - to_unsigned_tx() materializes the tx for non-PSBT signing flows Chain ergonomics: sort_inputs_by / shuffle_inputs (etc.) now consume self and return Self. into_finalizer is dropped — Finalizer comes from create_psbt or from Finalizer::new for callers that want it standalone. What was previously silent is now an explicit error: - min_locktime of the wrong unit was silently ignored - min_locktime below an input's CLTV was silently clamped up Both now error via SetLockTimeError. Setting v < 2 with a relative- timelock input errors via SetVersionError. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/anti_fee_sniping.rs | 9 +- examples/synopsis.rs | 13 +- src/afs.rs | 77 ++-- src/finalizer.rs | 38 +- src/input.rs | 2 +- src/input_candidates.rs | 8 +- src/selector.rs | 4 +- src/tx_template.rs | 849 +++++++++++++++++++++-------------- 8 files changed, 572 insertions(+), 428 deletions(-) diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 1c0a57e3..038ceb2a 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, SelectorParams, }; use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate}; @@ -90,10 +90,9 @@ fn main() -> anyhow::Result<()> { let selection_inputs = selection.inputs().to_vec(); - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip_height), - ..Default::default() - })?; + let (psbt, _) = selection + .apply_anti_fee_sniping(tip_height, &mut rand::thread_rng())? + .create_psbt(PsbtBuildParams::default())?; let tx = psbt.unsigned_tx; diff --git a/examples/synopsis.rs b/examples/synopsis.rs index c2685e8c..ae24a7b2 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -1,6 +1,6 @@ use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, SelectorParams, Signer, }; use bitcoin::{key::Secp256k1, Amount, FeeRate}; @@ -48,7 +48,7 @@ fn main() -> anyhow::Result<()> { .assume_checked(); // Okay now create tx. - let selection = wallet + let (mut psbt, finalizer) = wallet .all_candidates() .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_mtp))) @@ -66,10 +66,8 @@ fn main() -> anyhow::Result<()> { bdk_tx::ChangeScript::from_descriptor(internal.at_derivation_index(0)?), ) }, - )?; - - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + )? + .create_psbt(PsbtBuildParams::default())?; let _ = psbt.sign(&signer, &secp); let res = finalizer.finalize(&mut psbt); @@ -146,7 +144,6 @@ fn main() -> anyhow::Result<()> { }, )?; - let mut psbt = selection.create_psbt(PsbtParams::default())?; println!( "selected inputs: {:?}", selection @@ -156,7 +153,7 @@ fn main() -> anyhow::Result<()> { .collect::>() ); - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; psbt.sign(&signer, &secp).expect("failed to sign"); assert!( finalizer.finalize(&mut psbt).is_finalized(), diff --git a/src/afs.rs b/src/afs.rs index 9fd2bb03..427ba6ed 100644 --- a/src/afs.rs +++ b/src/afs.rs @@ -1,12 +1,12 @@ use crate::{ no_std_rand::{random_probability, random_range}, - Input, + TxTemplate, }; use alloc::vec::Vec; use miniscript::bitcoin::{ absolute::{self, LockTime}, transaction::Version, - Sequence, Transaction, + Sequence, }; use rand_core::RngCore; @@ -16,7 +16,7 @@ pub enum AntiFeeSnipingError { /// Transaction `version` must be >= 2 for AFS to use relative locktimes. UnsupportedVersion(Version), /// AFS only supports height-based locktimes. The transaction's locktime is - /// time-based (MTP), which can originate from either `PsbtParams::min_locktime` + /// time-based (MTP), which can originate from either `TxTemplateParams::min_locktime` /// or an input's time-based CLTV requirement. UnsupportedLockTime(absolute::LockTime), } @@ -73,79 +73,78 @@ impl std::error::Error for AntiFeeSnipingError {} /// # Errors /// - [`AntiFeeSnipingError::UnsupportedVersion`] if `tx.version < 2`. /// - [`AntiFeeSnipingError::UnsupportedLockTime`] if `tx.lock_time` is time-based -/// (either from `PsbtParams::min_locktime` or an input's time-based CLTV). +/// (either from `TxTemplateParams::min_locktime` or an input's time-based CLTV). /// /// # See Also /// [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki) pub(crate) fn apply_anti_fee_sniping( - tx: &mut Transaction, - inputs: &[Input], + mut template: TxTemplate, tip_height: absolute::Height, rng: &mut impl RngCore, -) -> Result<(), AntiFeeSnipingError> { +) -> Result { const MAX_RELATIVE_HEIGHT: u32 = 65_535; const FIFTY_PERCENT_PROBABILITY_RANGE: u32 = 2; const MIN_SEQUENCE_VALUE: u32 = 1; const TEN_PERCENT_PROBABILITY_RANGE: u32 = 10; const MAX_RANDOM_OFFSET: u32 = 100; - if tx.version < Version::TWO { - return Err(AntiFeeSnipingError::UnsupportedVersion(tx.version)); + if template.version() < Version::TWO { + return Err(AntiFeeSnipingError::UnsupportedVersion(template.version())); } - if !tx.lock_time.is_block_height() { - return Err(AntiFeeSnipingError::UnsupportedLockTime(tx.lock_time)); + if !template.lock_time().is_block_height() { + return Err(AntiFeeSnipingError::UnsupportedLockTime( + template.lock_time(), + )); } - let rbf_enabled = tx.is_explicitly_rbf(); + // A tx signals RBF if at least one input has `nSequence < 0xfffffffe`. + let fallback = template.fallback_sequence(); + let rbf_enabled = template + .inputs() + .iter() + .any(|input| input.sequence().unwrap_or(fallback).is_rbf()); - // vector of input_index and associated Input ref. - let taproot_inputs: Vec<(usize, &Input)> = tx - .input + // Indices of taproot inputs without a relative timelock — candidates for the nSequence path. + let taproot_inputs: Vec = template + .inputs() .iter() .enumerate() - .filter_map(|(vin, txin)| { - let input = inputs - .iter() - .find(|input| input.prev_outpoint() == txin.previous_output)?; - if input.prev_txout().script_pubkey.is_p2tr() && input.relative_timelock().is_none() { - Some((vin, input)) - } else { - None - } + .filter_map(|(i, input)| { + (input.prev_txout().script_pubkey.is_p2tr() && input.relative_timelock().is_none()) + .then_some(i) }) .collect(); // Conditions that force nLockTime (vs nSequence). let must_use_locktime = taproot_inputs.is_empty() - || inputs.iter().any(|input| { + || template.inputs().iter().any(|input| { let confirmation = input.confirmations(tip_height); confirmation == 0 || confirmation > MAX_RELATIVE_HEIGHT }); let use_locktime = !rbf_enabled || must_use_locktime - || taproot_inputs.is_empty() || random_probability(rng, FIFTY_PERCENT_PROBABILITY_RANGE); if use_locktime { - // Use nLockTime let mut afs_height = tip_height.to_consensus_u32(); - if random_probability(rng, TEN_PERCENT_PROBABILITY_RANGE) { let random_offset = random_range(rng, MAX_RANDOM_OFFSET); afs_height = afs_height.saturating_sub(random_offset); } - let afs_locktime = LockTime::from_height(afs_height).expect("must be valid Height"); - if tx.lock_time.is_implied_by(afs_locktime) { - tx.lock_time = afs_locktime; + // Only apply if it's a bump (i.e. doesn't regress an input's CLTV requirement). + if template.lock_time().is_implied_by(afs_locktime) { + template = template + .set_locktime(afs_locktime) + .expect("AFS picks a value ≥ current lock_time (same height-based unit)"); } } else { - // Use Sequence - let random_index = random_range(rng, taproot_inputs.len() as u32); - let (input_index, input) = taproot_inputs[random_index as usize]; - let confirmation = input.confirmations(tip_height); + let random_index = random_range(rng, taproot_inputs.len() as u32) as usize; + let input_index = taproot_inputs[random_index]; + let outpoint = template.inputs()[input_index].prev_outpoint(); + let confirmation = template.inputs()[input_index].confirmations(tip_height); let mut sequence_value = confirmation; if random_probability(rng, TEN_PERCENT_PROBABILITY_RANGE) { @@ -155,8 +154,12 @@ pub(crate) fn apply_anti_fee_sniping( .max(MIN_SEQUENCE_VALUE); } - tx.input[input_index].sequence = Sequence(sequence_value); + template + .input_mut(outpoint) + .expect("taproot input index resolved above") + .set_sequence(Sequence(sequence_value)) + .expect("AFS only picks inputs without timelock constraints"); } - Ok(()) + Ok(template) } diff --git a/src/finalizer.rs b/src/finalizer.rs index 6f2f64ce..3af65f01 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -17,29 +17,26 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// /// # Usage /// -/// Construct a [`Finalizer`] from a list of `(outpoint, plan)` pairs, or by calling -/// [`into_finalizer`] on a particular [`TxTemplate`]. Use [`finalize_input`] to finalize a single -/// input, or [`finalize`] to finalize every input and return a map containing the result of -/// finalization at each index. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata -/// from non-essential fields of the PSBT inputs and outputs, ensuring that only the necessary -/// information remains for transaction extraction. +/// A [`Finalizer`] is typically obtained alongside a PSBT from [`TxTemplate::create_psbt`]. +/// It can also be constructed directly from a list of `(outpoint, plan)` pairs via +/// [`Finalizer::new`]. Use [`finalize_input`] to finalize a single input, or [`finalize`] to +/// finalize every input. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata from +/// non-essential fields of the PSBT inputs and outputs. /// /// # Example /// /// ```rust,no_run -/// # use bdk_tx::PsbtParams; +/// # use bdk_tx::PsbtBuildParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); /// # let keymap = std::collections::BTreeMap::new(); /// # let template: bdk_tx::TxTemplate = unimplemented!(); -/// // Create PSBT from a selection of inputs and outputs. -/// let mut psbt = selection.create_psbt(PsbtParams::default())?; +/// let (mut psbt, finalizer) = template.create_psbt(PsbtBuildParams::default())?; /// /// // Sign the PSBT using your preferred method. /// let signer = bdk_tx::Signer(keymap); /// let _ = psbt.sign(&signer, &secp); /// /// // Finalize the PSBT. -/// let finalizer = selection.into_finalizer(); /// let finalize_map = finalizer.finalize(&mut psbt); /// assert!(finalize_map.is_finalized()); /// @@ -50,7 +47,7 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// /// [BIP174]: /// [`TxTemplate`]: crate::TxTemplate -/// [`into_finalizer`]: crate::TxTemplate::into_finalizer +/// [`TxTemplate::create_psbt`]: crate::TxTemplate::create_psbt /// [`Plan`]: miniscript::plan::Plan /// [`Transaction`]: bitcoin::Transaction /// [`finalize_input`]: Finalizer::finalize_input @@ -303,7 +300,7 @@ impl core::error::Error for FinalizeError {} #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { - use crate::{FinalizeError, Finalizer, Output, PsbtParams, Signer, TxTemplate}; + use crate::{FinalizeError, Finalizer, Output, PsbtBuildParams, Signer, TxTemplate}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{absolute, transaction, Amount, ScriptBuf, TapSighashType, TxIn, TxOut}; use miniscript::bitcoin; @@ -357,8 +354,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -377,8 +373,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -406,8 +401,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; assert!(!psbt.outputs[0].tap_key_origins.is_empty()); assert!(psbt.outputs[0].tap_internal_key.is_some()); @@ -461,7 +455,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; + let (mut psbt, _) = selection.create_psbt(PsbtBuildParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -501,8 +495,7 @@ mod tests { ], ); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -529,8 +522,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); diff --git a/src/input.rs b/src/input.rs index c50358d0..6f6b33df 100644 --- a/src/input.rs +++ b/src/input.rs @@ -668,7 +668,7 @@ impl Input { /// Mutable handle to an [`Input`] held inside a [`TxTemplate`]. /// /// Returned by [`TxTemplate::input_mut`] and [`TxTemplate::inputs_mut`]. This wrapper restricts -/// mutation to operations that preserve [`TxTemplate`]'s coin-selection invariants. +/// mutation to operations that preserve the template's invariants. /// /// Read-only access to the underlying [`Input`] is available via [`Deref`]. /// diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 75f90851..53f4e5a1 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -191,8 +191,7 @@ impl InputCandidates { self } - /// Attempt to convert the input candidates into a valid [`TxTemplate`] with a given - /// `algorithm` and selector `params`. + /// Run coin selection with `algorithm` and selector `params`, returning a [`TxTemplate`]. pub fn into_tx_template( self, algorithm: A, @@ -205,10 +204,9 @@ impl InputCandidates { selector .select_with_algorithm(algorithm) .map_err(IntoTxTemplateError::SelectionAlgorithm)?; - let selection = selector + selector .try_finalize() - .ok_or(IntoTxTemplateError::CannotMeetTarget(CannotMeetTarget))?; - Ok(selection) + .ok_or(IntoTxTemplateError::CannotMeetTarget(CannotMeetTarget)) } } diff --git a/src/selector.rs b/src/selector.rs index ef4e3ff6..a5324301 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -483,9 +483,9 @@ impl<'c> Selector<'c> { Some(has_drain) } - /// Try get final selection. + /// Try to finalize the selection into a [`TxTemplate`]. /// - /// Return `None` if target is not met yet. + /// Returns `None` if the target is not yet met. pub fn try_finalize(&self) -> Option { if !self.inner.is_target_met(self.target) { return None; diff --git a/src/tx_template.rs b/src/tx_template.rs index 10ae1fe6..801ae5a9 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -1,10 +1,21 @@ +//! Tx-shaping stage between coin selection and the final [`Psbt`] or [`Transaction`]. +//! +//! A [`TxTemplate`] is obtained from [`Selector::try_finalize`] or +//! [`InputCandidates::into_tx_template`], then mutated (sort, shuffle, anti-fee-sniping, +//! set_version, set_locktime, set_fallback_sequence, per-input sequence overrides) before +//! being emitted as a PSBT or a [`Transaction`]. +//! +//! [`Selector::try_finalize`]: crate::Selector::try_finalize +//! [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template +//! [`Transaction`]: bitcoin::Transaction + use alloc::boxed::Box; use alloc::vec::Vec; use bitcoin::{EcdsaSighashType, TapSighashType}; use core::cmp::Ordering; use core::fmt::{Debug, Display}; use miniscript::bitcoin; -use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence}; +use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence, Transaction, TxIn}; use miniscript::psbt::PsbtExt; use rand_core::RngCore; @@ -13,80 +24,124 @@ use crate::{ Output, }; -/// Final selection of inputs and outputs. +/// Default `nSequence` for plan-based inputs that don't specify their own. +/// +/// Matches Bitcoin Core's wallet default (`0xfffffffd`, +/// [`Sequence::ENABLE_RBF_NO_LOCKTIME`]). +pub const FALLBACK_SEQUENCE: Sequence = Sequence::ENABLE_RBF_NO_LOCKTIME; + +/// Error returned by [`TxTemplate::set_version`]. +#[derive(Debug, Clone, PartialEq)] +pub enum SetVersionError { + /// A relative-timelock input requires `version >= 2` (BIP-68). + RelativeTimelockRequiresV2 { + /// The version the caller attempted to set. + attempted: transaction::Version, + }, +} + +impl Display for SetVersionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::RelativeTimelockRequiresV2 { attempted } => write!( + f, + "version {attempted} is invalid: an input has a relative timelock, which requires version >= 2 (BIP-68)", + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SetVersionError {} + +/// Error returned by [`TxTemplate::set_locktime`]. +#[derive(Debug, Clone, PartialEq)] +pub enum SetLockTimeError { + /// The provided lock_time is below an input's required CLTV. + BelowInputCltv { + /// CLTV required by an input. + required: absolute::LockTime, + /// LockTime the caller attempted to set. + attempted: absolute::LockTime, + }, + /// The provided lock_time uses a different unit (block-height vs. time) than an input's CLTV. + UnitMismatch { + /// CLTV required by an input. + input: absolute::LockTime, + /// LockTime the caller attempted to set. + attempted: absolute::LockTime, + }, +} + +impl Display for SetLockTimeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::BelowInputCltv { + required, + attempted, + } => write!( + f, + "lock_time {attempted} is below an input's required CLTV {required}", + ), + Self::UnitMismatch { input, attempted } => write!( + f, + "lock_time {attempted} has a different unit than an input's CLTV {input}", + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SetLockTimeError {} + +/// A fully-resolved tx shape — the workspace between coin selection and the final [`Psbt`] +/// or [`Transaction`]. +/// +/// Typically obtained from [`Selector::try_finalize`] (or +/// [`InputCandidates::into_tx_template`]). Exposes the operations that *shape* the resulting +/// transaction: input/output ordering, anti-fee-sniping, version/locktime overrides, and +/// final emission to PSBT or [`Transaction`]. +/// +/// New templates start with `version = TWO`, `lock_time = max(input CLTVs)` (or `ZERO`), and +/// `fallback_sequence = ENABLE_RBF_NO_LOCKTIME`. +/// +/// [`Selector::try_finalize`]: crate::Selector::try_finalize +/// [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template #[derive(Debug, Clone)] #[must_use] pub struct TxTemplate { + version: transaction::Version, + lock_time: absolute::LockTime, + fallback_sequence: Sequence, inputs: Vec, outputs: Vec, } -/// Parameters for creating a psbt. +/// Parameters for emitting a [`Psbt`] from a [`TxTemplate`]. +/// +/// Carries only PSBT-specific options. Transaction-shape decisions (version, locktime, +/// sequence, anti-fee-sniping, input/output ordering) all live on [`TxTemplate`]. #[derive(Debug, Clone)] -pub struct PsbtParams { - /// Use a specific [`transaction::Version`]. - pub version: transaction::Version, - - /// Minimum tx locktime — a floor on the resulting `tx.lock_time`. - /// - /// The final `tx.lock_time` is the maximum of this value and any absolute locktime required by - /// an input's CLTV, provided the locktime units agree. If `min_locktime` uses a different unit - /// (block-height vs. time) than an input's CLTV, it is ignored — a height-based `min_locktime` - /// will not be combined with a time-based CLTV (and vice versa). - pub min_locktime: absolute::LockTime, - - /// Whether to require the full tx, aka [`non_witness_utxo`] for segwit v0 inputs. +pub struct PsbtBuildParams { + /// Whether to require the full tx (aka [`non_witness_utxo`]) for segwit v0 inputs. /// - /// Default is `true`. + /// Default: `true`. /// /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo pub mandate_full_tx_for_segwit_v0: bool, - - /// Apply BIP-326 anti-fee-sniping (AFS) protection, using the given block height. - /// - /// * `None` (default) — no AFS is applied. - /// * `Some(tip_height)` — AFS is applied with `tip_height` as the current chain tip. - /// - /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining the - /// transaction to only be valid at or after the chain tip. When enabled, - /// [`TxTemplate::create_psbt`] sets either the transaction's `nLockTime` or the `nSequence` of - /// one Taproot input to a value derived from `tip_height`. - /// - /// AFS only operates on a height-based `tx.lock_time`. If [`min_locktime`] or any input's - /// CLTV is time-based, enabling AFS produces [`AntiFeeSnipingError::UnsupportedLockTime`]. - /// - /// If `tx.lock_time` is already a block height greater than `tip_height` (e.g., because an - /// input's CLTV pins the tx to a future block), AFS leaves the transaction unchanged — the - /// existing CLTV already provides equivalent protection. - /// - /// # Errors - /// - /// When `Some(..)`, [`TxTemplate::create_psbt`] returns [`CreatePsbtError::AntiFeeSniping`] if: - /// - the transaction version is less than 2 - /// ([`AntiFeeSnipingError::UnsupportedVersion`]) — v2 is required for relative locktimes; or - /// - a time-based (MTP) locktime is in effect - /// ([`AntiFeeSnipingError::UnsupportedLockTime`]) — AFS only supports height-based locktimes. - /// - /// See [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki) for more details. - /// - /// [`min_locktime`]: Self::min_locktime - pub anti_fee_sniping: Option, } -impl Default for PsbtParams { +impl Default for PsbtBuildParams { fn default() -> Self { Self { - version: transaction::Version::TWO, - min_locktime: absolute::LockTime::ZERO, mandate_full_tx_for_segwit_v0: true, - anti_fee_sniping: None, } } } -/// Occurs when creating a psbt fails. +/// Error returned by [`TxTemplate::create_psbt`]. #[derive(Debug)] -pub enum CreatePsbtError { +pub enum BuildPsbtError { /// Missing tx for legacy input. MissingFullTxForLegacyInput(Box), /// Missing tx for segwit v0 input. @@ -95,61 +150,138 @@ pub enum CreatePsbtError { Psbt(bitcoin::psbt::Error), /// Update psbt output with descriptor error. OutputUpdate(miniscript::psbt::OutputUpdateError), - /// Occurs when applying anti-fee-sniping fails. - AntiFeeSniping(AntiFeeSnipingError), } -impl From for CreatePsbtError { - fn from(e: AntiFeeSnipingError) -> Self { - Self::AntiFeeSniping(e) - } -} - -impl core::fmt::Display for CreatePsbtError { +impl Display for BuildPsbtError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - CreatePsbtError::MissingFullTxForLegacyInput(input) => write!( + Self::MissingFullTxForLegacyInput(input) => write!( f, "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", input.prev_outpoint() ), - CreatePsbtError::MissingFullTxForSegwitV0Input(input) => write!( + Self::MissingFullTxForSegwitV0Input(input) => write!( f, "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", input.prev_outpoint() ), - CreatePsbtError::Psbt(error) => Display::fmt(&error, f), - CreatePsbtError::OutputUpdate(output_update_error) => { - Display::fmt(&output_update_error, f) - } - CreatePsbtError::AntiFeeSniping(e) => Display::fmt(e, f), + Self::Psbt(e) => Display::fmt(e, f), + Self::OutputUpdate(e) => Display::fmt(e, f), } } } #[cfg(feature = "std")] -impl std::error::Error for CreatePsbtError {} +impl std::error::Error for BuildPsbtError {} impl TxTemplate { pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { - Self { inputs, outputs } + let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); + Self { + version: transaction::Version::TWO, + lock_time, + fallback_sequence: FALLBACK_SEQUENCE, + inputs, + outputs, + } + } + + /// Resolved transaction version. + pub fn version(&self) -> transaction::Version { + self.version + } + + /// Override `tx.version`. + /// + /// Default is [`transaction::Version::TWO`]. Setting a different value is allowed only when + /// no input has a relative timelock (BIP-68 requires v2 in that case). + /// + /// # Errors + /// + /// - [`SetVersionError::RelativeTimelockRequiresV2`] if `version < 2` and any input has a + /// relative timelock. + pub fn set_version(mut self, version: transaction::Version) -> Result { + if version < transaction::Version::TWO + && self.inputs.iter().any(|i| i.relative_timelock().is_some()) + { + return Err(SetVersionError::RelativeTimelockRequiresV2 { attempted: version }); + } + self.version = version; + Ok(self) + } + + /// Resolved transaction lock_time. + pub fn lock_time(&self) -> absolute::LockTime { + self.lock_time + } + + /// Set the fallback `nSequence` used for inputs that don't specify their own. + /// + /// The fallback is applied lazily at materialization (in [`Self::to_unsigned_tx`] and + /// [`Self::create_psbt`]); calling this method after other transformations does not + /// retroactively change inputs whose sequence has already been set explicitly (e.g. by + /// [`apply_anti_fee_sniping`](Self::apply_anti_fee_sniping)). + pub fn set_fallback_sequence(mut self, sequence: Sequence) -> Self { + self.fallback_sequence = sequence; + self + } + + /// Override `tx.lock_time`. + /// + /// Returns an error if `lock_time` would conflict with an input's required CLTV + /// (either by being below the requirement, or by using a different unit). + /// + /// Setting `lock_time` to a non-zero value when *every* input has `nSequence::MAX` + /// is *not* rejected, but per BIP-65 / Bitcoin's `IsFinalTx` rule the lock_time will + /// then be ignored at validation time. + /// + /// # Errors + /// + /// - [`SetLockTimeError::BelowInputCltv`] if `lock_time < required` (same unit). + /// - [`SetLockTimeError::UnitMismatch`] if `lock_time` is height-based and an input's + /// CLTV is time-based (or vice versa). + pub fn set_locktime(mut self, lock_time: absolute::LockTime) -> Result { + for input in &self.inputs { + let Some(required) = input.absolute_timelock() else { + continue; + }; + if !required.is_same_unit(lock_time) { + return Err(SetLockTimeError::UnitMismatch { + input: required, + attempted: lock_time, + }); + } + if !required.is_implied_by(lock_time) { + return Err(SetLockTimeError::BelowInputCltv { + required, + attempted: lock_time, + }); + } + } + self.lock_time = lock_time; + Ok(self) + } + + /// Fallback `nSequence` applied to inputs that don't specify their own. + pub fn fallback_sequence(&self) -> Sequence { + self.fallback_sequence } - /// Inputs in this selection. + /// Inputs in this template. pub fn inputs(&self) -> &[Input] { &self.inputs } - /// Outputs in this selection. + /// Outputs in this template. pub fn outputs(&self) -> &[Output] { &self.outputs } /// Mutable handle to the input spending `outpoint`, if any. /// - /// Returns [`None`] if no input in this selection spends `outpoint`. The returned - /// [`InputMut`] only permits mutations that preserve the selection's coin-selection - /// invariants — see [`InputMut`] for the available operations. + /// Returns [`None`] if no input in this template spends `outpoint`. The returned + /// [`InputMut`] only permits mutations that preserve the template's invariants — see + /// [`InputMut`] for the available operations. pub fn input_mut(&mut self, outpoint: OutPoint) -> Option> { self.inputs .iter_mut() @@ -157,124 +289,115 @@ impl TxTemplate { .map(InputMut::new) } - /// Iterator yielding a mutable handle to every input in this selection. + /// Iterator yielding a mutable handle to every input in this template. /// - /// Each yielded [`InputMut`] only permits mutations that preserve the selection's - /// coin-selection invariants — see [`InputMut`] for the available operations. + /// Each yielded [`InputMut`] only permits mutations that preserve the template's + /// invariants — see [`InputMut`] for the available operations. pub fn inputs_mut(&mut self) -> impl Iterator> { self.inputs.iter_mut().map(InputMut::new) } - /// Reorder inputs in-place using `compare`. + /// Reorder inputs using `compare`. Uses a stable sort. /// - /// Uses a stable sort: inputs that compare equal retain their relative order. /// Typical use is BIP-69 lexicographic ordering by previous outpoint. - pub fn sort_inputs_by(&mut self, compare: F) + pub fn sort_inputs_by(mut self, compare: F) -> Self where F: FnMut(&Input, &Input) -> Ordering, { self.inputs.sort_by(compare); + self } - /// Randomly shuffle inputs in-place using `rng`. + /// Randomly shuffle inputs using `rng`. /// /// Useful for chain-analysis resistance when no deterministic ordering is required. - pub fn shuffle_inputs(&mut self, rng: &mut R) { + pub fn shuffle_inputs(mut self, rng: &mut R) -> Self { fisher_yates_shuffle(&mut self.inputs, rng); + self } - /// Reorder outputs in-place using `compare`. + /// Reorder outputs using `compare`. Uses a stable sort. /// - /// Uses a stable sort: outputs that compare equal retain their relative order. /// Typical use is BIP-69 (ascending by amount, then by `script_pubkey`). - pub fn sort_outputs_by(&mut self, compare: F) + pub fn sort_outputs_by(mut self, compare: F) -> Self where F: FnMut(&Output, &Output) -> Ordering, { self.outputs.sort_by(compare); + self } - /// Randomly shuffle outputs in-place using `rng`. + /// Randomly shuffle outputs using `rng`. /// - /// Useful for chain-analysis resistance — in particular, hiding which output - /// is the change. - pub fn shuffle_outputs(&mut self, rng: &mut R) { + /// Useful for chain-analysis resistance — in particular, hiding which output is the + /// change. + pub fn shuffle_outputs(mut self, rng: &mut R) -> Self { fisher_yates_shuffle(&mut self.outputs, rng); + self } - /// Accumulates the maximum locktime from an iterator of input-required locktimes. - /// - /// Returns `min_locktime` if the locktimes iterator is empty, otherwise the maximum locktime - /// across the inputs (with `min_locktime` only applied when compatible with the inputs' unit). - /// - /// # Panics + /// Materialize the unsigned `bitcoin::Transaction` represented by this template. /// - /// In debug builds, panics if `locktimes` contains values with different units (height vs. - /// time). `Selector::new` rejects such candidates upstream, so this should never fire in - /// practice. - fn accumulate_max_locktime( - locktimes: impl IntoIterator, - min_locktime: absolute::LockTime, - ) -> absolute::LockTime { - // Accumulate locktimes required by inputs. An input-vs-input unit mismatch is rejected - // upstream by `Selector::new`. `min_locktime` is only used when it is compatible with - // the input requirements; a different unit is intentionally ignored so that, e.g., a - // height-based `min_locktime` does not conflict with a time-based CLTV requirement. - let inputs_max = locktimes.into_iter().reduce(|a, b| { - debug_assert!( - a.is_same_unit(b), - "Selector::new should reject mixed-unit candidates", - ); - if a.is_implied_by(b) { - b - } else { - a - } - }); - match inputs_max { - Some(lt) if lt.is_implied_by(min_locktime) => min_locktime, - Some(lt) => lt, - None => min_locktime, - } - } - - /// Create PSBT. - #[cfg(feature = "std")] - pub fn create_psbt(&self, params: PsbtParams) -> Result { - self.create_psbt_with_rng(params, &mut rand::thread_rng()) - } - - /// Create PSBT with `rng`. - pub fn create_psbt_with_rng( - &self, - params: PsbtParams, - rng: &mut impl RngCore, - ) -> Result { - let mut tx = bitcoin::Transaction { - version: params.version, - lock_time: Self::accumulate_max_locktime( - self.inputs - .iter() - .filter_map(|input| input.absolute_timelock()), - params.min_locktime, - ), + /// Each input's `nSequence` is its own [`Input::sequence`] if set, otherwise + /// [`fallback_sequence`](Self::fallback_sequence). + pub fn to_unsigned_tx(&self) -> Transaction { + Transaction { + version: self.version, + lock_time: self.lock_time, input: self .inputs .iter() - .map(|input| bitcoin::TxIn { + .map(|input| TxIn { previous_output: input.prev_outpoint(), - sequence: input.sequence().unwrap_or(Sequence::ENABLE_RBF_NO_LOCKTIME), + sequence: input.sequence().unwrap_or(self.fallback_sequence), ..Default::default() }) .collect(), - output: self.outputs.iter().map(|output| output.txout()).collect(), - }; + output: self.outputs.iter().map(Output::txout).collect(), + } + } - if let Some(tip_height) = params.anti_fee_sniping { - apply_anti_fee_sniping(&mut tx, &self.inputs, tip_height, rng)?; - }; + /// Apply BIP-326 anti-fee-sniping (AFS) protection using `tip_height` as the chain tip. + /// + /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining + /// the transaction to only be valid at or after the chain tip. This sets either + /// `tx.lock_time` (via [`set_locktime`](Self::set_locktime)) or the `nSequence` of one + /// Taproot input (via [`Input::set_sequence`]). + /// + /// AFS only operates on a height-based `tx.lock_time`. If any input's CLTV is time-based, + /// this returns [`AntiFeeSnipingError::UnsupportedLockTime`]. + /// + /// If `tx.lock_time` is already a block height greater than `tip_height` (e.g., because an + /// input's CLTV pins the tx to a future block), this leaves the template unchanged. + /// + /// See [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki). + /// + /// # Errors + /// + /// - [`AntiFeeSnipingError::UnsupportedVersion`] if `version < 2`. + /// - [`AntiFeeSnipingError::UnsupportedLockTime`] if `lock_time` is time-based. + pub fn apply_anti_fee_sniping( + self, + tip_height: absolute::Height, + rng: &mut R, + ) -> Result { + apply_anti_fee_sniping(self, tip_height, rng) + } + + /// Build the [`Psbt`] and its associated [`Finalizer`]. + #[cfg(feature = "std")] + pub fn create_psbt(self, params: PsbtBuildParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { + self.create_psbt_with_rng(params, &mut rand::thread_rng()) + } - let mut psbt = Psbt::from_unsigned_tx(tx).map_err(CreatePsbtError::Psbt)?; + /// Build the [`Psbt`] and its associated [`Finalizer`] with a custom `rng`. + pub fn create_psbt_with_rng( + self, + params: PsbtBuildParams, + _rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), BuildPsbtError> { + let tx = self.to_unsigned_tx(); + let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) { if let Some(finalized_psbt_input) = plan_input.psbt_input() { @@ -288,20 +411,17 @@ impl TxTemplate { if witness_version.is_some() { psbt_input.witness_utxo = Some(plan_input.prev_txout().clone()); } - // We are allowed to have full tx for segwit inputs. Might as well include it. - // If the caller does not wish to include the full tx in Segwit V0 inputs, they should not - // include it in `crate::Input`. psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); if psbt_input.non_witness_utxo.is_none() { if witness_version.is_none() { - return Err(CreatePsbtError::MissingFullTxForLegacyInput(Box::new( + return Err(BuildPsbtError::MissingFullTxForLegacyInput(Box::new( plan_input.clone(), ))); } if params.mandate_full_tx_for_segwit_v0 && witness_version == Some(bitcoin::WitnessVersion::V0) { - return Err(CreatePsbtError::MissingFullTxForSegwitV0Input(Box::new( + return Err(BuildPsbtError::MissingFullTxForSegwitV0Input(Box::new( plan_input.clone(), ))); } @@ -330,26 +450,47 @@ impl TxTemplate { } unreachable!("input candidate must either have finalized psbt input or plan"); } + for (output_index, output) in self.outputs.iter().enumerate() { if let Some(desc) = output.descriptor() { psbt.update_output_with_descriptor(output_index, desc) - .map_err(CreatePsbtError::OutputUpdate)?; + .map_err(BuildPsbtError::OutputUpdate)?; } } - Ok(psbt) - } + let finalizer = Finalizer::new(self.inputs.into_iter().filter_map(|input| { + let outpoint = input.prev_outpoint(); + let plan = input.plan().cloned()?; + Some((outpoint, plan)) + })); - /// Into psbt finalizer. - pub fn into_finalizer(self) -> Finalizer { - Finalizer::new( - self.inputs - .iter() - .filter_map(|input| Some((input.prev_outpoint(), input.plan().cloned()?))), - ) + Ok((psbt, finalizer)) } } +/// Maximum CLTV requirement across `inputs`, or `None` if no input has a CLTV. +/// +/// # Panics +/// +/// In debug builds, panics if inputs have CLTVs of different units (height vs. time). +/// `Selector::new` rejects such candidates upstream, so this should never fire in practice. +fn max_input_cltv(inputs: &[Input]) -> Option { + inputs + .iter() + .filter_map(Input::absolute_timelock) + .reduce(|a, b| { + debug_assert!( + a.is_same_unit(b), + "Selector::new should reject mixed-unit candidates", + ); + if a.is_implied_by(b) { + b + } else { + a + } + }) +} + #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { @@ -358,10 +499,11 @@ mod tests { absolute::{self, LockTime, Time}, relative, secp256k1::Secp256k1, - transaction::{self, Version}, - Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, + transaction::Version, + Amount, ScriptBuf, Transaction, TxIn, TxOut, }; use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + use rand::thread_rng; use rand_core::OsRng; const TEST_KEY_HEX: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; @@ -382,7 +524,7 @@ mod tests { .plan(&Assets::new().add(desc_pk).after(cltv)) .unwrap(); let prev_tx = Transaction { - version: transaction::Version::TWO, + version: Version::TWO, lock_time: absolute::LockTime::ZERO, input: vec![TxIn::default()], output: vec![TxOut { @@ -394,12 +536,40 @@ mod tests { Ok((input, desc)) } - #[test] - fn test_min_locktime_height() -> anyhow::Result<()> { - let abs_locktime = absolute::LockTime::from_consensus(100_000); + fn setup_test_input(confirmation_height: u32) -> anyhow::Result { + let secp = Secp256k1::new(); + let desc = Descriptor::parse_descriptor(&secp, &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 (input, desc) = setup_cltv_input(abs_locktime)?; + let prev_tx = Transaction { + version: Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey, + value: Amount::from_sat(10_000), + }], + }; + let status = crate::ConfirmationStatus { + height: absolute::Height::from_consensus(confirmation_height)?, + prev_mtp: Some(Time::from_consensus(500_000_000)?), + }; + + Ok(Input::from_prev_tx(plan, prev_tx, 0, Some(status))?) + } + + /// Construction takes lock_time from the input CLTV; `set_locktime` bumps it above. + #[test] + fn set_locktime_height_above_input_cltv() -> anyhow::Result<()> { + let cltv = absolute::LockTime::from_consensus(100_000); + let (input, desc) = setup_cltv_input(cltv)?; let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( @@ -408,58 +578,49 @@ mod tests { )], ); - struct TestCase { - name: &'static str, - psbt_params: PsbtParams, - exp_locktime: u32, - } + let template = selection.clone(); + assert_eq!( + template.lock_time(), + cltv, + "construction takes lock_time from input CLTV" + ); - let cases = vec![ - TestCase { - name: "no min_locktime, use plan locktime", - psbt_params: PsbtParams::default(), - exp_locktime: 100_000, - }, - TestCase { - name: "larger min_locktime is used", - psbt_params: PsbtParams { - min_locktime: absolute::LockTime::from_consensus(100_100), - ..Default::default() - }, - exp_locktime: 100_100, - }, - TestCase { - name: "smaller min_locktime is ignored", - psbt_params: PsbtParams { - min_locktime: absolute::LockTime::from_consensus(99_900), - ..Default::default() - }, - exp_locktime: 100_000, - }, - ]; + let higher = absolute::LockTime::from_consensus(100_100); + let bumped = selection.set_locktime(higher)?; + assert_eq!(bumped.lock_time(), higher); - for test in cases { - let psbt = selection.create_psbt(test.psbt_params)?; - assert_eq!( - psbt.unsigned_tx.lock_time.to_consensus_u32(), - test.exp_locktime, - "Test failed {}", - test.name, - ); - } + Ok(()) + } + + /// `set_locktime` rejects values below an input's CLTV requirement. + #[test] + fn set_locktime_below_input_cltv_errors() -> anyhow::Result<()> { + let cltv = absolute::LockTime::from_consensus(100_000); + let (input, desc) = setup_cltv_input(cltv)?; + let selection = TxTemplate::from_parts( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); + let too_low = absolute::LockTime::from_consensus(99_999); + let result = selection.set_locktime(too_low); + + assert!(matches!( + result, + Err(SetLockTimeError::BelowInputCltv { required, attempted }) + if required == cltv && attempted == too_low + )); Ok(()) } - /// Tests that a height-based `min_locktime` is ignored when the input - /// requires a time-based (UNIX timestamp) CLTV, and that an explicit time-based - /// `min_locktime` greater than the requirement is respected. + /// A time-based input CLTV propagates to the template's lock_time at construction. #[test] - fn test_min_locktime_respects_lock_type() -> anyhow::Result<()> { + fn lock_time_takes_time_based_cltv_from_input() -> anyhow::Result<()> { let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); - let (input, desc) = setup_cltv_input(time_locktime)?; - let selection = TxTemplate::from_parts( vec![input], vec![Output::with_descriptor( @@ -468,75 +629,50 @@ mod tests { )], ); - // Default `min_locktime` is height 0 (block-height unit). It is incompatible with - // the time-based CLTV requirement, so it must be ignored. - let psbt = selection.create_psbt(PsbtParams::default())?; - assert_eq!( - psbt.unsigned_tx.lock_time, time_locktime, - "time-based CLTV requirement should be used; height-based `min_locktime` must be ignored", - ); - - // An explicit time-based `min_locktime` *greater* than the requirement should be respected. - let larger_time = absolute::LockTime::from_consensus(1_772_167_108); - assert!(larger_time > time_locktime); - let psbt = selection.create_psbt(PsbtParams { - min_locktime: larger_time, - ..Default::default() - })?; - assert_eq!( - psbt.unsigned_tx.lock_time, larger_time, - "a larger time-based `min_locktime` should override the CLTV requirement", - ); + assert_eq!(selection.lock_time(), time_locktime); Ok(()) } - pub fn setup_test_input(confirmation_height: u32) -> anyhow::Result { - let secp = Secp256k1::new(); - let desc = Descriptor::parse_descriptor(&secp, &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: transaction::Version::TWO, - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn::default()], - output: vec![TxOut { - script_pubkey, - value: Amount::from_sat(10_000), - }], - }; - - let status = crate::ConfirmationStatus { - height: absolute::Height::from_consensus(confirmation_height)?, - prev_mtp: Some(Time::from_consensus(500_000_000)?), - }; + /// `set_locktime` errors when the supplied unit conflicts with an input's CLTV unit. + #[test] + fn set_locktime_unit_mismatch_errors() -> anyhow::Result<()> { + let height_cltv = absolute::LockTime::from_consensus(100_000); + let (input, desc) = setup_cltv_input(height_cltv)?; + let selection = TxTemplate::from_parts( + vec![input], + vec![Output::with_descriptor( + desc.at_derivation_index(1)?, + Amount::from_sat(1000), + )], + ); - let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + let time_attempt = absolute::LockTime::from_consensus(1_734_230_218); + let result = selection.set_locktime(time_attempt); - Ok(input) + assert!(matches!( + result, + Err(SetLockTimeError::UnitMismatch { input, attempted }) + if input == height_cltv && attempted == time_attempt + )); + Ok(()) } + /// `set_locktime` propagates the chosen value through PSBT creation. #[test] - fn test_anti_fee_sniping_disabled() -> anyhow::Result<()> { + fn set_locktime_propagates_to_psbt() -> anyhow::Result<()> { let current_height = 2_500; - let input = setup_test_input(2_000).unwrap(); + let input = setup_test_input(2_000)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - // Disabled - default behavior is disable - let psbt = selection.create_psbt(PsbtParams { - min_locktime: absolute::LockTime::from_consensus(current_height), - ..Default::default() - })?; - let tx = psbt.unsigned_tx; - assert_eq!(tx.lock_time.to_consensus_u32(), current_height); - + let (psbt, _) = selection + .set_locktime(absolute::LockTime::from_consensus(current_height))? + .create_psbt(PsbtBuildParams::default())?; + assert_eq!( + psbt.unsigned_tx.lock_time.to_consensus_u32(), + current_height + ); Ok(()) } @@ -554,10 +690,9 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input.clone()], vec![output]); - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .create_psbt(PsbtBuildParams::default())?; let tx = psbt.unsigned_tx; @@ -566,19 +701,14 @@ mod tests { let locktime_value = tx.lock_time.to_consensus_u32(); let min_height = current_height.saturating_sub(100); assert!((min_height..=current_height).contains(&locktime_value)); - assert!(locktime_value <= current_height); - assert!(locktime_value >= current_height.saturating_sub(100)); } else { used_sequence = true; let sequence_value = tx.input[0].sequence.to_consensus_u32(); let confirmations = input.confirmations(absolute::Height::from_consensus(current_height).unwrap()); - let min_sequence = confirmations.saturating_sub(100); assert!((min_sequence..=confirmations).contains(&sequence_value)); assert!(sequence_value >= 1, "Sequence must be at least 1"); - assert!(sequence_value <= confirmations); - assert!(sequence_value >= confirmations.saturating_sub(100)); } loops += 1; @@ -605,11 +735,10 @@ mod tests { vec![input1.clone(), input2.clone(), input3.clone()], vec![output.clone()], ); - let psbt = selection - .create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - }) + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng()) + .unwrap() + .create_psbt(PsbtBuildParams::default()) .unwrap(); let tx = psbt.unsigned_tx; @@ -618,7 +747,6 @@ mod tests { used_locktime = true; } else { used_sequence = true; - // One of the inputs should have modified sequence let has_modified_sequence = tx.input.iter().any(|txin| { let seq = txin.sequence.to_consensus_u32(); seq > 0 && seq < 65_535 @@ -634,13 +762,12 @@ mod tests { } } - /// Regression: pre-fix, the AFS nLockTime path could overwrite `tx.lock_time` with a value + /// Regression: pre-fix, AFS's nLockTime path could overwrite `tx.lock_time` with a value /// lower than an input's required CLTV. #[test] fn test_anti_fee_sniping_preserves_input_cltv() -> anyhow::Result<()> { let cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(cltv)?; - // Tip is well below the input's CLTV requirement. let tip = absolute::Height::from_consensus(50_000)?; let selection = TxTemplate::from_parts( @@ -651,13 +778,11 @@ mod tests { )], ); - // The input is wsh (not Taproot), so AFS deterministically takes the locktime path; loop a - // few times anyway as cheap insurance against future control-flow changes. for _ in 0..100 { - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; + let (psbt, _) = selection + .clone() + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .create_psbt(PsbtBuildParams::default())?; assert_eq!( psbt.unsigned_tx.lock_time, cltv, "AFS must not overwrite an input's CLTV with a lower value", @@ -667,22 +792,16 @@ mod tests { Ok(()) } - /// Regression: pre-fix, the AFS nSequence path could pick a Taproot input that already carried - /// a CSV (relative-timelock) requirement and overwrite its sequence. The presence of a regular - /// Taproot input ensures the sequence path remains reachable — so the test also catches a - /// regression where AFS degrades to "never use the sequence path." + /// Regression: pre-fix, AFS's nSequence path could pick a Taproot input that already carried + /// a CSV (relative-timelock) requirement and overwrite its sequence. #[test] fn test_anti_fee_sniping_skips_taproot_csv_input() -> anyhow::Result<()> { let tip = absolute::Height::from_consensus(3_000)?; let csv_blocks = 10; - // Input A: regular Taproot, no CSV. let regular_input = setup_test_input(2_500)?; let regular_outpoint = regular_input.prev_outpoint(); - // Input B: Taproot whose script-path requires CSV. The internal key is omitted from - // `assets`, forcing planning to use the script-path leaf (which sets - // `plan.relative_timelock`). let secp = Secp256k1::new(); let desc_str = format!("tr({TEST_KEY_HEX},and_v(v:pk({TEST_KEY_TR}),older({csv_blocks})))"); let desc = Descriptor::parse_descriptor(&secp, &desc_str)? @@ -711,8 +830,6 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(18_000)); - // We will run AFS for 100 rounds. - // Track whether AFS's nSequence path actually fired for at least one of the rounds. let mut observed_sequence_path = false; for _ in 0..100 { @@ -720,10 +837,9 @@ mod tests { vec![regular_input.clone(), csv_input.clone()], vec![output.clone()], ); - let psbt = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - })?; + let (psbt, _) = selection + .apply_anti_fee_sniping(tip, &mut thread_rng())? + .create_psbt(PsbtBuildParams::default())?; let tx = psbt.unsigned_tx; let csv_txin = tx @@ -741,22 +857,20 @@ mod tests { .iter() .find(|t| t.previous_output == regular_outpoint) .expect("regular input must be present"); - if regular_txin.sequence != Sequence::ENABLE_RBF_NO_LOCKTIME { + if regular_txin.sequence != FALLBACK_SEQUENCE { observed_sequence_path = true; } } assert!( observed_sequence_path, - "AFS nSequence path must fire at least once across the 100 trials (otherwise the \ - CSV-preservation check above doesn't exercise the candidate-pool exclusion)", + "AFS nSequence path must fire at least once across the 100 trials", ); Ok(()) } - /// A time-based CLTV propagates to `tx.lock_time`; AFS only supports height-based locktimes, so - /// it must surface `UnsupportedLockTime`. + /// A time-based CLTV propagates to `tx.lock_time`; AFS only supports height-based locktimes. #[test] fn test_anti_fee_sniping_rejects_time_based_locktime() -> anyhow::Result<()> { let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); @@ -771,52 +885,93 @@ mod tests { )], ); - let result = selection.create_psbt(PsbtParams { - anti_fee_sniping: Some(tip), - ..Default::default() - }); + let result = selection.apply_anti_fee_sniping(tip, &mut thread_rng()); assert!(matches!( result, - Err(CreatePsbtError::AntiFeeSniping(AntiFeeSnipingError::UnsupportedLockTime(lt))) - if lt == time_locktime + Err(AntiFeeSnipingError::UnsupportedLockTime(lt)) if lt == time_locktime )); Ok(()) } #[test] - fn test_anti_fee_sniping_unsupported_version_error() { + fn test_anti_fee_sniping_unsupported_version_error() -> anyhow::Result<()> { let confirmation_height = 800_000; - let input = setup_test_input(confirmation_height).unwrap(); - let inputs = vec![input]; - let current_height = absolute::Height::from_consensus(confirmation_height + 50).unwrap(); - - let mut tx = Transaction { - version: Version::ONE, - lock_time: LockTime::from_height(current_height.to_consensus_u32()).unwrap(), - input: vec![TxIn { - previous_output: inputs[0].prev_outpoint(), - ..Default::default() + let input = setup_test_input(confirmation_height)?; + let current_height = absolute::Height::from_consensus(confirmation_height + 50)?; + + let selection = TxTemplate::from_parts( + vec![input], + vec![Output::with_script( + ScriptBuf::new(), + Amount::from_sat(9_000), + )], + ); + + let result = selection + .set_version(Version::ONE)? + .apply_anti_fee_sniping(current_height, &mut OsRng); + + assert!(matches!( + result, + Err(AntiFeeSnipingError::UnsupportedVersion(_)) + )); + + Ok(()) + } + + /// `set_version` rejects v1 when any input has a relative timelock (BIP-68). + #[test] + fn set_version_below_two_with_relative_timelock_errors() -> anyhow::Result<()> { + let csv_blocks = 10; + let secp = Secp256k1::new(); + let desc_str = + format!("tr({TEST_HEX_PK},and_v(v:pk({TEST_DESCRIPTOR_PK}),older({csv_blocks})))"); + let desc = Descriptor::parse_descriptor(&secp, &desc_str)? + .0 + .at_derivation_index(0)?; + let prev_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: desc.script_pubkey(), + value: Amount::from_sat(10_000), }], - output: vec![], }; + let assets = Assets::new() + .add(TEST_DESCRIPTOR_PK.parse::()?) + .older(relative::LockTime::from_height(csv_blocks)); + let plan = desc.plan(&assets).expect("script-path plan with CSV"); + let status = crate::ConfirmationStatus { + height: absolute::Height::from_consensus(2_500)?, + prev_mtp: Some(Time::from_consensus(500_000_000)?), + }; + let csv_input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; - let result = apply_anti_fee_sniping(&mut tx, &inputs, current_height, &mut OsRng); + let selection = TxTemplate::from_parts( + vec![csv_input], + vec![Output::with_script( + ScriptBuf::new(), + Amount::from_sat(9_000), + )], + ); - assert!( - matches!(result, Err(AntiFeeSnipingError::UnsupportedVersion(_))), - "should return UnsupportedVersion error for version < 2" + assert_eq!( + selection.version(), + Version::TWO, + "construction defaults to v2", ); - } - #[test] - fn test_fisher_yates_shuffle_preserves_multiset() { - let original: Vec = (0..32).collect(); - let mut shuffled = original.clone(); - fisher_yates_shuffle(&mut shuffled, &mut OsRng); - shuffled.sort(); - assert_eq!(shuffled, original); + let result = selection.set_version(Version::ONE); + assert!(matches!( + result, + Err(SetVersionError::RelativeTimelockRequiresV2 { attempted }) + if attempted == Version::ONE + )); + + Ok(()) } fn input_with_assets(desc_str: &str, assets: Assets) -> anyhow::Result { From 790c93566280b4f5996e91b39d20c435c175cccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 19 May 2026 17:05:32 +0000 Subject: [PATCH 11/24] refactor!: drop rand dependency from the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_psbt no longer needs an RNG (AFS — the only consumer — takes its own rng explicitly), so the create_psbt_with_rng wrapper and its thread_rng() call were dead weight. Collapses both into a single create_psbt(self, params) and moves rand to dev-dependencies. The library now depends only on rand_core (for the RngCore trait) + miniscript + bdk_coin_select. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 2 +- src/tx_template.rs | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5c7c1fb2..3edd3539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } bdk_coin_select = { version = "0.4.1" } miniscript = { version = "12.3.7", default-features = false } rand_core = { version = "0.6.4", default-features = false } -rand = { version = "0.8", optional = true } [dev-dependencies] anyhow = "1" @@ -27,6 +26,7 @@ bitcoin = { version = "0.32.10", default-features = false, features = ["rand-std bdk_testenv = "0.13.0" bdk_bitcoind_rpc = "0.22.0" bdk_chain = { version = "0.23.3" } +rand = "0.8" [features] default = ["std"] diff --git a/src/tx_template.rs b/src/tx_template.rs index 801ae5a9..9000aaac 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -385,17 +385,7 @@ impl TxTemplate { } /// Build the [`Psbt`] and its associated [`Finalizer`]. - #[cfg(feature = "std")] pub fn create_psbt(self, params: PsbtBuildParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { - self.create_psbt_with_rng(params, &mut rand::thread_rng()) - } - - /// Build the [`Psbt`] and its associated [`Finalizer`] with a custom `rng`. - pub fn create_psbt_with_rng( - self, - params: PsbtBuildParams, - _rng: &mut impl RngCore, - ) -> Result<(Psbt, Finalizer), BuildPsbtError> { let tx = self.to_unsigned_tx(); let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; From a411d8d21bfe775df6be57526dec748059660f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 16 Jun 2026 22:06:17 +0000 Subject: [PATCH 12/24] refactor: rename create_psbt to build_psbt and extract its types Settle on the "build" verb so the method, params, and error type agree: create_psbt -> build_psbt, PsbtBuildParams -> BuildPsbtParams (also fixing the word order). Move BuildPsbtParams/BuildPsbtError into a new build_psbt module; the build_psbt method stays inherent on TxTemplate since it touches private fields. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/anti_fee_sniping.rs | 4 +- examples/synopsis.rs | 6 +-- src/build_psbt.rs | 75 ++++++++++++++++++++++++++++++++++++ src/finalizer.rs | 22 +++++------ src/lib.rs | 2 + src/selector.rs | 2 +- src/tx_template.rs | 75 +++++------------------------------- 7 files changed, 103 insertions(+), 83 deletions(-) create mode 100644 src/build_psbt.rs diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index 038ceb2a..a926a49e 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output, SelectorParams, }; use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate}; @@ -92,7 +92,7 @@ fn main() -> anyhow::Result<()> { let (psbt, _) = selection .apply_anti_fee_sniping(tip_height, &mut rand::thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let tx = psbt.unsigned_tx; diff --git a/examples/synopsis.rs b/examples/synopsis.rs index ae24a7b2..2ce70129 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -1,6 +1,6 @@ use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, Output, PsbtBuildParams, + filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output, SelectorParams, Signer, }; use bitcoin::{key::Secp256k1, Amount, FeeRate}; @@ -67,7 +67,7 @@ fn main() -> anyhow::Result<()> { ) }, )? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let _ = psbt.sign(&signer, &secp); let res = finalizer.finalize(&mut psbt); @@ -153,7 +153,7 @@ fn main() -> anyhow::Result<()> { .collect::>() ); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; psbt.sign(&signer, &secp).expect("failed to sign"); assert!( finalizer.finalize(&mut psbt).is_finalized(), diff --git a/src/build_psbt.rs b/src/build_psbt.rs new file mode 100644 index 00000000..6160dec3 --- /dev/null +++ b/src/build_psbt.rs @@ -0,0 +1,75 @@ +//! Parameters and error type for [`TxTemplate::build_psbt`]. +//! +//! The build logic itself lives as an inherent method on [`TxTemplate`]; only the standalone +//! parameter and error types are housed here to keep `tx_template.rs` focused on tx shaping. +//! +//! [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt + +use alloc::boxed::Box; +use core::fmt::Display; + +use miniscript::bitcoin; + +use crate::Input; + +/// Parameters for emitting a [`Psbt`] from a [`TxTemplate`]. +/// +/// Carries only PSBT-specific options. Transaction-shape decisions (version, locktime, +/// sequence, anti-fee-sniping, input/output ordering) all live on [`TxTemplate`]. +/// +/// [`Psbt`]: bitcoin::Psbt +/// [`TxTemplate`]: crate::TxTemplate +#[derive(Debug, Clone)] +pub struct BuildPsbtParams { + /// Whether to require the full tx (aka [`non_witness_utxo`]) for segwit v0 inputs. + /// + /// Default: `true`. + /// + /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo + pub mandate_full_tx_for_segwit_v0: bool, +} + +impl Default for BuildPsbtParams { + fn default() -> Self { + Self { + mandate_full_tx_for_segwit_v0: true, + } + } +} + +/// Error returned by [`TxTemplate::build_psbt`]. +/// +/// [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt +#[derive(Debug)] +pub enum BuildPsbtError { + /// Missing tx for legacy input. + MissingFullTxForLegacyInput(Box), + /// Missing tx for segwit v0 input. + MissingFullTxForSegwitV0Input(Box), + /// Psbt error. + Psbt(bitcoin::psbt::Error), + /// Update psbt output with descriptor error. + OutputUpdate(miniscript::psbt::OutputUpdateError), +} + +impl Display for BuildPsbtError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::MissingFullTxForLegacyInput(input) => write!( + f, + "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + Self::MissingFullTxForSegwitV0Input(input) => write!( + f, + "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + Self::Psbt(e) => Display::fmt(e, f), + Self::OutputUpdate(e) => Display::fmt(e, f), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for BuildPsbtError {} diff --git a/src/finalizer.rs b/src/finalizer.rs index 3af65f01..61776445 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -17,7 +17,7 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// /// # Usage /// -/// A [`Finalizer`] is typically obtained alongside a PSBT from [`TxTemplate::create_psbt`]. +/// A [`Finalizer`] is typically obtained alongside a PSBT from [`TxTemplate::build_psbt`]. /// It can also be constructed directly from a list of `(outpoint, plan)` pairs via /// [`Finalizer::new`]. Use [`finalize_input`] to finalize a single input, or [`finalize`] to /// finalize every input. Upon finalizing the PSBT, the [`Finalizer`] also clears metadata from @@ -26,11 +26,11 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// # Example /// /// ```rust,no_run -/// # use bdk_tx::PsbtBuildParams; +/// # use bdk_tx::BuildPsbtParams; /// # let secp = bitcoin::secp256k1::Secp256k1::new(); /// # let keymap = std::collections::BTreeMap::new(); /// # let template: bdk_tx::TxTemplate = unimplemented!(); -/// let (mut psbt, finalizer) = template.create_psbt(PsbtBuildParams::default())?; +/// let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; /// /// // Sign the PSBT using your preferred method. /// let signer = bdk_tx::Signer(keymap); @@ -47,7 +47,7 @@ use miniscript::{bitcoin, miniscript::satisfy::Placeholder, plan::Plan, psbt::Ps /// /// [BIP174]: /// [`TxTemplate`]: crate::TxTemplate -/// [`TxTemplate::create_psbt`]: crate::TxTemplate::create_psbt +/// [`TxTemplate::build_psbt`]: crate::TxTemplate::build_psbt /// [`Plan`]: miniscript::plan::Plan /// [`Transaction`]: bitcoin::Transaction /// [`finalize_input`]: Finalizer::finalize_input @@ -300,7 +300,7 @@ impl core::error::Error for FinalizeError {} #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] mod tests { - use crate::{FinalizeError, Finalizer, Output, PsbtBuildParams, Signer, TxTemplate}; + use crate::{BuildPsbtParams, FinalizeError, Finalizer, Output, Signer, TxTemplate}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{absolute, transaction, Amount, ScriptBuf, TapSighashType, TxIn, TxOut}; use miniscript::bitcoin; @@ -354,7 +354,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -373,7 +373,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); @@ -401,7 +401,7 @@ mod tests { ], ); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; assert!(!psbt.outputs[0].tap_key_origins.is_empty()); assert!(psbt.outputs[0].tap_internal_key.is_some()); @@ -455,7 +455,7 @@ mod tests { ], ); - let (mut psbt, _) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, _) = selection.build_psbt(BuildPsbtParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -495,7 +495,7 @@ mod tests { ], ); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let tap_key_origins = psbt.outputs[0].tap_key_origins.clone(); let tap_internal_key = psbt.outputs[0].tap_internal_key; @@ -522,7 +522,7 @@ mod tests { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); let selection = TxTemplate::from_parts(vec![input], vec![output]); - let (mut psbt, finalizer) = selection.create_psbt(PsbtBuildParams::default())?; + let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; let secp = Secp256k1::new(); let signer = Signer(keymap); diff --git a/src/lib.rs b/src/lib.rs index 990d527c..8a50a5b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ extern crate std; pub extern crate bdk_coin_select; mod afs; +mod build_psbt; mod canonical_unspents; mod finalizer; mod input; @@ -24,6 +25,7 @@ mod signer; mod tx_template; pub use afs::*; +pub use build_psbt::*; pub use canonical_unspents::*; pub use finalizer::*; pub use input::*; diff --git a/src/selector.rs b/src/selector.rs index a5324301..1f02c704 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -404,7 +404,7 @@ impl<'c> Selector<'c> { } // Verify that all inputs agree on absolute timelock unit (height vs time). - // Downstream stages (create_psbt, apply_anti_fee_sniping) rely on this invariant. + // Downstream stages (build_psbt, apply_anti_fee_sniping) rely on this invariant. let mut unit: Option = None; for lt in candidates.inputs().filter_map(Input::absolute_timelock) { match unit { diff --git a/src/tx_template.rs b/src/tx_template.rs index 9000aaac..cfd56547 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -20,8 +20,8 @@ use miniscript::psbt::PsbtExt; use rand_core::RngCore; use crate::{ - apply_anti_fee_sniping, fisher_yates_shuffle, AntiFeeSnipingError, Finalizer, Input, InputMut, - Output, + apply_anti_fee_sniping, fisher_yates_shuffle, AntiFeeSnipingError, BuildPsbtError, + BuildPsbtParams, Finalizer, Input, InputMut, Output, }; /// Default `nSequence` for plan-based inputs that don't specify their own. @@ -117,63 +117,6 @@ pub struct TxTemplate { outputs: Vec, } -/// Parameters for emitting a [`Psbt`] from a [`TxTemplate`]. -/// -/// Carries only PSBT-specific options. Transaction-shape decisions (version, locktime, -/// sequence, anti-fee-sniping, input/output ordering) all live on [`TxTemplate`]. -#[derive(Debug, Clone)] -pub struct PsbtBuildParams { - /// Whether to require the full tx (aka [`non_witness_utxo`]) for segwit v0 inputs. - /// - /// Default: `true`. - /// - /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo - pub mandate_full_tx_for_segwit_v0: bool, -} - -impl Default for PsbtBuildParams { - fn default() -> Self { - Self { - mandate_full_tx_for_segwit_v0: true, - } - } -} - -/// Error returned by [`TxTemplate::create_psbt`]. -#[derive(Debug)] -pub enum BuildPsbtError { - /// Missing tx for legacy input. - MissingFullTxForLegacyInput(Box), - /// Missing tx for segwit v0 input. - MissingFullTxForSegwitV0Input(Box), - /// Psbt error. - Psbt(bitcoin::psbt::Error), - /// Update psbt output with descriptor error. - OutputUpdate(miniscript::psbt::OutputUpdateError), -} - -impl Display for BuildPsbtError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::MissingFullTxForLegacyInput(input) => write!( - f, - "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() - ), - Self::MissingFullTxForSegwitV0Input(input) => write!( - f, - "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() - ), - Self::Psbt(e) => Display::fmt(e, f), - Self::OutputUpdate(e) => Display::fmt(e, f), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for BuildPsbtError {} - impl TxTemplate { pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); @@ -218,7 +161,7 @@ impl TxTemplate { /// Set the fallback `nSequence` used for inputs that don't specify their own. /// /// The fallback is applied lazily at materialization (in [`Self::to_unsigned_tx`] and - /// [`Self::create_psbt`]); calling this method after other transformations does not + /// [`Self::build_psbt`]); calling this method after other transformations does not /// retroactively change inputs whose sequence has already been set explicitly (e.g. by /// [`apply_anti_fee_sniping`](Self::apply_anti_fee_sniping)). pub fn set_fallback_sequence(mut self, sequence: Sequence) -> Self { @@ -385,7 +328,7 @@ impl TxTemplate { } /// Build the [`Psbt`] and its associated [`Finalizer`]. - pub fn create_psbt(self, params: PsbtBuildParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { + pub fn build_psbt(self, params: BuildPsbtParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { let tx = self.to_unsigned_tx(); let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; @@ -658,7 +601,7 @@ mod tests { let (psbt, _) = selection .set_locktime(absolute::LockTime::from_consensus(current_height))? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; assert_eq!( psbt.unsigned_tx.lock_time.to_consensus_u32(), current_height @@ -682,7 +625,7 @@ mod tests { let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let tx = psbt.unsigned_tx; @@ -728,7 +671,7 @@ mod tests { let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng()) .unwrap() - .create_psbt(PsbtBuildParams::default()) + .build_psbt(BuildPsbtParams::default()) .unwrap(); let tx = psbt.unsigned_tx; @@ -772,7 +715,7 @@ mod tests { let (psbt, _) = selection .clone() .apply_anti_fee_sniping(tip, &mut thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; assert_eq!( psbt.unsigned_tx.lock_time, cltv, "AFS must not overwrite an input's CLTV with a lower value", @@ -829,7 +772,7 @@ mod tests { ); let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng())? - .create_psbt(PsbtBuildParams::default())?; + .build_psbt(BuildPsbtParams::default())?; let tx = psbt.unsigned_tx; let csv_txin = tx From 79bdf78d78cc21f9ab680e135927c5a324ae2309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 16 Jun 2026 22:25:15 +0000 Subject: [PATCH 13/24] feat(tx-template)!: seal TxTemplate into SealedTxTemplate after anti-fee-sniping apply_anti_fee_sniping now consumes the template and returns a SealedTxTemplate exposing only reads + emission, so version/locktime/sequence/ordering can't be changed after AFS. TxTemplate wraps SealedTxTemplate and derefs to it for the shared read/emit surface; the free afs helper mutates &mut self in place and the method does the sealing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/afs.rs | 16 ++- src/tx_template.rs | 352 ++++++++++++++++++++++++++------------------- 2 files changed, 213 insertions(+), 155 deletions(-) diff --git a/src/afs.rs b/src/afs.rs index 427ba6ed..b93228dd 100644 --- a/src/afs.rs +++ b/src/afs.rs @@ -56,9 +56,11 @@ impl std::error::Error for AntiFeeSnipingError {} /// Random offsets (0-99 blocks) are applied with 10% probability to avoid creating /// a unique fingerprint that could identify transactions from this wallet. /// +/// Mutates `template` in place; sealing into a [`SealedTxTemplate`](crate::SealedTxTemplate) +/// is the caller's job (see [`TxTemplate::apply_anti_fee_sniping`](crate::TxTemplate::apply_anti_fee_sniping)). +/// /// # Parameters -/// - `tx`: The transaction to modify -/// - `inputs`: The inputs associated with the transaction +/// - `template`: The tx template to modify /// - `tip_height`: The current blockchain height (used as the base for time locks) /// - `rng`: Random number generator implementing `RngCore` /// @@ -78,10 +80,10 @@ impl std::error::Error for AntiFeeSnipingError {} /// # See Also /// [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki) pub(crate) fn apply_anti_fee_sniping( - mut template: TxTemplate, + template: &mut TxTemplate, tip_height: absolute::Height, rng: &mut impl RngCore, -) -> Result { +) -> Result<(), AntiFeeSnipingError> { const MAX_RELATIVE_HEIGHT: u32 = 65_535; const FIFTY_PERCENT_PROBABILITY_RANGE: u32 = 2; const MIN_SEQUENCE_VALUE: u32 = 1; @@ -136,8 +138,8 @@ pub(crate) fn apply_anti_fee_sniping( // Only apply if it's a bump (i.e. doesn't regress an input's CLTV requirement). if template.lock_time().is_implied_by(afs_locktime) { - template = template - .set_locktime(afs_locktime) + template + .set_locktime_in_place(afs_locktime) .expect("AFS picks a value ≥ current lock_time (same height-based unit)"); } } else { @@ -161,5 +163,5 @@ pub(crate) fn apply_anti_fee_sniping( .expect("AFS only picks inputs without timelock constraints"); } - Ok(template) + Ok(()) } diff --git a/src/tx_template.rs b/src/tx_template.rs index cfd56547..39eccc26 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -1,9 +1,10 @@ //! Tx-shaping stage between coin selection and the final [`Psbt`] or [`Transaction`]. //! //! A [`TxTemplate`] is obtained from [`Selector::try_finalize`] or -//! [`InputCandidates::into_tx_template`], then mutated (sort, shuffle, anti-fee-sniping, -//! set_version, set_locktime, set_fallback_sequence, per-input sequence overrides) before -//! being emitted as a PSBT or a [`Transaction`]. +//! [`InputCandidates::into_tx_template`], then mutated (sort, shuffle, set_version, +//! set_locktime, set_fallback_sequence, per-input sequence overrides) before being emitted as +//! a PSBT or a [`Transaction`]. Anti-fee-sniping is the terminal step: it seals the template +//! into a [`SealedTxTemplate`], which permits only reads and emission. //! //! [`Selector::try_finalize`]: crate::Selector::try_finalize //! [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template @@ -94,13 +95,157 @@ impl Display for SetLockTimeError { #[cfg(feature = "std")] impl std::error::Error for SetLockTimeError {} +/// A sealed, fully-resolved transaction shape — the read/emit core shared with [`TxTemplate`]. +/// +/// Produced by [`TxTemplate::apply_anti_fee_sniping`]. Because anti-fee-sniping is the terminal +/// shaping step, the version, locktime, sequences and input/output ordering are all fixed once +/// sealed — so this type deliberately exposes *no* mutators, only read access and final emission +/// ([`to_unsigned_tx`](Self::to_unsigned_tx), [`build_psbt`](Self::build_psbt)). +/// +/// [`TxTemplate`] derefs to this type, so every read/emit method here is also reachable on an +/// unsealed template. +#[derive(Debug, Clone)] +#[must_use] +pub struct SealedTxTemplate { + version: transaction::Version, + lock_time: absolute::LockTime, + fallback_sequence: Sequence, + inputs: Vec, + outputs: Vec, +} + +impl SealedTxTemplate { + /// Resolved transaction version. + pub fn version(&self) -> transaction::Version { + self.version + } + + /// Resolved transaction lock_time. + pub fn lock_time(&self) -> absolute::LockTime { + self.lock_time + } + + /// Fallback `nSequence` applied to inputs that don't specify their own. + pub fn fallback_sequence(&self) -> Sequence { + self.fallback_sequence + } + + /// Inputs in this template. + pub fn inputs(&self) -> &[Input] { + &self.inputs + } + + /// Outputs in this template. + pub fn outputs(&self) -> &[Output] { + &self.outputs + } + + /// Materialize the unsigned `bitcoin::Transaction` represented by this template. + /// + /// Each input's `nSequence` is its own [`Input::sequence`] if set, otherwise + /// [`fallback_sequence`](Self::fallback_sequence). + pub fn to_unsigned_tx(&self) -> Transaction { + Transaction { + version: self.version, + lock_time: self.lock_time, + input: self + .inputs + .iter() + .map(|input| TxIn { + previous_output: input.prev_outpoint(), + sequence: input.sequence().unwrap_or(self.fallback_sequence), + ..Default::default() + }) + .collect(), + output: self.outputs.iter().map(Output::txout).collect(), + } + } + + /// Build the [`Psbt`] and its associated [`Finalizer`]. + pub fn build_psbt(self, params: BuildPsbtParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { + let tx = self.to_unsigned_tx(); + let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; + + for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) { + if let Some(finalized_psbt_input) = plan_input.psbt_input() { + *psbt_input = finalized_psbt_input.clone(); + continue; + } + if let Some(plan) = plan_input.plan() { + plan.update_psbt_input(psbt_input); + + let witness_version = plan.witness_version(); + if witness_version.is_some() { + psbt_input.witness_utxo = Some(plan_input.prev_txout().clone()); + } + psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); + if psbt_input.non_witness_utxo.is_none() { + if witness_version.is_none() { + return Err(BuildPsbtError::MissingFullTxForLegacyInput(Box::new( + plan_input.clone(), + ))); + } + if params.mandate_full_tx_for_segwit_v0 + && witness_version == Some(bitcoin::WitnessVersion::V0) + { + return Err(BuildPsbtError::MissingFullTxForSegwitV0Input(Box::new( + plan_input.clone(), + ))); + } + } + // 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 [`Selector::try_finalize`] (or /// [`InputCandidates::into_tx_template`]). Exposes the operations that *shape* the resulting -/// transaction: input/output ordering, anti-fee-sniping, version/locktime overrides, and -/// final emission to PSBT or [`Transaction`]. +/// transaction: input/output ordering, version/locktime overrides, and final emission to PSBT +/// or [`Transaction`]. Anti-fee-sniping is the terminal shaping step — it consumes the template +/// and yields a [`SealedTxTemplate`] that can only be read and emitted. +/// +/// All read/emit methods come from [`SealedTxTemplate`] via [`Deref`](core::ops::Deref); this +/// type adds the mutators on top. /// /// New templates start with `version = TWO`, `lock_time = max(input CLTVs)` (or `ZERO`), and /// `fallback_sequence = ENABLE_RBF_NO_LOCKTIME`. @@ -109,29 +254,26 @@ impl std::error::Error for SetLockTimeError {} /// [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template #[derive(Debug, Clone)] #[must_use] -pub struct TxTemplate { - version: transaction::Version, - lock_time: absolute::LockTime, - fallback_sequence: Sequence, - inputs: Vec, - outputs: Vec, +pub struct TxTemplate(SealedTxTemplate); + +impl core::ops::Deref for TxTemplate { + type Target = SealedTxTemplate; + + fn deref(&self) -> &SealedTxTemplate { + &self.0 + } } impl TxTemplate { pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); - Self { + Self(SealedTxTemplate { version: transaction::Version::TWO, lock_time, fallback_sequence: FALLBACK_SEQUENCE, inputs, outputs, - } - } - - /// Resolved transaction version. - pub fn version(&self) -> transaction::Version { - self.version + }) } /// Override `tx.version`. @@ -145,27 +287,26 @@ impl TxTemplate { /// relative timelock. pub fn set_version(mut self, version: transaction::Version) -> Result { if version < transaction::Version::TWO - && self.inputs.iter().any(|i| i.relative_timelock().is_some()) + && self + .0 + .inputs + .iter() + .any(|i| i.relative_timelock().is_some()) { return Err(SetVersionError::RelativeTimelockRequiresV2 { attempted: version }); } - self.version = version; + self.0.version = version; Ok(self) } - /// Resolved transaction lock_time. - pub fn lock_time(&self) -> absolute::LockTime { - self.lock_time - } - /// Set the fallback `nSequence` used for inputs that don't specify their own. /// - /// The fallback is applied lazily at materialization (in [`Self::to_unsigned_tx`] and - /// [`Self::build_psbt`]); calling this method after other transformations does not - /// retroactively change inputs whose sequence has already been set explicitly (e.g. by - /// [`apply_anti_fee_sniping`](Self::apply_anti_fee_sniping)). + /// The fallback is applied lazily at materialization (in + /// [`to_unsigned_tx`](SealedTxTemplate::to_unsigned_tx) and [`Self::build_psbt`]); it does + /// not retroactively change inputs whose sequence has already been set explicitly (e.g. via + /// [`InputMut::set_sequence`]). pub fn set_fallback_sequence(mut self, sequence: Sequence) -> Self { - self.fallback_sequence = sequence; + self.0.fallback_sequence = sequence; self } @@ -184,7 +325,17 @@ impl TxTemplate { /// - [`SetLockTimeError::UnitMismatch`] if `lock_time` is height-based and an input's /// CLTV is time-based (or vice versa). pub fn set_locktime(mut self, lock_time: absolute::LockTime) -> Result { - for input in &self.inputs { + self.set_locktime_in_place(lock_time)?; + Ok(self) + } + + /// In-place [`set_locktime`](Self::set_locktime), for callers holding `&mut self` + /// (e.g. anti-fee-sniping). Validation is identical. + pub(crate) fn set_locktime_in_place( + &mut self, + lock_time: absolute::LockTime, + ) -> Result<(), SetLockTimeError> { + for input in &self.0.inputs { let Some(required) = input.absolute_timelock() else { continue; }; @@ -201,23 +352,8 @@ impl TxTemplate { }); } } - self.lock_time = lock_time; - Ok(self) - } - - /// Fallback `nSequence` applied to inputs that don't specify their own. - pub fn fallback_sequence(&self) -> Sequence { - self.fallback_sequence - } - - /// Inputs in this template. - pub fn inputs(&self) -> &[Input] { - &self.inputs - } - - /// Outputs in this template. - pub fn outputs(&self) -> &[Output] { - &self.outputs + self.0.lock_time = lock_time; + Ok(()) } /// Mutable handle to the input spending `outpoint`, if any. @@ -226,7 +362,8 @@ impl TxTemplate { /// [`InputMut`] only permits mutations that preserve the template's invariants — see /// [`InputMut`] for the available operations. pub fn input_mut(&mut self, outpoint: OutPoint) -> Option> { - self.inputs + self.0 + .inputs .iter_mut() .find(|input| input.prev_outpoint() == outpoint) .map(InputMut::new) @@ -237,7 +374,7 @@ impl TxTemplate { /// Each yielded [`InputMut`] only permits mutations that preserve the template's /// invariants — see [`InputMut`] for the available operations. pub fn inputs_mut(&mut self) -> impl Iterator> { - self.inputs.iter_mut().map(InputMut::new) + self.0.inputs.iter_mut().map(InputMut::new) } /// Reorder inputs using `compare`. Uses a stable sort. @@ -247,7 +384,7 @@ impl TxTemplate { where F: FnMut(&Input, &Input) -> Ordering, { - self.inputs.sort_by(compare); + self.0.inputs.sort_by(compare); self } @@ -255,7 +392,7 @@ impl TxTemplate { /// /// Useful for chain-analysis resistance when no deterministic ordering is required. pub fn shuffle_inputs(mut self, rng: &mut R) -> Self { - fisher_yates_shuffle(&mut self.inputs, rng); + fisher_yates_shuffle(&mut self.0.inputs, rng); self } @@ -266,7 +403,7 @@ impl TxTemplate { where F: FnMut(&Output, &Output) -> Ordering, { - self.outputs.sort_by(compare); + self.0.outputs.sort_by(compare); self } @@ -275,31 +412,10 @@ impl TxTemplate { /// Useful for chain-analysis resistance — in particular, hiding which output is the /// change. pub fn shuffle_outputs(mut self, rng: &mut R) -> Self { - fisher_yates_shuffle(&mut self.outputs, rng); + fisher_yates_shuffle(&mut self.0.outputs, rng); self } - /// Materialize the unsigned `bitcoin::Transaction` represented by this template. - /// - /// Each input's `nSequence` is its own [`Input::sequence`] if set, otherwise - /// [`fallback_sequence`](Self::fallback_sequence). - pub fn to_unsigned_tx(&self) -> Transaction { - Transaction { - version: self.version, - lock_time: self.lock_time, - input: self - .inputs - .iter() - .map(|input| TxIn { - previous_output: input.prev_outpoint(), - sequence: input.sequence().unwrap_or(self.fallback_sequence), - ..Default::default() - }) - .collect(), - output: self.outputs.iter().map(Output::txout).collect(), - } - } - /// Apply BIP-326 anti-fee-sniping (AFS) protection using `tip_height` as the chain tip. /// /// AFS discourages miners from reorganizing recent blocks to capture fees by constraining @@ -313,91 +429,31 @@ impl TxTemplate { /// If `tx.lock_time` is already a block height greater than `tip_height` (e.g., because an /// input's CLTV pins the tx to a future block), this leaves the template unchanged. /// - /// See [BIP326](https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki). + /// AFS is the *final* shaping step: it consumes the template and returns a + /// [`SealedTxTemplate`], which permits only reads and emission. Apply any ordering, + /// version, locktime or sequence changes *before* calling this. See [BIP326]. + /// + /// [BIP326]: https://github.com/bitcoin/bips/blob/master/bip-0326.mediawiki /// /// # Errors /// /// - [`AntiFeeSnipingError::UnsupportedVersion`] if `version < 2`. /// - [`AntiFeeSnipingError::UnsupportedLockTime`] if `lock_time` is time-based. pub fn apply_anti_fee_sniping( - self, + mut self, tip_height: absolute::Height, rng: &mut R, - ) -> Result { - apply_anti_fee_sniping(self, tip_height, rng) + ) -> Result { + apply_anti_fee_sniping(&mut self, tip_height, rng)?; + Ok(self.0) } /// Build the [`Psbt`] and its associated [`Finalizer`]. + /// + /// Convenience for building without sealing; equivalent to the inherent + /// [`SealedTxTemplate::build_psbt`] on the unsealed shape. pub fn build_psbt(self, params: BuildPsbtParams) -> Result<(Psbt, Finalizer), BuildPsbtError> { - let tx = self.to_unsigned_tx(); - let mut psbt = Psbt::from_unsigned_tx(tx).map_err(BuildPsbtError::Psbt)?; - - for (plan_input, psbt_input) in self.inputs.iter().zip(psbt.inputs.iter_mut()) { - if let Some(finalized_psbt_input) = plan_input.psbt_input() { - *psbt_input = finalized_psbt_input.clone(); - continue; - } - if let Some(plan) = plan_input.plan() { - plan.update_psbt_input(psbt_input); - - let witness_version = plan.witness_version(); - if witness_version.is_some() { - psbt_input.witness_utxo = Some(plan_input.prev_txout().clone()); - } - psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); - if psbt_input.non_witness_utxo.is_none() { - if witness_version.is_none() { - return Err(BuildPsbtError::MissingFullTxForLegacyInput(Box::new( - plan_input.clone(), - ))); - } - if params.mandate_full_tx_for_segwit_v0 - && witness_version == Some(bitcoin::WitnessVersion::V0) - { - return Err(BuildPsbtError::MissingFullTxForSegwitV0Input(Box::new( - plan_input.clone(), - ))); - } - } - // 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)) + self.0.build_psbt(params) } } From 090384400dd469132332bbe9139a0866500df6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 15 Jun 2026 20:09:42 +0000 Subject: [PATCH 14/24] feat: Add single_random_draw selection algorithm Provide selection_algorithm_single_random_draw, which shuffles candidates with a caller-supplied rng and selects until the target is met. Pass it to Selector::select_with_algorithm so randomness lives at the selection step and candidate construction stays deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 53f4e5a1..e3350888 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -1,9 +1,10 @@ use alloc::{vec, vec::Vec}; use core::fmt; -use bdk_coin_select::{metrics::LowestFee, Candidate, NoBnbSolution}; +use bdk_coin_select::{metrics::LowestFee, Candidate, InsufficientFunds, NoBnbSolution}; use bitcoin::{absolute, FeeRate, OutPoint}; use miniscript::bitcoin; +use rand_core::RngCore; use crate::collections::{BTreeMap, HashSet}; use crate::{ @@ -261,6 +262,31 @@ pub fn selection_algorithm_lowest_fee_bnb( } } +/// Coin selection algorithm that selects candidates in a uniformly-random order until the target +/// is met (single random draw). +/// +/// The `rng` is carried by the returned algorithm, so candidate construction stays deterministic +/// and randomness lives at the selection step. Pass the result to +/// [`Selector::select_with_algorithm`]. +/// +/// [`Selector::select_with_algorithm`]: crate::Selector::select_with_algorithm +pub fn selection_algorithm_single_random_draw( + rng: &mut impl RngCore, +) -> impl FnMut(&mut Selector) -> Result<(), InsufficientFunds> + '_ { + move |selector| { + // Assign every candidate a random sort key, then sort by it to obtain a uniform shuffle. + // The keys are precomputed (one per candidate) so the closure handed to + // `sort_candidates_by_key` is a deterministic lookup: that closure is invoked multiple + // times per comparison, so it must not draw from the rng itself. + let n = selector.inner().candidates().len(); + let keys: Vec = (0..n).map(|_| rng.next_u64()).collect(); + selector + .inner_mut() + .sort_candidates_by_key(|(i, _)| keys[i]); + selector.select_until_target_met() + } +} + /// Default group policy. pub fn group_by_spk() -> impl Fn(&Input) -> bitcoin::ScriptBuf { |input| input.prev_txout().script_pubkey.clone() From 56c1d116efb678fd4fe70bb8c9fd4758ca481e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 16 Jun 2026 12:35:48 +0000 Subject: [PATCH 15/24] feat: Add InputCandidates::push_must_select / push_can_select Incremental builders to add an Input to the must-select group or as an optional can-select group, with outpoint de-duplication. Lets callers extend an InputCandidates after construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index e3350888..0cfd4e62 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -192,6 +192,37 @@ impl InputCandidates { self } + /// Add `input` to the must-select group (always included by coin selection). + /// + /// All must-select inputs form a single group spent together. If `input`'s outpoint is already + /// a candidate, it is ignored and the existing candidate is kept. + pub fn push_must_select(mut self, input: impl Into) -> Self { + let input = input.into(); + if self.contains.insert(input.prev_outpoint()) { + let mut inputs = self + .must_select + .take() + .map_or_else(Vec::new, InputGroup::into_inputs); + inputs.push(input); + self.must_select = InputGroup::from_inputs(inputs); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + } + self + } + + /// Add `input` as its own optional (can-select) group. + /// + /// If `input`'s outpoint is already a candidate, it is ignored and the existing candidate is + /// kept. + pub fn push_can_select(mut self, input: impl Into) -> Self { + let input = input.into(); + if self.contains.insert(input.prev_outpoint()) { + self.can_select.push(InputGroup::from_input(input)); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + } + self + } + /// Run coin selection with `algorithm` and selector `params`, returning a [`TxTemplate`]. pub fn into_tx_template( self, From c9a1591e0617ca8372c6e15911df18098b751258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 25 Jun 2026 11:04:40 +0000 Subject: [PATCH 16/24] refactor(tx_template): Rename `TxTemplate::from_parts` to `new` --- src/finalizer.rs | 12 ++++++------ src/selector.rs | 2 +- src/tx_template.rs | 26 +++++++++++++------------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index 61776445..8396f104 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -352,7 +352,7 @@ mod tests { fn test_finalize_single_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; @@ -371,7 +371,7 @@ mod tests { fn test_finalize_sets_final_script_sig() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(PKH_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; @@ -393,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 = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input_0, input_1, input_2], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -447,7 +447,7 @@ mod tests { input_0.plan().cloned().expect("plan must exist"), )]); - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input_0, input_1], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -487,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 = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![ Output::with_descriptor(taproot_output_descriptor, Amount::from_sat(20_000)), @@ -520,7 +520,7 @@ mod tests { fn test_already_finalized_input() -> anyhow::Result<()> { let (input, keymap) = create_input_from_descriptor_at(TR_XPRV, 0)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (mut psbt, finalizer) = selection.build_psbt(BuildPsbtParams::default())?; diff --git a/src/selector.rs b/src/selector.rs index 1f02c704..f40ef8a9 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -506,7 +506,7 @@ impl<'c> Selector<'c> { Amount::from_sat(maybe_change.value), ))); } - Some(TxTemplate::from_parts(inputs, outputs)) + Some(TxTemplate::new(inputs, outputs)) } } diff --git a/src/tx_template.rs b/src/tx_template.rs index 39eccc26..f29570f3 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -265,7 +265,7 @@ impl core::ops::Deref for TxTemplate { } impl TxTemplate { - pub(crate) fn from_parts(inputs: Vec, outputs: Vec) -> Self { + pub(crate) fn new(inputs: Vec, outputs: Vec) -> Self { let lock_time = max_input_cltv(&inputs).unwrap_or(absolute::LockTime::ZERO); Self(SealedTxTemplate { version: transaction::Version::TWO, @@ -559,7 +559,7 @@ mod tests { fn set_locktime_height_above_input_cltv() -> anyhow::Result<()> { let cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(cltv)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -586,7 +586,7 @@ mod tests { fn set_locktime_below_input_cltv_errors() -> anyhow::Result<()> { let cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(cltv)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -610,7 +610,7 @@ mod tests { fn lock_time_takes_time_based_cltv_from_input() -> anyhow::Result<()> { let time_locktime = absolute::LockTime::from_consensus(1_734_230_218); let (input, desc) = setup_cltv_input(time_locktime)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -628,7 +628,7 @@ mod tests { fn set_locktime_unit_mismatch_errors() -> anyhow::Result<()> { let height_cltv = absolute::LockTime::from_consensus(100_000); let (input, desc) = setup_cltv_input(height_cltv)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -653,7 +653,7 @@ mod tests { let current_height = 2_500; let input = setup_test_input(2_000)?; let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input], vec![output]); + let selection = TxTemplate::new(vec![input], vec![output]); let (psbt, _) = selection .set_locktime(absolute::LockTime::from_consensus(current_height))? @@ -677,7 +677,7 @@ mod tests { while !used_locktime || !used_sequence { let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); - let selection = TxTemplate::from_parts(vec![input.clone()], vec![output]); + let selection = TxTemplate::new(vec![input.clone()], vec![output]); let (psbt, _) = selection .apply_anti_fee_sniping(tip, &mut thread_rng())? @@ -720,7 +720,7 @@ mod tests { let mut loops = 0; while !used_locktime || !used_sequence { - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input1.clone(), input2.clone(), input3.clone()], vec![output.clone()], ); @@ -759,7 +759,7 @@ mod tests { let (input, desc) = setup_cltv_input(cltv)?; let tip = absolute::Height::from_consensus(50_000)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -822,7 +822,7 @@ mod tests { let mut observed_sequence_path = false; for _ in 0..100 { - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![regular_input.clone(), csv_input.clone()], vec![output.clone()], ); @@ -866,7 +866,7 @@ mod tests { let (input, desc) = setup_cltv_input(time_locktime)?; let tip = absolute::Height::from_consensus(800_000)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_descriptor( desc.at_derivation_index(1)?, @@ -890,7 +890,7 @@ mod tests { let input = setup_test_input(confirmation_height)?; let current_height = absolute::Height::from_consensus(confirmation_height + 50)?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![input], vec![Output::with_script( ScriptBuf::new(), @@ -939,7 +939,7 @@ mod tests { }; let csv_input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; - let selection = TxTemplate::from_parts( + let selection = TxTemplate::new( vec![csv_input], vec![Output::with_script( ScriptBuf::new(), From ac4776f9b1b55d6f5d412d5a6183d382ee289612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 25 Jun 2026 11:29:56 +0000 Subject: [PATCH 17/24] feat(input_candidates): upsert in push_must_select / push_can_select Previously these were no-ops when the outpoint already existed, so a push could neither promote a can-select candidate to must-select nor replace stale input data, and the surviving candidate depended on insertion order rather than group precedence. They now upsert: the existing candidate is replaced and moved to the requested group, with must-select taking precedence over can-select (consistent with `new`). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 188 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 172 insertions(+), 16 deletions(-) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 0cfd4e62..a9bacc7d 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -194,35 +194,84 @@ impl InputCandidates { /// Add `input` to the must-select group (always included by coin selection). /// - /// All must-select inputs form a single group spent together. If `input`'s outpoint is already - /// a candidate, it is ignored and the existing candidate is kept. + /// All must-select inputs form a single group spent together. + /// + /// If `input`'s outpoint is already a candidate it is *upserted*: the existing candidate is + /// replaced with `input` and moved into the must-select group. When the previous candidate was + /// part of a multi-input `can_select` group, it is detached and the remaining members stay + /// grouped together. pub fn push_must_select(mut self, input: impl Into) -> Self { let input = input.into(); - if self.contains.insert(input.prev_outpoint()) { - let mut inputs = self - .must_select - .take() - .map_or_else(Vec::new, InputGroup::into_inputs); - inputs.push(input); - self.must_select = InputGroup::from_inputs(inputs); - self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); - } + self.take_input(input.prev_outpoint()); + self.contains.insert(input.prev_outpoint()); + let mut inputs = self + .must_select + .take() + .map_or_else(Vec::new, InputGroup::into_inputs); + inputs.push(input); + self.must_select = InputGroup::from_inputs(inputs); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); self } /// Add `input` as its own optional (can-select) group. /// - /// If `input`'s outpoint is already a candidate, it is ignored and the existing candidate is - /// kept. + /// If `input`'s outpoint is already a candidate it is *upserted*: the existing candidate is + /// replaced with `input`. As must-select takes precedence over can-select (consistent with + /// [`new`](Self::new)), an outpoint that is already must-select keeps its data replaced but is + /// *not* demoted to can-select. pub fn push_can_select(mut self, input: impl Into) -> Self { let input = input.into(); - if self.contains.insert(input.prev_outpoint()) { - self.can_select.push(InputGroup::from_input(input)); - self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); + let outpoint = input.prev_outpoint(); + let in_must_select = self + .must_select + .as_ref() + .is_some_and(|g| g.inputs().iter().any(|i| i.prev_outpoint() == outpoint)); + if in_must_select { + return self.push_must_select(input); } + self.take_input(outpoint); + self.contains.insert(outpoint); + self.can_select.push(InputGroup::from_input(input)); + self.cs_candidates = Self::build_cs_candidates(&self.must_select, &self.can_select); self } + /// Remove and return the candidate input with `outpoint` from wherever it currently lives. + /// + /// When the input was part of a multi-input `can_select` group, the remaining members are kept + /// together as a group (in the same position). Returns `None` if `outpoint` is not a candidate. + /// Does not rebuild [`Self::cs_candidates`]; callers must do so. + fn take_input(&mut self, outpoint: OutPoint) -> Option { + if !self.contains.remove(&outpoint) { + return None; + } + if let Some(group) = self.must_select.take() { + let mut inputs = group.into_inputs(); + if let Some(pos) = inputs.iter().position(|i| i.prev_outpoint() == outpoint) { + let removed = inputs.remove(pos); + self.must_select = InputGroup::from_inputs(inputs); + return Some(removed); + } + self.must_select = InputGroup::from_inputs(inputs); + } + for idx in 0..self.can_select.len() { + let pos = self.can_select[idx] + .inputs() + .iter() + .position(|i| i.prev_outpoint() == outpoint); + if let Some(pos) = pos { + let mut inputs = self.can_select.remove(idx).into_inputs(); + let removed = inputs.remove(pos); + if let Some(group) = InputGroup::from_inputs(inputs) { + self.can_select.insert(idx, group); + } + return Some(removed); + } + } + None + } + /// Run coin selection with `algorithm` and selector `params`, returning a [`TxTemplate`]. pub fn into_tx_template( self, @@ -337,3 +386,110 @@ pub fn filter_unspendable( pub fn no_filtering() -> impl Fn(&InputGroup) -> bool { |_| true } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Input; + use bitcoin::{hashes::Hash, Amount, OutPoint, TxOut, Txid}; + use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + use std::str::FromStr; + + const TEST_XPUB: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; + + /// Build an [`Input`] at `vout` carrying `value` sats; `value` doubles as an identity tag so + /// tests can assert an upsert actually replaced the previous candidate's data. + fn input_at(vout: u32, value: u64) -> Input { + let desc = + Descriptor::::from_str(&format!("tr({TEST_XPUB})")).unwrap(); + let definite = desc.at_derivation_index(0).unwrap(); + let script_pubkey = definite.script_pubkey(); + let assets = Assets::new().add(DescriptorPublicKey::from_str(TEST_XPUB).unwrap()); + let plan = definite.plan(&assets).unwrap(); + let outpoint = OutPoint::new(Txid::all_zeros(), vout); + let txout = TxOut { + value: Amount::from_sat(value), + script_pubkey, + }; + Input::from_prev_txout(plan, outpoint, txout, None, false) + } + + fn op(vout: u32) -> OutPoint { + OutPoint::new(Txid::all_zeros(), vout) + } + + fn value_at(c: &InputCandidates, outpoint: OutPoint) -> Option { + c.inputs() + .find(|i| i.prev_outpoint() == outpoint) + .map(|i| i.prev_txout().value.to_sat()) + } + + fn is_must(c: &InputCandidates, outpoint: OutPoint) -> bool { + c.must_select() + .is_some_and(|g| g.inputs().iter().any(|i| i.prev_outpoint() == outpoint)) + } + + fn is_can(c: &InputCandidates, outpoint: OutPoint) -> bool { + c.can_select() + .iter() + .any(|g| g.inputs().iter().any(|i| i.prev_outpoint() == outpoint)) + } + + #[test] + fn push_must_select_promotes_and_replaces_can_select_candidate() { + let c = InputCandidates::new([], [input_at(0, 100)]); + assert!(is_can(&c, op(0))); + + let c = c.push_must_select(input_at(0, 200)); + assert!( + is_must(&c, op(0)), + "outpoint should be promoted to must-select" + ); + assert!( + !is_can(&c, op(0)), + "outpoint should no longer be can-select" + ); + assert_eq!( + value_at(&c, op(0)), + Some(200), + "candidate data should be replaced" + ); + assert_eq!(c.inputs().count(), 1, "no duplicate candidate"); + assert_eq!(c.coin_select_candidates().len(), 1); + } + + #[test] + fn push_can_select_does_not_demote_must_select_but_replaces_data() { + let c = InputCandidates::new([input_at(0, 100)], []); + + let c = c.push_can_select(input_at(0, 200)); + assert!( + is_must(&c, op(0)), + "must-select takes precedence; no demotion" + ); + assert!(!is_can(&c, op(0))); + assert_eq!( + value_at(&c, op(0)), + Some(200), + "candidate data should be replaced" + ); + assert_eq!(c.inputs().count(), 1); + } + + #[test] + fn push_must_select_detaches_outpoint_from_multi_input_group() { + // All inputs share a script pubkey, so grouping by spk yields a single can-select group. + let c = InputCandidates::new([], [input_at(0, 100), input_at(1, 100), input_at(2, 100)]) + .regroup(group_by_spk()); + assert_eq!(c.can_select().len(), 1); + assert_eq!(c.can_select()[0].inputs().len(), 3); + + let c = c.push_must_select(input_at(1, 999)); + assert!(is_must(&c, op(1))); + assert_eq!(value_at(&c, op(1)), Some(999)); + assert_eq!(c.can_select().len(), 1, "remaining members stay grouped"); + assert_eq!(c.can_select()[0].inputs().len(), 2); + assert_eq!(c.inputs().count(), 3, "no input lost or duplicated"); + assert_eq!(c.coin_select_candidates().len(), 2); + } +} From df09bd3ba9ac004196cc525973a22d694dd11ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 27 Jun 2026 03:23:00 +0000 Subject: [PATCH 18/24] test: reconcile #74/#79 tests with TxTemplate/build_psbt API The sighash tests added by the finalizer/selection PRs were authored against the pre-TxTemplate API; update them to TxTemplate::new + build_psbt (which now returns (Psbt, Finalizer)) and the merged test-key const names. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/finalizer.rs | 10 ++++------ src/tx_template.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/finalizer.rs b/src/finalizer.rs index 8396f104..779776d1 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -553,10 +553,9 @@ mod tests { 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 selection = TxTemplate::new(vec![input], vec![output]); + let template = TxTemplate::new(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; psbt.sign(&Signer(keymap), &Secp256k1::new()) .expect("signing failed"); @@ -573,10 +572,9 @@ mod tests { 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 selection = TxTemplate::new(vec![input], vec![output]); + let template = TxTemplate::new(vec![input], vec![output]); - let mut psbt = selection.create_psbt(PsbtParams::default())?; - let finalizer = selection.into_finalizer(); + let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?; psbt.sign(&Signer(keymap), &Secp256k1::new()) .expect("signing failed"); diff --git a/src/tx_template.rs b/src/tx_template.rs index f29570f3..f1734b3e 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -915,8 +915,7 @@ mod tests { fn set_version_below_two_with_relative_timelock_errors() -> anyhow::Result<()> { let csv_blocks = 10; let secp = Secp256k1::new(); - let desc_str = - format!("tr({TEST_HEX_PK},and_v(v:pk({TEST_DESCRIPTOR_PK}),older({csv_blocks})))"); + let desc_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)?; @@ -930,7 +929,7 @@ mod tests { }], }; let assets = Assets::new() - .add(TEST_DESCRIPTOR_PK.parse::()?) + .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 { @@ -999,10 +998,11 @@ mod tests { assets } - fn run_sighash_case(input: Input, params: PsbtParams) -> anyhow::Result { + 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]); - Ok(selection.create_psbt(params)?) + let (psbt, _finalizer) = selection.build_psbt(params)?; + Ok(psbt) } /// `create_psbt` writes the correct `sighash_type` on Plan-derived inputs across every @@ -1071,7 +1071,7 @@ mod tests { ]; for (name, input, expected) in cases { - let psbt = run_sighash_case(input, PsbtParams::default())?; + let psbt = run_sighash_case(input, BuildPsbtParams::default())?; assert_eq!(psbt.inputs[0].sighash_type, expected, "{name}"); } Ok(()) From 4c0262d9cca692c461fcd96b46793c9791d91c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 26 Jun 2026 07:11:00 +0000 Subject: [PATCH 19/24] refactor!: Replace `Selector` with a single `into_selection` pass Fold the whole selection lifecycle into `InputCandidates::into_selection` and remove the public `Selector` struct. Algorithms now operate directly on a `bdk_coin_select::CoinSelector` plus a pure-data `SelectionContext`, instead of a wrapper that conflated lifecycle plumbing with the algorithm handle. - `SelectorParams`/`SelectorError` -> `SelectionParams`/`SelectionError`. - Unify the long-term feerate into one `SelectionParams::longterm_feerate` (`None` = target feerate), feeding both the change policy and the bnb metric; the change policy is now always waste-aware. - `IntoSelectionError` carries distinct `CannotMeetTarget { missing }` (impossible) vs `AlgorithmFellShort` (algorithm under-selected) variants. - Fix the reachability pre-check to select effective inputs at the resolved target feerate (`target.fee.rate`), not the raw one, avoiding a false `CannotMeetTarget` under RBF. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/anti_fee_sniping.rs | 12 +- examples/synopsis.rs | 20 +-- src/input_candidates.rs | 197 ++++++++++++++++++------- src/selector.rs | 268 +++++++++-------------------------- src/tx_template.rs | 18 +-- 5 files changed, 236 insertions(+), 279 deletions(-) diff --git a/examples/anti_fee_sniping.rs b/examples/anti_fee_sniping.rs index a926a49e..b0613c0d 100644 --- a/examples/anti_fee_sniping.rs +++ b/examples/anti_fee_sniping.rs @@ -2,7 +2,7 @@ use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ filter_unspendable, group_by_spk, selection_algorithm_lowest_fee_bnb, BuildPsbtParams, Output, - SelectorParams, + SelectionParams, }; use bitcoin::{absolute::LockTime, key::Secp256k1, Amount, FeeRate}; use miniscript::Descriptor; @@ -73,11 +73,11 @@ fn main() -> anyhow::Result<()> { .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_time))) .into_tx_template( - selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), - SelectorParams { - // For waste optimization when deciding change. - change_longterm_feerate: Some(longterm_feerate), - ..SelectorParams::new( + 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(), diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 2ce70129..0283f79b 100644 --- a/examples/synopsis.rs +++ b/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, BuildPsbtParams, Output, - SelectorParams, Signer, + SelectionParams, Signer, }; use bitcoin::{key::Secp256k1, Amount, FeeRate}; use miniscript::Descriptor; @@ -53,11 +53,11 @@ fn main() -> anyhow::Result<()> { .regroup(group_by_spk()) .filter(filter_unspendable(tip_height, Some(tip_mtp))) .into_tx_template( - selection_algorithm_lowest_fee_bnb(longterm_feerate, 100_000), - SelectorParams { - // For waste-optimization when deciding change. - change_longterm_feerate: Some(longterm_feerate), - ..SelectorParams::new( + 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(), @@ -122,8 +122,8 @@ fn main() -> anyhow::Result<()> { // Do coin 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"), @@ -135,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. diff --git a/src/input_candidates.rs b/src/input_candidates.rs index a9bacc7d..4ada574a 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -1,14 +1,14 @@ use alloc::{vec, vec::Vec}; use core::fmt; -use bdk_coin_select::{metrics::LowestFee, Candidate, InsufficientFunds, NoBnbSolution}; -use bitcoin::{absolute, FeeRate, OutPoint}; +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::{ - CannotMeetTarget, FeeRateExt, Input, InputGroup, Selector, SelectorError, SelectorParams, + FeeRateExt, Input, InputGroup, Output, SelectionContext, SelectionError, SelectionParams, TxTemplate, }; @@ -272,46 +272,143 @@ impl InputCandidates { None } - /// Run coin selection with `algorithm` and selector `params`, returning a [`TxTemplate`]. + /// 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::Setup`] if the change policy cannot be built or 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: SelectorParams, + params: SelectionParams, ) -> Result> where - A: FnMut(&mut Selector) -> Result<(), E>, + A: FnOnce(&mut CoinSelector, SelectionContext) -> Result<(), E>, { - let mut selector = Selector::new(&self, params).map_err(IntoTxTemplateError::Selector)?; - selector - .select_with_algorithm(algorithm) - .map_err(IntoTxTemplateError::SelectionAlgorithm)?; - selector - .try_finalize() - .ok_or(IntoTxTemplateError::CannotMeetTarget(CannotMeetTarget)) + let target = params.to_cs_target(); + let change_policy = params + .to_cs_change_policy() + .map_err(IntoTxTemplateError::Setup)?; + 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::Setup(SelectionError::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)) } } -/// Occurs when we cannot find a solution for selection. +/// Error returned by [`InputCandidates::into_tx_template`]. +/// +/// Covers every way the lifecycle can fail: setup/validation, an impossible target, a failing +/// algorithm, or an algorithm that finished without meeting the target. #[derive(Debug)] pub enum IntoTxTemplateError { - /// Coin selector returned an error - Selector(SelectorError), - /// Selection algorithm failed. - SelectionAlgorithm(E), - /// The target cannot be met - CannotMeetTarget(CannotMeetTarget), + /// Setting up the selection failed (invalid change policy or incompatible timelock units). + Setup(SelectionError), + /// 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::Selector(error) => { - write!(f, "{error}") - } - IntoTxTemplateError::SelectionAlgorithm(error) => { + IntoTxTemplateError::Setup(error) => write!(f, "{error}"), + 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::CannotMeetTarget(error) => write!(f, "{error}"), + IntoTxTemplateError::AlgorithmFellShort => write!( + f, + "the selection algorithm returned successfully but did not meet the target" + ), } } } @@ -319,26 +416,24 @@ impl fmt::Display for IntoTxTemplateError { #[cfg(feature = "std")] impl std::error::Error for IntoTxTemplateError {} -/// Select for lowest fee with bnb +/// 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( - 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(|_| ()) +) -> 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(|_| ()) } } @@ -347,23 +442,19 @@ pub fn selection_algorithm_lowest_fee_bnb( /// /// The `rng` is carried by the returned algorithm, so candidate construction stays deterministic /// and randomness lives at the selection step. Pass the result to -/// [`Selector::select_with_algorithm`]. -/// -/// [`Selector::select_with_algorithm`]: crate::Selector::select_with_algorithm +/// [`InputCandidates::into_tx_template`]. pub fn selection_algorithm_single_random_draw( rng: &mut impl RngCore, -) -> impl FnMut(&mut Selector) -> Result<(), InsufficientFunds> + '_ { - move |selector| { +) -> 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 = selector.inner().candidates().len(); + let n = cs.candidates().len(); let keys: Vec = (0..n).map(|_| rng.next_u64()).collect(); - selector - .inner_mut() - .sort_candidates_by_key(|(i, _)| keys[i]); - selector.select_until_target_met() + cs.sort_candidates_by_key(|(i, _)| keys[i]); + cs.select_until_target_met(cx.target) } } diff --git a/src/selector.rs b/src/selector.rs index f40ef8a9..db94901c 100644 --- a/src/selector.rs +++ b/src/selector.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, - TxTemplate, -}; +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(|_| SelectionError::InsufficientAssets), None => descriptor .max_weight_to_satisfy() - .map_err(SelectorError::Miniscript), + .map_err(SelectionError::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,11 @@ impl SelectorParams { /// /// # Errors /// - /// Returns [`SelectorError::InsufficientAssets`] if the provided assets cannot satisfy the + /// Returns [`SelectionError::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 [`SelectionError::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,60 +324,45 @@ 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) - }, - ) - } -} - -/// 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" - ) + // 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(), + )) } } -#[cfg(feature = "std")] -impl std::error::Error for CannotMeetTarget {} - -/// Selector error +/// Error setting up a selection from [`SelectionParams`]. +/// +/// These are failures that occur before any selection algorithm runs: an unsatisfiable change +/// descriptor or an unbuildable set of input candidates. Target-reachability failures are reported +/// separately by [`InputCandidates::into_selection`](crate::InputCandidates::into_selection). #[derive(Debug)] -pub enum SelectorError { +pub enum SelectionError { /// 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`]. + /// Filter the [`InputCandidates`](crate::InputCandidates) down to a single-unit subset before + /// selecting. LockTypeMismatch, } -impl fmt::Display for SelectorError { +impl fmt::Display for SelectionError { 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") } @@ -381,134 +374,7 @@ impl fmt::Display for SelectorError { } #[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 (build_psbt, apply_anti_fee_sniping) rely on this invariant. - let mut unit: Option = None; - for lt in candidates.inputs().filter_map(Input::absolute_timelock) { - match unit { - 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 to finalize the selection into a [`TxTemplate`]. - /// - /// Returns `None` if the target is not yet met. - pub fn try_finalize(&self) -> Option { - if !self.inner.is_target_met(self.target) { - return None; - } - 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(TxTemplate::new(inputs, outputs)) - } -} +impl std::error::Error for SelectionError {} #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] @@ -545,18 +411,22 @@ 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), ); + let result = candidates.into_tx_template( + |_cs, _cx| Result::<(), core::convert::Infallible>::Ok(()), + params, + ); assert!(matches!( - Selector::new(&candidates, params), - Err(SelectorError::LockTypeMismatch) + result, + Err(IntoTxTemplateError::Setup(SelectionError::LockTypeMismatch)) )); Ok(()) } diff --git a/src/tx_template.rs b/src/tx_template.rs index f1734b3e..79a5d72e 100644 --- a/src/tx_template.rs +++ b/src/tx_template.rs @@ -1,12 +1,10 @@ //! Tx-shaping stage between coin selection and the final [`Psbt`] or [`Transaction`]. //! -//! A [`TxTemplate`] is obtained from [`Selector::try_finalize`] or -//! [`InputCandidates::into_tx_template`], then mutated (sort, shuffle, 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. +//! 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. //! -//! [`Selector::try_finalize`]: crate::Selector::try_finalize //! [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template //! [`Transaction`]: bitcoin::Transaction @@ -238,8 +236,7 @@ impl SealedTxTemplate { /// A fully-resolved tx shape — the workspace between coin selection and the final [`Psbt`] /// or [`Transaction`]. /// -/// Typically obtained from [`Selector::try_finalize`] (or -/// [`InputCandidates::into_tx_template`]). Exposes the operations that *shape* the resulting +/// 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. @@ -250,7 +247,6 @@ impl SealedTxTemplate { /// New templates start with `version = TWO`, `lock_time = max(input CLTVs)` (or `ZERO`), and /// `fallback_sequence = ENABLE_RBF_NO_LOCKTIME`. /// -/// [`Selector::try_finalize`]: crate::Selector::try_finalize /// [`InputCandidates::into_tx_template`]: crate::InputCandidates::into_tx_template #[derive(Debug, Clone)] #[must_use] @@ -462,7 +458,7 @@ impl TxTemplate { /// # Panics /// /// In debug builds, panics if inputs have CLTVs of different units (height vs. time). -/// `Selector::new` rejects such candidates upstream, so this should never fire in practice. +/// `into_tx_template` rejects such candidates upstream, so this should never fire in practice. fn max_input_cltv(inputs: &[Input]) -> Option { inputs .iter() @@ -470,7 +466,7 @@ fn max_input_cltv(inputs: &[Input]) -> Option { .reduce(|a, b| { debug_assert!( a.is_same_unit(b), - "Selector::new should reject mixed-unit candidates", + "into_tx_template should reject mixed-unit candidates", ); if a.is_implied_by(b) { b From 2de1dce27e0c922d8fbfb1b46828fe90c3364a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 26 Jun 2026 15:38:26 +0000 Subject: [PATCH 20/24] refactor!: Split setup errors into `ChangePolicyError` + `LockTypeMismatch` `SelectionError` lumped change-policy construction failures together with candidate timelock validation, so `to_cs_change_policy` advertised a `LockTypeMismatch` variant it could never return. - `SelectionError` -> `ChangePolicyError` (just `Miniscript` / `InsufficientAssets`), now the honest return type of `to_cs_change_policy`. - `IntoSelectionError::Setup(_)` -> `ChangePolicy(ChangePolicyError)`, and `LockTypeMismatch` is promoted to a top-level `IntoSelectionError` variant. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 28 ++++++++++++++++++---------- src/selector.rs | 41 ++++++++++++++--------------------------- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 4ada574a..8827c084 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -8,7 +8,7 @@ use rand_core::RngCore; use crate::collections::{BTreeMap, HashSet}; use crate::{ - FeeRateExt, Input, InputGroup, Output, SelectionContext, SelectionError, SelectionParams, + ChangePolicyError, FeeRateExt, Input, InputGroup, Output, SelectionContext, SelectionParams, TxTemplate, }; @@ -281,8 +281,9 @@ impl InputCandidates { /// /// # Errors /// - /// - [`IntoTxTemplateError::Setup`] if the change policy cannot be built or the candidates have - /// incompatible absolute timelock units. + /// - [`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. @@ -299,7 +300,7 @@ impl InputCandidates { let target = params.to_cs_target(); let change_policy = params .to_cs_change_policy() - .map_err(IntoTxTemplateError::Setup)?; + .map_err(IntoTxTemplateError::ChangePolicy)?; let longterm_feerate = params .longterm_feerate .unwrap_or(params.target_feerate) @@ -314,7 +315,7 @@ impl InputCandidates { match unit { Some(existing_unit) => { if !existing_unit.is_same_unit(lt) { - return Err(IntoTxTemplateError::Setup(SelectionError::LockTypeMismatch)); + return Err(IntoTxTemplateError::LockTypeMismatch); } } None => unit = Some(lt), @@ -372,12 +373,16 @@ impl InputCandidates { /// Error returned by [`InputCandidates::into_tx_template`]. /// -/// Covers every way the lifecycle can fail: setup/validation, an impossible target, a failing -/// algorithm, or an algorithm that finished without meeting the target. +/// 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 { - /// Setting up the selection failed (invalid change policy or incompatible timelock units). - Setup(SelectionError), + /// 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 { @@ -397,7 +402,10 @@ pub enum IntoTxTemplateError { impl fmt::Display for IntoTxTemplateError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - IntoTxTemplateError::Setup(error) => write!(f, "{error}"), + 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" diff --git a/src/selector.rs b/src/selector.rs index db94901c..39483aca 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -152,7 +152,7 @@ impl ChangeScript { } } - fn satisfaction_weight(&self) -> Result { + fn satisfaction_weight(&self) -> Result { match &self { ChangeScript::Script { satisfaction_weight, @@ -166,10 +166,10 @@ impl ChangeScript { .clone() .plan(assets) .map(|p| Weight::from_wu_usize(p.satisfaction_weight())) - .map_err(|_| SelectionError::InsufficientAssets), + .map_err(|_| ChangePolicyError::InsufficientAssets), None => descriptor .max_weight_to_satisfy() - .map_err(SelectionError::Miniscript), + .map_err(ChangePolicyError::Miniscript), }, } } @@ -295,11 +295,12 @@ impl SelectionParams { /// /// # Errors /// - /// Returns [`SelectionError::InsufficientAssets`] if the provided assets cannot satisfy the + /// Returns [`ChangePolicyError::InsufficientAssets`] if the provided assets cannot satisfy the /// change descriptor. /// - /// Returns [`SelectionError::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(), @@ -339,42 +340,31 @@ impl SelectionParams { } } -/// Error setting up a selection from [`SelectionParams`]. +/// Error building the change policy from [`SelectionParams`]. /// -/// These are failures that occur before any selection algorithm runs: an unsatisfiable change -/// descriptor or an unbuildable set of input candidates. Target-reachability failures are reported -/// separately by [`InputCandidates::into_selection`](crate::InputCandidates::into_selection). +/// Returned by [`SelectionParams::to_cs_change_policy`]; every variant stems from the change +/// descriptor being unsatisfiable with the available assets. #[derive(Debug)] -pub enum SelectionError { +pub enum ChangePolicyError { /// Miniscript error (e.g. the change descriptor is inherently unsatisfiable). Miniscript(miniscript::Error), /// 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`](crate::InputCandidates) down to a single-unit subset before - /// selecting. - LockTypeMismatch, } -impl fmt::Display for SelectionError { +impl fmt::Display for ChangePolicyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Miniscript(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 SelectionError {} +impl std::error::Error for ChangePolicyError {} #[cfg_attr(coverage_nightly, coverage(off))] #[cfg(test)] @@ -424,10 +414,7 @@ mod tests { |_cs, _cx| Result::<(), core::convert::Infallible>::Ok(()), params, ); - assert!(matches!( - result, - Err(IntoTxTemplateError::Setup(SelectionError::LockTypeMismatch)) - )); + assert!(matches!(result, Err(IntoTxTemplateError::LockTypeMismatch))); Ok(()) } } From 3d496069f4bc389273ef0163926a8f0cd6e1f56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 26 Jun 2026 15:44:30 +0000 Subject: [PATCH 21/24] refactor: Rename selector.rs to selection_params.rs The module no longer holds a `Selector`; it now holds `SelectionParams` and its supporting config/context/error types. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib.rs | 4 ++-- src/{selector.rs => selection_params.rs} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{selector.rs => selection_params.rs} (100%) diff --git a/src/lib.rs b/src/lib.rs index 8a50a5b6..696a1f27 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,7 @@ mod input_candidates; mod no_std_rand; mod output; mod rbf; -mod selector; +mod selection_params; mod signer; mod tx_template; @@ -36,7 +36,7 @@ use miniscript::{DefiniteDescriptorKey, Descriptor}; use no_std_rand::*; pub use output::*; pub use rbf::*; -pub use selector::*; +pub use selection_params::*; pub use signer::*; pub use tx_template::*; diff --git a/src/selector.rs b/src/selection_params.rs similarity index 100% rename from src/selector.rs rename to src/selection_params.rs From b5eca4e5863f928264b0326b34bb628b5a6b5e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 27 Jun 2026 03:37:36 +0000 Subject: [PATCH 22/24] style: rustfmt import after Selector/into_selection merge Co-Authored-By: Claude Opus 4.8 (1M context) --- src/input_candidates.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 8827c084..a9f4d02a 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -1,7 +1,9 @@ use alloc::{vec, vec::Vec}; use core::fmt; -use bdk_coin_select::{metrics::LowestFee, Candidate, CoinSelector, InsufficientFunds, NoBnbSolution}; +use bdk_coin_select::{ + metrics::LowestFee, Candidate, CoinSelector, InsufficientFunds, NoBnbSolution, +}; use bitcoin::{absolute, Amount, OutPoint}; use miniscript::bitcoin; use rand_core::RngCore; From 0010fe441759f9079894d1a800fdbf06d51fd14d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 27 Jun 2026 03:39:47 +0000 Subject: [PATCH 23/24] refactor: convert repo into a Cargo workspace Move the bdk_tx crate under tx/ and add a workspace root so a sibling bdk_wallet_tx crate can live under wallet_tx/. CI feature flags are scoped to -p bdk_tx (no_std) and --workspace (all-features). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/rust.yml | 8 ++-- Cargo.toml | 46 ++----------------- CHANGELOG.md => tx/CHANGELOG.md | 0 tx/Cargo.toml | 43 +++++++++++++++++ README.md => tx/README.md | 0 {examples => tx/examples}/anti_fee_sniping.rs | 0 {examples => tx/examples}/common.rs | 0 {examples => tx/examples}/synopsis.rs | 0 {src => tx/src}/afs.rs | 0 {src => tx/src}/build_psbt.rs | 0 {src => tx/src}/canonical_unspents.rs | 0 {src => tx/src}/finalizer.rs | 0 {src => tx/src}/input.rs | 0 {src => tx/src}/input_candidates.rs | 0 {src => tx/src}/lib.rs | 0 {src => tx/src}/no_std_rand.rs | 0 {src => tx/src}/output.rs | 0 {src => tx/src}/rbf.rs | 0 {src => tx/src}/selection_params.rs | 0 {src => tx/src}/signer.rs | 0 {src => tx/src}/tx_template.rs | 0 {tests => tx/tests}/psbt.rs | 0 22 files changed, 50 insertions(+), 47 deletions(-) rename CHANGELOG.md => tx/CHANGELOG.md (100%) create mode 100644 tx/Cargo.toml rename README.md => tx/README.md (100%) rename {examples => tx/examples}/anti_fee_sniping.rs (100%) rename {examples => tx/examples}/common.rs (100%) rename {examples => tx/examples}/synopsis.rs (100%) rename {src => tx/src}/afs.rs (100%) rename {src => tx/src}/build_psbt.rs (100%) rename {src => tx/src}/canonical_unspents.rs (100%) rename {src => tx/src}/finalizer.rs (100%) rename {src => tx/src}/input.rs (100%) rename {src => tx/src}/input_candidates.rs (100%) rename {src => tx/src}/lib.rs (100%) rename {src => tx/src}/no_std_rand.rs (100%) rename {src => tx/src}/output.rs (100%) rename {src => tx/src}/rbf.rs (100%) rename {src => tx/src}/selection_params.rs (100%) rename {src => tx/src}/signer.rs (100%) rename {src => tx/src}/tx_template.rs (100%) rename {tests => tx/tests}/psbt.rs (100%) 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 3edd3539..77e63748 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 } - -[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" +[workspace] +members = ["tx"] +resolver = "2" 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/README.md b/tx/README.md similarity index 100% rename from README.md rename to tx/README.md diff --git a/examples/anti_fee_sniping.rs b/tx/examples/anti_fee_sniping.rs similarity index 100% rename from examples/anti_fee_sniping.rs rename to tx/examples/anti_fee_sniping.rs 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 100% rename from examples/synopsis.rs rename to tx/examples/synopsis.rs diff --git a/src/afs.rs b/tx/src/afs.rs similarity index 100% rename from src/afs.rs rename to tx/src/afs.rs diff --git a/src/build_psbt.rs b/tx/src/build_psbt.rs similarity index 100% rename from src/build_psbt.rs rename to tx/src/build_psbt.rs 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 100% rename from src/finalizer.rs rename to tx/src/finalizer.rs diff --git a/src/input.rs b/tx/src/input.rs similarity index 100% rename from src/input.rs rename to tx/src/input.rs diff --git a/src/input_candidates.rs b/tx/src/input_candidates.rs similarity index 100% rename from src/input_candidates.rs rename to tx/src/input_candidates.rs diff --git a/src/lib.rs b/tx/src/lib.rs similarity index 100% rename from src/lib.rs rename to tx/src/lib.rs 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/selection_params.rs b/tx/src/selection_params.rs similarity index 100% rename from src/selection_params.rs rename to tx/src/selection_params.rs diff --git a/src/signer.rs b/tx/src/signer.rs similarity index 100% rename from src/signer.rs rename to tx/src/signer.rs diff --git a/src/tx_template.rs b/tx/src/tx_template.rs similarity index 100% rename from src/tx_template.rs rename to tx/src/tx_template.rs 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 From 30c0a44d4bc6b39af849659932a72a38deb6e99e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sat, 27 Jun 2026 07:56:54 +0000 Subject: [PATCH 24/24] feat: add bdk_wallet_tx bridge crate Bridge between bdk_wallet and bdk_tx, exposed as a `WalletTxExt` extension trait on `bdk_wallet::Wallet`. Keeping it in its own crate means neither base crate depends on the other: `bdk_wallet` stays stable, `bdk_tx` stays free to move, and the bridge absorbs the coupling (the decoupling argument from bitcoindevkit/bdk_wallet#297). Three-stage pipeline: - `candidates`/`candidates_with`/`rbf_candidates` -> `CandidateSet` (stage 1). - `select` -> `(TxTemplate, Option)` (stage 2): a pure read over a borrowed `&CandidateSet` (re-runnable) that peeks the auto-derived change address; commit it with `commit_change` (reveal + mark used) when you use the tx, or ignore it on abandon. The caller supplies the RNG. Emit the PSBT directly via `bdk_tx::TxTemplate::build_psbt`. - `add_global_xpubs` fills the PSBT's global xpubs (the only emission step that needs the wallet). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 2 +- README.md | 25 ++ wallet_tx/Cargo.toml | 19 + wallet_tx/README.md | 59 +++ wallet_tx/examples/three_stage.rs | 93 ++++ wallet_tx/src/candidates.rs | 148 +++++++ wallet_tx/src/error.rs | 168 ++++++++ wallet_tx/src/lib.rs | 676 ++++++++++++++++++++++++++++++ wallet_tx/src/params.rs | 276 ++++++++++++ wallet_tx/tests/three_stage.rs | 332 +++++++++++++++ 10 files changed, 1797 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 wallet_tx/Cargo.toml create mode 100644 wallet_tx/README.md create mode 100644 wallet_tx/examples/three_stage.rs create mode 100644 wallet_tx/src/candidates.rs create mode 100644 wallet_tx/src/error.rs create mode 100644 wallet_tx/src/lib.rs create mode 100644 wallet_tx/src/params.rs create mode 100644 wallet_tx/tests/three_stage.rs diff --git a/Cargo.toml b/Cargo.toml index 77e63748..fd4972fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["tx"] +members = ["tx", "wallet_tx"] resolver = "2" diff --git a/README.md b/README.md new file mode 100644 index 00000000..45a2ffce --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# bdk-tx workspace + +A Cargo workspace with two crates: + +- [`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). + +`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. + +## Building + +```sh +cargo build --workspace +cargo test --workspace +``` + +`bdk_tx` additionally supports `no_std`: + +```sh +cargo check -p bdk_tx --no-default-features --features miniscript/no-std +``` 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(()) +}