Skip to content

feat(wallet): Three-stage PSBT creation over bdk_tx::TxTemplate (PoC for discussion)#502

Draft
evanlinjin wants to merge 11 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/create_psbt_evan
Draft

feat(wallet): Three-stage PSBT creation over bdk_tx::TxTemplate (PoC for discussion)#502
evanlinjin wants to merge 11 commits into
bitcoindevkit:masterfrom
evanlinjin:feat/create_psbt_evan

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Jun 15, 2026

Copy link
Copy Markdown
Member

Description

This is a proof-of-concept opened for discussion — not a merge-ready PR. It explores an alternative design to #297 so we can compare API shapes side by side and decide on a direction before polishing either one. Plenty here (naming, the missing convenience façade, the upstream bdk_tx dependency) is deliberately unfinished; I'm interested in feedback on the shape, not the finish.

Both approaches add PSBT creation to Wallet and produce identical PSBTs; they differ in where the complexity lives.

To cover the real cases (manual coin selection, foreign inputs, assets, sweep, RBF, fee policy, version/locktime/anti-fee-sniping, ordering, global xpubs), a single create_psbt(params) entry point needs one very large params type — in #297, a ~30-method typestate builder. That shape carries three costs that compound over time:

  1. The wallet ends up re-declaring bdk_tx. Version, locktime, anti-fee-sniping, ordering, shuffling, and per-input/fallback sequence already exist in bdk_tx. Surfacing them as wallet options means wrapping and maintaining each, so every bdk_tx change becomes a wallet change and the option list grows to mirror a dependency we should simply be using.
  2. Invalid or conflicting options only surface at the final call. A single params type only sorts two things out when create_psbt finally runs: conflicts (an explicit locktime next to anti_fee_sniping_height, both setting nLockTime, precedence hidden in docs) and invalid values (a sequence violating an input's CSV requirement isn't rejected when set — 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 intact. This shape also suits frontend / interactive use well: a UI can resolve candidates once, let the user adjust selection and outputs against a live, inspectable CandidateSet/TxTemplate, and surface each error inline at the control that caused it — rather than filling a whole form and getting one wholesale failure at submit.
  3. Nothing is reusable. A single call returning a finished PSBT can't resolve a candidate set once and build several transactions, or inspect/adjust the selection before committing.

The direction this PoC explores: the wallet should be a thin, composable layer over bdk_tx, not a façade that hides it. PSBT creation is three genuinely distinct concerns — what can I spend, what do I spend to hit this target, turn that into a PSBT — so it's modeled as three stages instead of one call:

let coins    = wallet.candidates()?;                               // 1. resolve
let template = wallet.select(coins, SelectParams {                 // 2. select → TxTemplate
    recipients, fee_rate, ..Default::default()
})?;
let template = template.shuffle_outputs(&mut rng);                 //    shape (bdk_tx methods)
let (psbt, finalizer) = wallet.finish(template, FinishParams::default())?;  // 3. emit

This makes the three costs go away: each stage has a small, focused params struct; transaction shaping happens on the returned bdk_tx::TxTemplate using bdk_tx's own methods (no re-wrapping, no drift); sweep and RBF become composable axes rather than special types (a sweep is DrainAll with no recipients; RBF is a field on the candidate params); and the intermediate values are real and inspectable.

Because the candidate set and the template are now real values the caller holds, several capabilities that the single-call design structurally could not expose fall out for free:

  • Input grouping (CandidateSet::regroup) — group UTXOs by a key (e.g. address / script pubkey) so coin selection treats a group as one unit. This is privacy-relevant: it avoids spending some but not all UTXOs of an address. There was no way to express this through PsbtParams.
  • Post-resolution filtering (CandidateSet::filter) — apply arbitrary predicates over the resolved inputs (by value, script, confirmation status, coinbase/maturity), beyond the fixed CandidateParams knobs.
  • Resolve-once, select-many — reuse one resolved candidate set across multiple independent selections. This one isn't hypothetical: a large bdk_wallet user has already asked for exactly this, and the single-call design can't accommodate it without re-resolving the whole candidate set every time.
  • Foreign inputs go straight onto the setCandidateSet::push_must_select / push_can_select take pre-built bdk_tx::Inputs, so CandidateParams stays purely about deriving candidates from the wallet rather than also being a pass-through for caller-supplied bdk_tx values.
  • RBF batching — for a replacement, CandidateSet::replaced_unspent() exposes the replaced txs' still-unspent wallet outputs, so a caller can re-create those payments when batching several txs into one replacement.
  • bdk_tx escape hatchCandidateSet::into_parts() (yielding the InputCandidates and any RbfParams) and the returned TxTemplate let a caller drop down to bdk_tx primitives for anything the wallet doesn't wrap, instead of being limited to the options the wallet chose to surface.

These aren't features bolted on — they're a direct consequence of modeling the stages as values rather than hiding them behind one call.

If anything the staged design is more ergonomic and transparent: the single-call approach is one large params struct where the options' relationships are hard to follow, whereas each stage here scopes its options 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 be layered on top of the composable stages later, whereas composability can't be retrofitted onto a sealed call.

Full API surface (CandidateParams/SelectParams/FinishParams, SelectionStrategy, CandidateSet, and the per-stage CandidatesError/CreatePsbtError) is in the diff and rustdoc. Examples: examples/psbt.rs, examples/replace_by_fee.rs.

Notes to the reviewers

As a PoC, these are open for discussion rather than settled — they're the main things I'd want a decision on:

  • Privacy default. select returns an unshuffled template; the caller must call shuffle_outputs. Deliberate and loudly documented, but it gives up Implement create_psbt for Wallet #297's shuffle-by-default safety. Should finish shuffle by default with an opt-out instead?
  • No convenience façade. Intentionally omitted — add a one-call helper now, or wait for demand?
  • Params are not #[non_exhaustive], so adding a field is breaking; kept this way to allow inline ..Default::default() construction. Open to revisiting.
  • Determinism. Candidate construction is RNG-free; randomness lives only in select (via Selector::select_with_algorithm).
  • Upstream dependency. Requires bdk_tx's TxTemplate (feat: introduce TxTemplate as an intermediate stage bdk-tx#73) plus two small additions currently on the fork: a single_random_draw selection algorithm and InputCandidates::push_must_select / push_can_select. The branch points bdk_tx at that fork and must be repointed to upstream before merge.

Changelog notice

  • Added a three-stage PSBT-creation API to Wallet: candidates/rbf_candidates/candidates_with, select/select_with_rng, and finish, along with CandidateParams, SelectParams, FinishParams, SelectionStrategy, CandidateSet, and the CandidatesError/CreatePsbtError error types. CandidateSet supports push_must_select/push_can_select (foreign inputs), filter/regroup, replaced_unspent (RBF batching), and into_parts (bdk_tx escape hatch).

Checklists

All Submissions:

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

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

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.66534% with 82 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.54%. Comparing base (39de6ed) to head (e57a536).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
src/wallet/mod.rs 91.21% 27 Missing and 9 partials ⚠️
src/wallet/error.rs 0.00% 25 Missing ⚠️
src/psbt/params.rs 68.65% 21 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #502      +/-   ##
==========================================
+ Coverage   80.93%   81.54%   +0.60%     
==========================================
  Files          24       25       +1     
  Lines        5482     6004     +522     
  Branches      247      276      +29     
==========================================
+ Hits         4437     4896     +459     
- Misses        968     1022      +54     
- Partials       77       86       +9     
Flag Coverage Δ
rust 81.54% <83.66%> (+0.60%) ⬆️

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.

Replace `create_psbt`/`sweep` with an explicit three-stage flow built on
bdk_tx's `TxTemplate`, keeping the wallet a thin layer over `bdk_tx`:

1. `candidates()` / `rbf_candidates()` / `candidates_with(&CandidateParams)`
   resolve a `CandidateSet` (deterministic, rng-free).
2. `select(coins, SelectParams)` runs coin selection and returns a
   `TxTemplate`; single-random-draw randomness lives here (via
   `Selector::select_with_algorithm`).
3. `finish(template, FinishParams)` emits the `Psbt` and `Finalizer`.

The caller shapes the returned `TxTemplate` (version, locktime, sequence,
anti-fee-sniping, ordering, shuffling) with `bdk_tx`'s own methods rather than
through wallet options. It is returned unshuffled with no anti-fee-sniping, so
change-output privacy is opted into explicitly.

Params are plain public-field structs (no builders):
- `CandidateParams` is purely wallet-derivation config: `must_spend`
  (`BTreeSet<OutPoint>`), `assets` (plain `Assets`, empty == none),
  canonicalization, maturity, `manually_selected_only`, and `replace` (RBF).
- `SelectParams` (recipients, change, coin-selection strategy, fee rate) and
  `FinishParams` (emission options).

`CandidateSet`:
- Foreign (non-wallet) inputs are pushed on with `push_must_select` /
  `push_can_select` (forwarding to upstream `InputCandidates::push_*`), so they
  aren't wallet-derivation config.
- `filter` / `regroup` / `into_parts` expose `bdk_tx` for anything not wrapped.
- For RBF it carries `bdk_tx::RbfParams` directly, rejects a pushed input that
  spends a replaced output (`ConflictingInput`), and exposes the wallet outputs
  the replacement strips (`replaced_unspent`) for batching.

Other:
- Sweep folds into `select` via `SelectionStrategy::DrainAll`; a `NoRecipients`
  guard prevents accidental full-wallet drains; a no-change drain auto-derives
  and reveals an internal change address.
- A replaced tx the wallet controls no input of is rejected
  (`CandidatesError::CannotReplace`).
- Errors split into `CandidatesError` (stage 1) and `CreatePsbtError` (2/3).
- Point `bdk_tx` at the `feature/tx-template` branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the feat/create_psbt_evan branch from 0b4d1df to e57a536 Compare June 16, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants