Skip to content

feat: Add Wallet::create_psbt#516

Open
ValuedMammal wants to merge 14 commits into
bitcoindevkit:masterfrom
ValuedMammal:feat/bdk-tx
Open

feat: Add Wallet::create_psbt#516
ValuedMammal wants to merge 14 commits into
bitcoindevkit:masterfrom
ValuedMammal:feat/bdk-tx

Conversation

@ValuedMammal

Copy link
Copy Markdown
Collaborator

Description

This PR introduces Wallet::create_psbt and Wallet::replace_by_fee - a new PSBT construction API built on top of bdk-tx. This is a second attempt at integrating the planning module that started with #297. The scope is mostly the same and incorporates a number of improvements noted on #297. Notably the entire surface area is gated behind two compile flags.

flags:

  • the bdk-tx Cargo feature
  • the --cfg bdk_wallet_unstable rustc flag

Enabling bdk-tx without also passing bdk_wallet_unstable is a compile error with a clear diagnostic. This allows us to ship the API for early adopters while reserving the right to break it in a minor release.

Users can opt in by adding the following to your project's .cargo/config.toml:

[build]
rustflags = ["--cfg", "bdk_wallet_unstable"]

and enable the feature in Cargo.toml:

bdk_wallet = { version = "...", features = ["bdk-tx"] }

New public API

Item Notes
Wallet::create_psbt Build a new PSBT with coin selection
Wallet::create_psbt_with_rng Same with caller-supplied entropy
Wallet::replace_by_fee Construct an RBF replacement for one or more transactions
Wallet::replace_by_fee_with_rng Same with caller-supplied entropy
PsbtParams<C> Builder for PSBT construction; C is CreateTx or ReplaceTx/Rbf
SelectionStrategy Enum: All, SingleRandomDraw, LowestFee, Custom
CreatePsbtError Error type for create_psbt
ReplaceByFeeError Error type for replace_by_fee
bdk_tx re-export For callers who need direct access to bdk_tx

TxOrdering is also generalized to TxOrdering<In, Out> to accommodate
bdk-tx's typed input/output types. TxOrdering<In, Out> adds default type parameters
(TxIn, TxOut) that are identical to the previous concrete type, so remains
semver-compatible.
Existing callers that don't use the Custom variant are unaffected.

Differences from #297

  • Non-essential API surface removed - Some wallet methods present in Implement create_psbt for Wallet #297 were trimmed; the public API is narrower and more focused.
  • bdk_tx re-export - bdk_wallet::bdk_tx is re-exported so callers who need bdk-tx types directly don't need a separate bdk_tx dependency.
  • SelectionStrategy::All replaces drain_wallet - modeling "sweep everything" as a strategy is cleaner than a PsbtParams option.
  • SelectionStrategy::Custom - users can supply their own coin-selection algorithm as a closure without having to implement a trait.
  • SingleRandomDraw correctness fix - shuffling now only runs when SingleRandomDraw is active; the extra shuffle_slice calls are removed.
  • bdk_wallet_unstable cfg gate + bdk-tx Cargo feature - the full create_psbt / replace_by_fee surface is hidden unless explicitly opted into.

Changelog notice

### Added
- feat(wallet): add unstable `Wallet::create_psbt` and `Wallet::replace_by_fee`
  behind the `bdk-tx` cargo feature and the `--cfg bdk_wallet_unstable` rustc
  flag. These APIs are explicitly **unstable**: breaking changes may land in
  minor releases without a semver bump. To opt in, enable the `bdk-tx` feature
  **and** pass `--cfg bdk_wallet_unstable` to rustc.

Before submitting

`TxOrdering` is made generic by exposing the generic from
`TxSort` function. This means we're not limited to
ordering lists of only `TxIn` and `TxOut`, which will be
useful for sorting inputs/outputs of a `bdk_tx::Selection`.

We use bitcoin `TxIn` and `TxOut` as the default type parameter
to maintain backward compatibility.
We add the `psbt::params` module along with new types
including `PsbtParams` and `SelectionStrategy`.

`PsbtParams` is mostly inspired by `TxParams` from `tx_builder.rs`,
except that we've removed support for `policy_path` in favor of
`add_assets` API.

`PsbtParams<C>` contains a type parameter `C` indicating the context
in which the parameters can be used. Methods related to PSBT
creation exist within the `CreateTx` context, and methods
related to replacements (RBF) exist within the `ReplaceTx`
context.

In `lib.rs` re-export everything under `psbt` module.

- deps: Add `bdk_tx` 0.2.0 to Cargo.toml
We use the new `PsbtParams` to add methods on `Wallet` for
creating PSBTs, including RBF transactions.

`Wallet::create_psbt` and `Wallet::replace_by_fee` each have
no-std counterparts that take an additional `impl RngCore`
parameter.

Also adds a convenience method `replace_by_fee_and_recipients`
that exposes the minimum information needed to create an RBF.

This commit re-introduces the `Wallet::insert_tx` API for adding
newly created transactions to the wallet.

Added `Wallet::transactions_with_params` that allows customizing
the internal canonicalization logic.

Added errors to `wallet::errors` module:
- `CreatePsbtError`
- `ReplaceByFeeError`
Added unit test to `psbt/params.rs`
- `test_replace_params`

To tests/add_foreign_utxo.rs added
- `test_add_planned_psbt_input`

To tests/psbt.rs added
- `test_create_psbt`
- `test_create_psbt_insufficient_funds_error`
- `test_create_psbt_maturity_height`
- `test_create_psbt_cltv`
- `test_create_psbt_cltv_timestamp`
- `test_create_psbt_csv`
- `test_replace_by_fee_and_recpients`
- `test_replace_by_fee_replaces_descendant_fees`
- `test_replace_by_fee_confirmed_tx_error`
- `test_replace_by_fee_no_inputs_from_original`
- `test_create_psbt_utxo_filter`

Plus several Sequence fallback and override scenarios
- `test_create_psbt_fallback_sequence_applied_to_coin_selected_input`
- `test_create_psbt_fallback_sequence_skipped_for_csv_input`
- `test_create_psbt_sequence_override_manually_selected_input`
- `test_create_psbt_sequence_override_takes_precedence_over_fallback`
- `test_create_psbt_sequence_override_csv_conflict_returns_error`

To tests/wallet.rs added
- `test_spend_non_canonical_txout`

- `test-utils`: Add `insert_tx_anchor` test helper for adding
a transaction to the wallet with associated anchor block.
…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.
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`.
Shuffling the input candidates now occurs only in the case
that SingleRandomDraw is the chosen selection strategy.
As such, the extra calls to `shuffle_slice` can be
removed.
`bdk_tx` is now an optional dependency controlled by the new
`bdk-tx` feature. All `create_psbt`/`replace_by_fee` surface area
is gated behind `cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))`.
Enabling the `bdk-tx` feature without also setting
`--cfg bdk_wallet_unstable` in RUSTFLAGS is a compile error with a
useful error message.

The justfile and CI are updated to pass the `bdk_wallet_unstable`
flag in order to test all features.

All `create_psbt` and `replace_by_fee` tests are consolidated into a new
`tests/create_psbt.rs` gated by `bdk_wallet_unstable`. `.gitignore` is
updated to exclude a local `.cargo/config.toml`, and an Unreleased
CHANGELOG entry documents the opt-in requirement for users.
@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.11838% with 132 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.71%. Comparing base (75e0d30) to head (c9b8b79).

Files with missing lines Patch % Lines
src/psbt/params.rs 86.52% 50 Missing and 2 partials ⚠️
src/wallet/mod.rs 89.76% 31 Missing and 17 partials ⚠️
src/wallet/error.rs 0.00% 31 Missing ⚠️
src/lib.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #516      +/-   ##
==========================================
+ Coverage   81.15%   81.71%   +0.55%     
==========================================
  Files          24       25       +1     
  Lines        5552     6438     +886     
  Branches      247      290      +43     
==========================================
+ Hits         4506     5261     +755     
- Misses        970     1082     +112     
- Partials       76       95      +19     
Flag Coverage Δ
rust 81.71% <85.11%> (+0.55%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@oleonardolima oleonardolima moved this to Needs Review in BDK Wallet Jul 18, 2026
@oleonardolima oleonardolima added this to the Wallet 3.2.0 milestone Jul 18, 2026
@oleonardolima oleonardolima added the new feature New feature or request label Jul 18, 2026

@oleonardolima oleonardolima left a comment

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.

I left a few comments, and questions. I'd like to do some manual testing too, not only with the examples in regtest.

The commit history could use some work, by rewriting and restructuring it, it'll help other reviewers.

Comment thread Cargo.toml Outdated

[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
Collaborator

Choose a reason for hiding this comment

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

nit: shouldn't this be optional ?

Comment thread src/psbt/params.rs
pub type Rbf = ReplaceTx;

/// Parameters to create a PSBT.
// TODO: Can we derive `Clone` for this?

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.

nit: are you looking forward to address this in a follow-up ?

Comment thread src/psbt/params.rs
Comment on lines +18 to +24
/// Marker type representing the PSBT creation state.
#[derive(Debug)]
pub struct CreateTx;

/// Marker type representing the Replace-By-Fee (RBF) state.
#[derive(Debug)]
pub struct ReplaceTx;

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.

question: food for thought, do you think having these structs is better than having an enum (as it's representing a state), for example ?

Comment thread src/psbt/params.rs Outdated
Comment on lines +160 to +163
assert!(
!txs.is_empty(),
"replace_txs requires at least one transaction"
);

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.

fix: it's way better if we return an error instead of panicking here.

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.

I've been reviewing commit-by commit, so you can ignore this comment now.

Comment thread src/psbt/params.rs
Comment on lines +321 to +335
/// Filter [`FullTxOut`]s by the provided closure.
///
/// This option can be used to mark specific outputs unspendable or apply custom UTXO
/// filtering logic.
///
/// Any txouts for which the `predicate` returns `false` will be excluded from coin selection,
/// otherwise any coin in the wallet that is mature and spendable will be eligible for
/// selection.
pub fn filter_utxos<F>(&mut self, predicate: F) -> &mut Self
where
F: Fn(&FullTxOut<ConfirmationBlockTime>) -> bool + Send + Sync + 'static,
{
self.utxo_filter = UtxoFilter(Arc::new(predicate));
self
}

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.

nit: the method name is a bit odd, it's not filtering anything, it's setting the utxo_filter, updating it to utxo_filter is better.

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 agree, utxo_filter is a clearer name and matches the internal field.

Comment thread src/wallet/mod.rs
I: IntoIterator<Item = FullTxOut<ConfirmationBlockTime>> + 'a,
F: Fn(&FullTxOut<ConfirmationBlockTime>) -> bool + 'a,
{
let current_height = params.maturity_height.unwrap_or(self.chain.tip().height());

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.

nit: it'd be clear if you stick to maturity_height, or just height.

Comment thread src/wallet/mod.rs
Comment on lines +3090 to +3112
txos.into_iter().filter(move |txo| {
// Exclude outputs that are manually selected.
if params.set.contains(&txo.outpoint) {
return false;
}
// Filter outputs according to `policy` fn.
if !policy(txo) {
return false;
}
// Exclude locked UTXOs.
if self.is_outpoint_locked(txo.outpoint) {
return false;
}
// Exclude immature outputs.
if !txo.is_mature(current_height) {
return false;
}
// Exclude spent outputs.
if txo.spent_by.is_some() {
return false;
}
true
})

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.

nit: IIRC you could do it lazily by chaining the .filter's instead.

Comment thread src/psbt/params.rs
pub struct PsbtParams<C> {
/// Set of selected UTXO outpoints.
/// Set of selected UTXO outpoints, `HashSet` ensures uniqueness
pub(crate) set: HashSet<OutPoint>,

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.

question: woulndn't be using something as select be clearer ?

Comment thread src/wallet/mod.rs Outdated
SelectionStrategy::SingleRandomDraw => {
// We should have shuffled candidates earlier, so just select
// until the target is met.
// TODO: Can we shuffle candidates here instead using select_with_algo?

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.

question: is this something you'd like to address in this PR or in a follow-up ?

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.

Got it in the next commit :)

Comment thread justfile
Comment on lines +23 to +25
RUSTFLAGS="" cargo check --no-default-features --features miniscript/no-std,bdk_chain/hashbrown --all-targets
RUSTFLAGS="" cargo check --features {{FEATURES}} --all-targets
RUSTFLAGS="--cfg bdk_wallet_unstable" cargo clippy --all-features --all-targets -- -D warnings

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.

question: why not add add a check-unstable instead ?

@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.

Outstanding review comments from #297.

Comment thread src/wallet/error.rs
Comment on lines +438 to +439
/// One of the transactions to be replaced is already confirmed
TransactionConfirmed(Txid),

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 don't think this error variant earns it's keep.

  • Most callers would want to check whether the transaction is still unconfirmed before trying to replace it. A caller that doesn't do that obviously wants to create a transaction that conflicts with a confirmed one.

  • The guard is partial. It only catches "already confirmed at build time". The original transaction can be confirmed before the replacement is ready to broadcast.

Comment thread src/wallet/mod.rs
Comment on lines +3456 to +3458
// For each txid being replaced, verify that at least one of its original inputs
// remains in the selected set. A replacement must conflict with every transaction it
// replaces — two transactions cannot spend the same UTXO.

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.

Suggested change
// For each txid being replaced, verify that at least one of its original inputs
// remains in the selected set. A replacement must conflict with every transaction it
// replaces — two transactions cannot spend the same UTXO.
// A replacement must conflict (double-spend) with each tx it replaces - so require at least
// one of each original's inputs to remain selected.

wdyt?

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.

Do you think we should return an error if an original's descendant cannot have it's fee calculated? The current implementation has a risk of undershooting the RBF fee floor.

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

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.

Suggested change
let tx = self.tx_graph.graph().get_tx(txid)?;
let tx = self.tx_graph.graph().get_tx(txid).expect("tx must exist as they were obtained via the TxGraph");

We obtained descendants directly from TxGraph. So they must exist.

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.

@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.

I did an initial review (with LLM help) and new changes look good.

One other suggestion, but doesn't need to be done in this PR, is figure out how we want to document experimental and possibly unstable APIs in the rust docs.

The LLM suggested adding:

/// **Unstable**: requires the `bdk-tx` Cargo feature and the
/// `--cfg bdk_wallet_unstable` rustc flag; may change in any minor release.
///

Comment thread src/psbt/params.rs
Comment on lines +321 to +335
/// Filter [`FullTxOut`]s by the provided closure.
///
/// This option can be used to mark specific outputs unspendable or apply custom UTXO
/// filtering logic.
///
/// Any txouts for which the `predicate` returns `false` will be excluded from coin selection,
/// otherwise any coin in the wallet that is mature and spendable will be eligible for
/// selection.
pub fn filter_utxos<F>(&mut self, predicate: F) -> &mut Self
where
F: Fn(&FullTxOut<ConfirmationBlockTime>) -> bool + Send + Sync + 'static,
{
self.utxo_filter = UtxoFilter(Arc::new(predicate));
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 agree, utxo_filter is a clearer name and matches the internal field.

Comment thread src/psbt/params.rs
Custom {
/// Selection algorithm
#[allow(clippy::type_complexity)]
algorithm: Arc<dyn Fn(&mut Selector) -> Result<(), SelectorError>>,

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 this custom algo function also needs to be Send + Sync to be consistent with the param type used above in the coin_selection_algorithm function.

Suggested change
algorithm: Arc<dyn Fn(&mut Selector) -> Result<(), SelectorError>>,
algorithm: Arc<dyn Fn(&mut Selector) -> Result<(), SelectorError> + Send + Sync>,

Comment thread src/psbt/params.rs
pub struct ReplaceTx;

/// Alias for [`ReplaceTx`] context marker.
pub type Rbf = ReplaceTx;

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: do we really need this alias type? In the code Rbf is used everywhere instead of the ReplaceTx marker type. But the docs for ReplaceTx already describe it as used for doing a RBF. My preference would be to remove Rbf and just use ReplaceTx everywhere.

Comment thread src/wallet/mod.rs
let err = bdk_coin_select::InsufficientFunds {
missing: target_amount.to_sat(),
};
return Err(CreatePsbtError::InsufficientFunds(err))?;

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: it looks strange to throw an error while returning an error. The below should be more idiomatic.

Suggested change
return Err(CreatePsbtError::InsufficientFunds(err))?;
return Err(CreatePsbtError::InsufficientFunds(err).into());

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: Needs Review

Development

Successfully merging this pull request may close these issues.

4 participants