Skip to content

Implement create_psbt for Wallet#297

Open
ValuedMammal wants to merge 10 commits into
bitcoindevkit:masterfrom
ValuedMammal:feat/create_psbt
Open

Implement create_psbt for Wallet#297
ValuedMammal wants to merge 10 commits into
bitcoindevkit:masterfrom
ValuedMammal:feat/create_psbt

Conversation

@ValuedMammal

@ValuedMammal ValuedMammal commented Aug 12, 2025

Copy link
Copy Markdown
Collaborator

Description

This PR introduces a new PSBT construction flow and associated wallet APIs.

Primary additions:

  • New PsbtParams<Ctx> API with typed contexts (CreateTx and ReplaceTx) for transaction creation and replacement.
  • New wallet entry points for PSBT construction and RBF:
    • Wallet::create_psbt and Wallet::create_psbt_with_rng
    • Wallet::replace_by_fee, Wallet::replace_by_fee_with_rng, and Wallet::replace_by_fee_and_recipients
  • New error types for the new create-PSBT and RBF paths.
  • Supporting wallet helpers for transaction insertion and canonicalization-aware transaction listing.
  • New examples and expanded test coverage for expected PSBT/RBF behavior.
  • Generic TxOrdering<In, Out> updates used by the new selection/PSBT path

This work is part of the broader migration of transaction construction to bdk_tx, which uses miniscript's plan module under the hood. A key long-term goal is the move away from legacy policy-based transaction construction internals (#4).

fix #204

Notes to the reviewers

Suggested review order for this PR:

  1. Start with examples/psbt.rs and examples/replace_by_fee.rs for a high-level API walkthrough.
  2. Review src/psbt/params.rs in detail (types, state transitions, params semantics, and so on).
  3. Review the new wallet implementation in src/wallet/mod.rs (create-PSBT and RBF execution paths plus shared helpers).
  4. Review tests in tests/psbt.rs, tests/wallet.rs to ensure expected behavior and edge cases are covered.

Changelog notice

Changed

  • TxOrdering::Custom is generic over the input and output types.

Added

  • psbt::params module
  • PsbtParams struct
  • CoinSelectionStrategy enum
  • UtxoFilter struct

Added pub methods on Wallet

  • Wallet::create_psbt{_with_rng}
  • Wallet::replace_by_fee{_with_rng}
  • Wallet::replace_by_fee_and_recipients

Added examples

  • examples/psbt.rs
  • examples/replace_by_fee.rs

Checklists

@ValuedMammal ValuedMammal moved this to In Progress in BDK Wallet Aug 12, 2025
@ValuedMammal ValuedMammal added this to the Wallet 3.0.0 milestone Aug 12, 2025
@ValuedMammal ValuedMammal self-assigned this Aug 12, 2025
@coveralls

coveralls commented Aug 26, 2025

Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 19016316365

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 524 of 656 (79.88%) changed or added relevant lines in 5 files are covered.
  • 299 unchanged lines in 8 files lost coverage.
  • Overall coverage decreased (-0.03%) to 84.779%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/wallet/tx_builder.rs 7 12 58.33%
src/wallet/error.rs 0 19 0.0%
src/wallet/mod.rs 317 357 88.8%
src/psbt/params.rs 183 251 72.91%
Files with Coverage Reduction New Missed Lines %
src/wallet/signer.rs 3 81.11%
src/wallet/params.rs 9 76.24%
src/descriptor/template.rs 10 97.46%
src/descriptor/mod.rs 13 88.04%
src/descriptor/dsl.rs 16 95.36%
src/keys/mod.rs 69 79.49%
src/descriptor/policy.rs 89 79.17%
src/wallet/mod.rs 90 84.1%
Totals Coverage Status
Change from base Build 18318022693: -0.03%
Covered Lines: 7536
Relevant Lines: 8889

💛 - Coveralls

@thunderbiscuit

Copy link
Copy Markdown
Member

I like the create_psbt name for the function. The Params struct I need to do a quick search but off the top of my head it feels like we have a few of those scattered across the crates named exactly that, so it gets confusing. In my exploration PR I used TransactionParams, but that's a bit long and verbose.

What do you envision the API for replace-by-fee transactions look like in this new bdk-tx world? I'm picturing something like Wallet::create_replacement_psbt which takes a WalletTx and another Params struct.

@ValuedMammal ValuedMammal moved this from In Progress to Needs Review in BDK Wallet Sep 11, 2025
@ValuedMammal ValuedMammal marked this pull request as ready for review September 11, 2025 15:39
@ValuedMammal ValuedMammal changed the title [WIP] Implement create_psbt for Wallet Implement create_psbt for Wallet Sep 11, 2025

@thunderbiscuit thunderbiscuit left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is more of a conceptual review/question set, as I'm getting acquainted with the PR, and it also requires knowledge and understanding of the bdk-tx crate/workflow.

One general question I have is do you think is missing in functionality between this and the current TxBuilder? Can we make a todo list that compares functionality with the current TxBuilder to better visualize how close of a replacement this is, or if it only provides part of the functionality for now (and if so which parts)?

I haven't had time to look/test the examples and my day is over, but I'll come back to this on Monday.

Comment thread src/wallet/mod.rs
Comment thread src/psbt/params.rs Outdated
Comment thread src/psbt/params.rs
Comment thread src/psbt/params.rs Outdated
Comment thread src/psbt/params.rs Outdated
Comment thread src/wallet/mod.rs Outdated

@oleonardolima oleonardolima left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did an initial review, with simple comments/questions. Still need to take a deeper look into bdk-tx and do a more thorough review here.

Comment thread wallet/Cargo.toml Outdated
Comment thread src/wallet/error.rs
Comment on lines +264 to +267
/// No Bnb solution.
Bnb(bdk_coin_select::NoBnbSolution),
/// Non-sufficient funds
InsufficientFunds(bdk_coin_select::InsufficientFunds),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we have a generic CS error instead ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on what you mean by generic CS error?

Comment thread src/wallet/mod.rs Outdated
@ValuedMammal

Copy link
Copy Markdown
Collaborator Author

Thank you for the quick review @thunderbiscuit @oleonardolima.

@ValuedMammal

ValuedMammal commented Sep 20, 2025

Copy link
Copy Markdown
Collaborator Author

TxBuilder vs PsbtParams feature comparison

subject to change

Feature TxBuilder PsbtParams Status
internal_policy_path NA
external_policy_path NA
manually_selected_only Done
include_output_redeem_witness_script NA
current_height Done
fee_rate Done
fee_absolute _
sighash (type) Done
ordering Done
add_global_xpubs Done
allow_dust _
unspendable NA
recipients Done
utxos Done
drain_wallet Done
drain_to/change_script Done
locktime Done
version Done
fallback_sequence Done
bumping_fee Done
only_witness_utxo Done
change_policy Done
add_foreign_utxo / planned_input Done
filter_utxos Done

N/A:
- *_policy_path: deprecated, use add_assets instead
- include_output_redeem_witness_script: deprecated, no longer used
- unspendable: NA, refer to filter_utxos
- change_policy: NA, refer to filter_utxos

@ValuedMammal

ValuedMammal commented Oct 1, 2025

Copy link
Copy Markdown
Collaborator Author
  • We need a way to implement fee_absolute or find out if this is possible using bdk_coin_select.
  • Note that UtxoFilter can accomplish a number of things from the old interface, such as "change policy" and "unspendable".
  • To prevent multiple parameters from trying to dictate the current height, the current_height option can be achieved via the assets.absolute_timelock.
  • current_height renamed to maturity_height, as it's only used as a parameter to is_mature.

@ValuedMammal

Copy link
Copy Markdown
Collaborator Author

We need a way to implement fee_absolute or find out if this is possible using bdk_coin_select.

I think a straightforward approach is to set a target feerate of 0 and add the fee amount to the output value_sum. The effect is to raise the cost of meeting the target by the amount of the target fee.

I can see it being relevant to CPFP, since there we're less concerned about the feerate of an individual tx and more so with the fee needed to bump a package to a target feerate.

@notmandatory notmandatory left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of great work here, I mainly focused my review on the examples, APIs and confirmed no breaking changes, I only have a couple questions and a small nit to fix. I also ran a couple different LLM models at it and they gave it high marks without finding any significant issues.

Comment thread src/psbt/params.rs Outdated
///
/// Panics if `txs` is empty.
pub fn replace_txs(self, txs: &[Arc<Transaction>]) -> PsbtParams<Rbf> {
assert!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we return a result here instead of this assert? Would rather not have a panic caused by user input validation.

Comment thread tests/psbt.rs Outdated
// Test that replacing two unconfirmed txs A, B results in a transaction
// that spends the inputs of both A and B.
#[test]
fn test_replace_by_fee_and_recpients() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit ⛏️

Suggested change
fn test_replace_by_fee_and_recpients() {
fn test_replace_by_fee_and_recipients() {

Comment thread src/psbt/params.rs Outdated
impl PsbtParams<Rbf> {
/// Replace spends of the provided `txs`. This will internally set the list of UTXOs
/// to be spent.
fn replace(&mut self, txs: &[Arc<Transaction>]) {

@notmandatory notmandatory Jun 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the txs param need to be an &[Arc<Transaction>] ? can it be &[Transaction] since the code below on 528 is cloning the inner ref and not the Arc so there is no additional efficiency.

Same comment for the callers PsbtParams<>::replace_txs() and Wallet::replace_by_fee_and_recipients().

@ValuedMammal

Copy link
Copy Markdown
Collaborator Author

Thanks @notmandatory

…ansaction>>

The empty txs case is now caught in `replace_by_fee_with_rng`,
which returns `ReplaceByFeeError::NoOriginalTransactions` when
`params.replace` is empty.

Adds a test in `tests/psbt.rs` covering the error path.
Comment thread examples/psbt.rs Outdated
Comment thread tests/psbt.rs Outdated
Comment thread src/psbt/params.rs Outdated
Comment on lines +389 to +394
pub fn add_planned_input(&mut self, input: Input) -> &mut Self {
if self.set.insert(input.prev_outpoint()) {
self.inputs.push(input);
}
self
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got a bit confused on the usage of both self.utxos and self.set, shouldn't the input outpoint be addded to self.utxos here too ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly then, utxos corresponds to local UTXOs and inputs corresponds to foreign UTXOs in the old TxBuilder and set is a hashset of all manually selected utxos (local + foreign) for quick membership checks.

But then do we plan to provide the old invariants? By old invariants I mean,
"Foreign UTXOs are not prioritized over local UTXOs. If a local UTXO is added to the manually selected list, it will replace any conflicting foreign UTXOs. However, a foreign UTXO cannot replace a conflicting local UTXO."

Comment thread src/wallet/mod.rs Outdated
///
/// [relevance]: Indexer::is_tx_relevant
/// [indexer]: Self::spk_index
pub fn insert_tx<T>(&mut self, tx: T)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a MUST to go together with create_psbt ? I'd move this to another PR instead.

Does it also need a matching (or existing) WalletEvent (by using apply_update_events) ?

Also, having the .expect() in a public API feels a bit odd.

Comment thread src/wallet/mod.rs
Comment on lines +3174 to +3192
match params.coin_selection {
SelectionStrategy::SingleRandomDraw => {
// We should have shuffled candidates earlier, so just select
// until the target is met.
selector
.select_until_target_met()
.map_err(CreatePsbtError::InsufficientFunds)?;
}
SelectionStrategy::LowestFee {
longterm_feerate,
max_rounds,
} => {
selector
.select_with_algorithm(selection_algorithm_lowest_fee_bnb(
longterm_feerate,
max_rounds,
))
.map_err(CreatePsbtError::Bnb)?;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: the user does not have the option of using a customized CS algorithm anymore ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/wallet/mod.rs
///
/// [`Height`]: bitcoin::absolute::Height
/// [`Time`]: bitcoin::absolute::Time
fn status_from_position(pos: ChainPosition<ConfirmationBlockTime>) -> Option<ConfirmationStatus> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: confirmation_status() would be just fine too, also it could live in utils.rs instead

Comment thread src/wallet/mod.rs
}

/// List indexed [`FullTxOut`]s.
fn list_indexed_txouts(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd suggest renaming it to list_txouts and reuse it in the already existing list_output method.

@nymius nymius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need another round of review yet, but these are the things I've found so far.

Comment thread src/wallet/tx_builder.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the feature unlocked by this change?

Comment thread src/psbt/params.rs
/// Parameters to create a PSBT.
// TODO: Can we derive `Clone` for this?
#[derive(Debug)]
pub struct PsbtParams<C> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking in perspective to the motivation for bdk-tx, why this is better than TxBuilder? What's the difference?

@notmandatory notmandatory Jun 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two advantages I see are:

  1. bdk-tx uses the rust-miniscript planning module instead of the hard to use Policy approach in TxBuilder.
  2. By extracting bdk-tx into a new crate it can be used by any projects using rust-bitcoin and rust-miniscript.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Steve, In the change to bdk-tx, I initially assigned the same weight to the "code complexity" and lack of "flexibility" issues than to Policy, I might have been shortsighted.
Rephrasing my question: how this approach is not going to lead us to the same level of complexity of the old TxBuilder? Or have we assumed the complexity is insurmountable? (that's a valid answer too).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nymius I guess what you mean by "complexity" is "invalid state representation"?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I meant code complexity: the numerous fields, the setters, the long create_tx method and all that.
Or the shape of the code didn't matter that much and the main concern were these invalid state representations.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that some complexity in create_tx cannot be avoided right now. I'm assuming it can be simplified once CanonicalView, it's extract subgraph method and the bdk_tx_core glue lands.

Comment thread src/psbt/params.rs Outdated
impl PsbtParams<Rbf> {
/// Replace spends of the provided `txs`. This will internally set the list of UTXOs
/// to be spent.
fn replace(&mut self, txs: &[Arc<Transaction>]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we cannot create the PsbtParams here, i.e., fn replace(txs: &[Arc<Transaction>]) -> PsbtParams<Rbf>? Is there any parameter that we would like to keep from whatever we had before? I've checked the example and we are not reusing parameters from psbt1, we should?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_replace_params repeats the pattern, it doesn't reuse the old PsbtParams.

Comment thread tests/psbt.rs
}

#[test]
fn test_create_psbt_maturity_height() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A test with no assertions? Shouldn't we check at least the outpoint used belongs to the coinbase tx?

Comment thread tests/add_foreign_utxo.rs
}

#[test]
fn test_add_planned_psbt_input() -> anyhow::Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is anyhow::Result<()> really needed here? It is first introduced by this test. Why not replacing the ? operators by .expect("...")?

Comment thread src/psbt/params.rs
Comment thread src/psbt/params.rs Outdated
Comment thread src/psbt/params.rs Outdated
Comment thread src/psbt/params.rs
Comment on lines +34 to +37
pub(crate) set: HashSet<OutPoint>,
/// List of UTXO outpoints to spend.
pub(crate) utxos: Vec<OutPoint>,
/// List of planned transaction [`Input`]s.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we need both?

Comment thread tests/psbt.rs
fn test_create_psbt_cltv_timestamp() {
use absolute::LockTime;

let lock_time = LockTime::from_consensus(1734230218);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, I would use a constant relative to the unix timepstamp threshold, e.g., LOCKTIME_TIMESTAMP_FLOOR = 500 millions then LockTime::from_consensus(LOCKTIME_TIMESTAMP_FLOOR + 1)

Comment thread tests/psbt.rs

// Locktime greater than required
{
let new_lock_time = 1772167108;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here than above comment.

Comment thread tests/psbt.rs
);

let addr = wallet.next_unused_address(KeychainKind::External);
let rel_locktime = relative::LockTime::from_consensus(6).unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use a different value here, as 6 is the default if nothing set, as line 381 above shows

Comment thread tests/psbt.rs
.add_utxos(&[op])
.add_recipients([(addr.script_pubkey(), Amount::from_btc(0.42).unwrap())]);
let (psbt, _) = wallet.create_psbt(params).unwrap();
assert_eq!(psbt.unsigned_tx.input[0].sequence, Sequence(6));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use a local named constant to point to the place where this value is set.

Comment thread tests/psbt.rs
Comment on lines +714 to +717
// The replacement must pay at least the combined fee of all three transactions
// (Bitcoin Core RBF Rule 3).
let total_original_fee = fee_a + fee_b + fee_c;
assert_eq!(total_original_fee.to_sat(), 90_000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not checking replacement_fee >= fee_a + fee_b + fee_c instead of checking they equal 90_000? Is there any value on doing this? Could the fee be different than 90_000 after how we have set up the txs above?

Comment thread tests/psbt.rs
params.change_script(change_script);
params.drain_wallet();
let (psbt, _) = wallet.create_psbt(params).unwrap();
assert_eq!(psbt.unsigned_tx.input.len(), 2);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can check the inputs used are 600 and 1000 sats each.

Comment thread src/wallet/mod.rs
Comment on lines +3289 to +3295
let params = PsbtParams {
fee_rate,
recipients,
..Default::default()
}
.replace_txs(txs);
self.replace_by_fee_with_rng(params, &mut rand::thread_rng())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really an ergonomic improvement?

Comment thread tests/psbt.rs
insert_checkpoint(&mut wallet, confirm_block);

// Attempting to replace the now-confirmed tx should return TransactionConfirmed.
let result = wallet.replace_by_fee_and_recipients(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reusing the old params here?

params.replace(&[Arc::new(unconfirmed_tx)]).fee_rate(fee_rate)?

This goes in hand with https://github.com/bitcoindevkit/bdk_wallet/pull/297/changes#r3383792188.

Comment thread tests/wallet.rs Outdated

// Test we can select and spend an indexed but not-yet-canonical utxo
#[test]
fn test_spend_non_canonical_txout() -> anyhow::Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with this, where it's said that unconfirmed txs are non canonical? I would consider this different if there existed a conflicting tx, or even a confirmed conflicting tx, but in this case, the tests is checking unconfirmed tx2 and parents belong to the canonical view.

Adds two `CreatePsbtError` variants to cover distinct failure modes
when building a PSBT:

- `NoRecipients`: no recipients were added, or `drain_wallet` was set
    without an explicit `change_script`.

- `AllOutputsBelowDust`: after coin selection the only output fell
    below dust and was dropped to fees, leaving the transaction with
    zero outputs.

Early-exit guards in `create_psbt_with_rng` and `replace_by_fee_with_rng`
return `NoRecipients`. The post-selection guard in `create_psbt_from_selector`
returns `AllOutputsBelowDust`.

Tests:
- `test_create_psbt_no_recipients_error`
- `test_create_psbt_drain_wallet_change_below_dust_error`
- `test_replace_by_fee_drain_wallet_change_below_dust_error`
- Move `add_planned_input` to `impl PsbtParams<CreateTx>`. It was
  previously available on `PsbtParams<C>` (all contexts), allowing
  inputs to be erroneously registered after the state transition.

- Fix `replace()` to preserve pre-registered planned inputs. A `Transaction`
  cannot reconstruct `Input` metadata (psbt fields, satisfaction weight, etc.),
  so planned inputs must be added before calling `replace_txs`. We remove any
  planned input whose `prev_txid` is directly in the replacement set.

- Add `ReplaceByFeeError::ConflictingInput(OutPoint)` in
  `replace_by_fee_with_rng` after computing the full `to_replace` set,
  and validate every manually-selected input against it.

- Add `test_replace_tx_with_planned_input`,
  `test_replace_strips_conflicting_planned_input`, and
  `test_replace_by_fee_conflicting_input_descendant`.
@ValuedMammal ValuedMammal moved this from Needs Review to In Progress in BDK Wallet Jun 14, 2026
@ValuedMammal

ValuedMammal commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @110CodingP

Moved to 3.2.0 as it's still under testing.

Comment thread src/psbt/params.rs
Comment on lines +426 to +430
/// Drain wallet.
///
/// This will force selection of the available input candidates. As such, the option is only
/// applied to inputs that meet the spending criteria.
pub fn drain_wallet(&mut self) -> &mut Self {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps select_all would be a better name for describing what it actually does.

@evanlinjin evanlinjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, what do you think about a staged API?

// Stage 1 — wallet resolves a snapshot of spendable inputs
let coins = wallet.candidates()       // -> InputCandidates
    .with_assets(assets)
    .maturity_height(h)
    .filter(|txo| ...)
    .add_foreign(input)               // planned/foreign inputs
    .replace(txs)?;                   // RBF seeds forced inputs here

// Stage 2 — wallet builds the tx from that set
let (psbt, finalizer) = wallet.create_psbt(coins, CreateTxParams)?;
let (psbt, finalizer) = wallet.create_rbf(coins, RbfParams)?;

Comment thread src/psbt/params.rs
/// Target fee rate.
pub(crate) fee_rate: FeeRate,
/// Whether to spend all available coins.
pub(crate) drain_wallet: bool,

@evanlinjin evanlinjin Jun 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at how drain_wallet functions, I think DrainAll should be a SelectionStrategy (or as you suggested, SelectAll).

Comment thread src/psbt/params.rs
Comment on lines +35 to +38
/// List of UTXO outpoints to spend.
pub(crate) utxos: Vec<OutPoint>,
/// List of planned transaction [`Input`]s.
pub(crate) inputs: Vec<Input>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having both utxos (resolved by wallet) and inputs (external UTXOs), I think there should be an unified enum:

enum InputSpec {
    Owned(OutPoint),   // to be resolved by wallet
    Planned(Input),    // taken as-is
}

This will fix an ordering bug with Untouched - where the order does not respect the order the UTXOs were introduced if we have both wallet-resolved and external inputs.

Comment thread Cargo.toml

[dependencies]
bdk_chain = { version = "0.23.3", features = ["miniscript", "serde"], default-features = false }
bdk_tx = { version = "0.2.0", default-features = false }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is related to this PR, but we should pin bitcoin-units to 0.1.3 or higher. Any lower, and FeeRate::from_sat_per_vb_u32 would not be available.

Comment thread src/psbt/params.rs
#[non_exhaustive]
pub enum SelectionStrategy {
/// Single random draw.
#[default]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue that LowestFee should be the default strategy - as it creates better results.

Comment thread src/psbt/params.rs
///
/// [`UnknownUtxo`]: crate::wallet::error::CreatePsbtError::UnknownUtxo
/// [`Wallet::create_psbt`]: crate::Wallet::create_psbt
pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that a consuming builder pattern has better ergonomics. The current (non-consuming) version, forces the caller to instantiate on a separate line and keep a mutable variable around.

Suggested change
pub fn add_utxos(&mut self, outpoints: &[OutPoint]) -> &mut Self {
pub fn add_utxos(mut self, outpoints: &[OutPoint]) -> Self {

PsbtParams will then have #[must_use].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess a non-consuming builder is slightly better API for someone who needs conditional construction. I'll be happy either way.

Comment thread src/wallet/mod.rs
.iter()
.filter_map(|&txid| {
let tx = self.tx_graph.graph().get_tx(txid)?;
self.calculate_fee(&tx).ok()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the fee cannot be calculated, we may undershoot the RBF fee floor. Maybe we should return an error?

Comment thread src/psbt/params.rs
self
}

/// Set the [`Descriptor`] or raw [`Script`] to be used for generating the change output.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably note that if this is not explicitly specified, the wallet will still derive a change address.

@evanlinjin

Copy link
Copy Markdown
Member

Thanks for pushing this forward. The implementation is solid and surfaced a lot of the tricky cases. Before we commit to an API shape, I want to put an alternative on the table. I built a PoC of it in #502.

I don't think a single create_psbt(PsbtParams) is the right approach — it puts the complexity in the wrong place. bdk_tx already models transaction construction as a staged flow (resolve candidates → select coins → shape a TxTemplate → emit). That shape is deliberate and correct. A single-call wrapper collapses those stages back into one large params type, and that has three consequences worth avoiding:

  1. Invalid or conflicting options only surface at the final call. A single params bag only sorts two things out when create_psbt finally runs: conflicts — an explicit locktime alongside anti_fee_sniping_height, both setting nLockTime, with the precedence hidden in docs — and invalid values — a sequence that violates an input's CSV requirement isn't rejected when you set it; it surfaces only at build time, failing the whole process. The staged flow applies operations in explicit order, and each one (e.g. template.input_mut(op).set_sequence(..)) validates and errors at the call site, leaving the rest of the template intact. It's also a better fit for frontend / interactive use: a UI can resolve candidates once and let the user adjust selection and outputs against a live, inspectable CandidateSet/TxTemplate, surfacing each error inline at the control that caused it instead of failing a whole form at submit.
  2. The wallet ends up re-declaring bdk_tx. Version, locktime, anti-fee-sniping, ordering, shuffling, per-input/fallback sequence — these already exist on bdk_tx's TxTemplate (this PR carries sequence_override/fallback_sequence, for instance). Re-exposing them as wallet options means we wrap and maintain each one, and the wallet's surface grows to mirror a dependency it should just be using. Every bdk_tx change becomes a bdk_wallet change.
  3. It only exposes what the wallet authors thought to wrap. Hiding bdk_tx behind a single call means anything bdk_tx can do that create_psbt didn't surface is simply out of reach.

So bdk_wallet's wrappers should respect bdk_tx's flow rather than hide it: the wallet's real job is to make it easy to fill bdk_tx's structs with what only the wallet knows (its UTXOs, descriptors/plans, change derivation, canonicalization), and then get out of the way. #502 does exactly that — candidates()select()finish(), returning a bdk_tx::TxTemplate you shape with bdk_tx's own methods.

Concretely, because the candidate set and template are real values you hold, the staged design unlocks things a single opaque call can't: input grouping, post-resolution filtering, a direct bdk_tx escape hatch, and resolve-once / select-many — the last of which a large bdk_wallet user has explicitly asked for.

If anything, the staged design is more ergonomic — and more transparent. The single-call approach hands you one large params struct where it's hard to see how the options relate; staging scopes each set of options to its stage, so what's valid where is obvious. The one thing it gives up is the single-call one-liner for the trivial "send X to Y" case — and that convenience can always be layered on top of composable stages, whereas composability can't be retrofitted onto a sealed call. #502 is a draft PoC for this comparison; I'd love your eyes on it, and to hear where the staged shape falls short.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new feature New feature or request

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Flexible RBF construction