feat(wallet): Three-stage PSBT creation over bdk_tx::TxTemplate (PoC for discussion)#502
Draft
evanlinjin wants to merge 11 commits into
Draft
feat(wallet): Three-stage PSBT creation over bdk_tx::TxTemplate (PoC for discussion)#502evanlinjin wants to merge 11 commits into
evanlinjin wants to merge 11 commits into
Conversation
`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 Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
2 tasks
0857666 to
0b4d1df
Compare
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>
0b4d1df to
e57a536
Compare
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Both approaches add PSBT creation to
Walletand 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:bdk_tx. Version, locktime, anti-fee-sniping, ordering, shuffling, and per-input/fallback sequence already exist inbdk_tx. Surfacing them as wallet options means wrapping and maintaining each, so everybdk_txchange becomes a wallet change and the option list grows to mirror a dependency we should simply be using.create_psbtfinally runs: conflicts (an explicitlocktimenext toanti_fee_sniping_height, both settingnLockTime, 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, inspectableCandidateSet/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.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:This makes the three costs go away: each stage has a small, focused params struct; transaction shaping happens on the returned
bdk_tx::TxTemplateusingbdk_tx's own methods (no re-wrapping, no drift); sweep and RBF become composable axes rather than special types (a sweep isDrainAllwith 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:
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 throughPsbtParams.CandidateSet::filter) — apply arbitrary predicates over the resolved inputs (by value, script, confirmation status, coinbase/maturity), beyond the fixedCandidateParamsknobs.bdk_walletuser has already asked for exactly this, and the single-call design can't accommodate it without re-resolving the whole candidate set every time.CandidateSet::push_must_select/push_can_selecttake pre-builtbdk_tx::Inputs, soCandidateParamsstays purely about deriving candidates from the wallet rather than also being a pass-through for caller-suppliedbdk_txvalues.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_txescape hatch —CandidateSet::into_parts()(yielding theInputCandidatesand anyRbfParams) and the returnedTxTemplatelet a caller drop down tobdk_txprimitives 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-stageCandidatesError/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:
selectreturns an unshuffled template; the caller must callshuffle_outputs. Deliberate and loudly documented, but it gives up Implementcreate_psbtfor Wallet #297's shuffle-by-default safety. Shouldfinishshuffle by default with an opt-out instead?#[non_exhaustive], so adding a field is breaking; kept this way to allow inline..Default::default()construction. Open to revisiting.select(viaSelector::select_with_algorithm).bdk_tx'sTxTemplate(feat: introduce TxTemplate as an intermediate stage bdk-tx#73) plus two small additions currently on the fork: asingle_random_drawselection algorithm andInputCandidates::push_must_select/push_can_select. The branch pointsbdk_txat that fork and must be repointed to upstream before merge.Changelog notice
Wallet:candidates/rbf_candidates/candidates_with,select/select_with_rng, andfinish, along withCandidateParams,SelectParams,FinishParams,SelectionStrategy,CandidateSet, and theCandidatesError/CreatePsbtErrorerror types.CandidateSetsupportspush_must_select/push_can_select(foreign inputs),filter/regroup,replaced_unspent(RBF batching), andinto_parts(bdk_txescape hatch).Checklists
All Submissions:
just pbefore pushingNew Features: