Skip to content

feat: add bdk_wallet_tx bridge crate (workspace)#84

Open
evanlinjin wants to merge 24 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/bdk_wallet_tx
Open

feat: add bdk_wallet_tx bridge crate (workspace)#84
evanlinjin wants to merge 24 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/bdk_wallet_tx

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Jun 27, 2026

Copy link
Copy Markdown
Member

Description

Adds bdk_wallet_tx, a bridge crate that drives bdk_tx's multi-stage transaction building from a bdk_wallet::Wallet, and converts this repo into a Cargo workspace to house it (tx/ = bdk_tx, wallet_tx/ = bdk_wallet_tx).

This is an alternative to embedding bdk_tx in bdk_wallet (bitcoindevkit/bdk_wallet#297, #502). Putting bdk_tx's pre-stable types in bdk_wallet's public API would couple the stable crate to a fast-moving one — every breaking bdk_tx release would force a bdk_wallet major. A separate crate depending on both means neither base crate depends on the other; the bridge absorbs the coupling and is versioned on its own. (Rationale: bitcoindevkit/bdk_wallet#297 comment.)

WalletTxExt for bdk_wallet::Wallet exposes a three-stage pipeline (ported from the bdk_wallet#502 PoC, re-expressed over the integrated bdk_tx API and the wallet's public accessors):

use bdk_tx::BuildPsbtParams;
use bdk_wallet_tx::{WalletTxExt, SelectParams, SelectionStrategy};

// 1. Candidates — resolve the wallet's spendable inputs (incl. RBF via `rbf_candidates`).
let coins = wallet.candidates()?;

// 2. Select — a *pure read*: returns the `TxTemplate` and the auto-derived change address (peeked).
let (template, change) = wallet.select(&coins, SelectParams {
    recipients: vec![(recipient_spk, amount)],
    coin_selection: SelectionStrategy::SingleRandomDraw,
    feerate,
    longterm_feerate: None,
    change_script: None,
}, &mut rng)?;

// 3. Emit the PSBT directly via bdk_tx. Reserve the change address so a later `select` won't
//    reuse it (reveal + mark used; then persist the change set); optionally add global xpubs.
let (mut psbt, finalizer) = template.build_psbt(BuildPsbtParams::default())?;
if let Some(c) = &change { wallet.reserve_change(c); }
wallet.add_global_xpubs(&mut psbt)?;

Notes to the reviewers

⚠️ This branch is stacked on #73, #74, #79, #81, #82 (cherry-picked onto master), so most of the diff is their commits. The load-bearing dependency is the TxTemplate stage (#73) — without the multi-stage pipeline there's nothing to bridge; #82 reshapes the selection entry point this crate calls, and #74/#79/#81 are independent bdk_tx improvements bundled here for the integrated state. They don't all need to land in lockstep — once the TxTemplate line is in, this rebases down to just the workspace + bridge commits.

The commits worth reviewing here — everything else is a cherry-pick (review those in their own PRs):

Commit What
59a29ff feat: add bdk_wallet_tx bridge crate — the crux of this PR
0010fe4 convert the repo into a Cargo workspace
e97ef02, df09bd3 reconcile the #74 / #79 tests with #73's TxTemplate / build_psbt API
b5eca4e trivial rustfmt after the #73 / #82 merge

One caveat: the #82 commits as they appear here (4c0262d, 2de1dce, 3d49606) carry conflict resolution against #73 — the merged entry point is InputCandidates::into_tx_template(algorithm, SelectionParams) -> Result<TxTemplate, IntoTxTemplateError> (#73's tx-template staging on #82's single-pass internals, Selector removed). If you care about that reconciliation, diff them against pristine #82.

Change-address lifecycle (a deliberate departure from TxBuilder)

bdk_wallet's TxBuilder::finish() revealed and staged the change address implicitly, as a side effect of building the PSBT — building a transaction always mutated wallet state, whether or not you went on to broadcast it. Here it is explicit and caller-driven: select only peeks the change address (a pure &self read returning the change AddressInfo alongside the template, or None when caller-supplied / no change output), and you reserve_change it (= reveal_addresses_to + mark_used) to claim a distinct change address — reversible with unmark_used if that selection ends up unused. Benefits:

  • Abandon is free. Speculative builds — fee estimation, trying different params, UI previews, picking the cheapest of several strategies — cost nothing in wallet state: no revealed-but-unused change addresses, no changeset to discard. The peeked address is simply reused next time.
  • Selection is a side-effect-free read. select mutates nothing, so it is re-runnable (over a borrowed &CandidateSet) and safe to call freely; the only changeset to persist comes from reserve_change, so persistence is deliberate rather than "after every build".
  • Reuse control is correct and explicit. reserve_change bundles reveal and mark_used, so reserving a selection guarantees the next select draws a fresh change address. Batching several txs before broadcasting is therefore correct by construction — no address reuse — and you choose exactly which selections to reserve. (Excluding an already-built tx's inputs from the next candidate set is just coins.filter(..), since select borrows coins.) The returned AddressInfo.keychain is pre-mapped, so it's safe for mark_used/unmark_used (which don't map it) on single-descriptor wallets — an upstream bug fixed by bitcoindevkit/bdk_wallet#507; once that lands the pre-mapping workaround here can be dropped.

The trade-off is explicitness: you must reserve_change (and persist) the selections you might broadcast, and unmark_used any you drop — finish() revealed automatically. That's the intended shift: from automatic but always-mutating to explicit but controllable.

Other design notes for the bridge (59a29ff):

  • No stage-3 wrapper. TxTemplate::build_psbt needs nothing from the wallet, so callers invoke it directly; the only wallet-specific emission step, filling global xpubs, is add_global_xpubs(&mut Psbt).
  • Candidate spendability filtering (CandidateParams). Auto-gathered candidates are filtered after planning, via InputCandidates::filter (so manually-selected must_spend inputs are never dropped): coins locked through Wallet::lock_outpoint, already-spent outputs, and — gated by allow_immature / allow_timelocked (both default false) — immature coinbase and not-yet-spendable CLTV/CSV outputs are excluded. Height-based timelocks resolve exactly; time-based locks need median-time-past, which bdk_wallet doesn't retain, so the caller supplies it: tip_mtp (a single value — absolute CLTV-time and the tip side of relative locks) and fetch_mtp: Option<Box<MtpOracle>> (a per-input Fn(BlockId) -> Option<Time> oracle filling each input's prev_mtp — relative CSV-time locks). Without them, time-based-locked inputs stay unresolved and are conservatively excluded. Unlike #502 we never fabricate MTP (prev_mtp is None absent an oracle); the eventual clean fix is MTP retained on checkpoints upstream.
  • No anti-fee-sniping. The template is unshuffled with no AFS — the caller applies template.apply_anti_fee_sniping(tip_height, rng) / shuffle_outputs(rng) before emitting the PSBT.
  • longterm_feerate lives on SelectParams (not SelectionStrategy::LowestFee): it feeds the waste-aware change policy for every strategy, and post-refactor!: Replace Selector with a single into_selection pass #82 the bnb metric reads it from SelectionParams.
  • Errors: CandidatesError (stage 1), SelectError (stage 2), MissingKeyOrigin (add_global_xpubs).
  • Target: bdk_wallet master @ 58fe631 (the commit #502 is based on), pinned as a git dependency. Its test-utils needs tempfile >= 3.26, bumped in the lockfile.

Tested via wallet_tx/tests/three_stage.rs (SingleRandomDraw, DrainAll, LowestFee, change peek/reserve, locked-outpoint exclusion, add_global_xpubs, and the NoRecipients error) against a deterministically-funded bdk_wallet — no bitcoind. cargo fmt/clippy/doc/test are green across the workspace; bdk_tx still builds no_std.

Changelog notice

Added:
- New `bdk_wallet_tx` bridge crate: a `WalletTxExt` extension trait on `bdk_wallet::Wallet`
  exposing a three-stage transaction-building pipeline over `bdk_tx`
  (`candidates`/`rbf_candidates` -> `select` -> `add_global_xpubs`), with `reserve_change`
  for the auto-derived change address.

Changed:
- The repository is now a Cargo workspace; the `bdk_tx` crate has moved to `tx/`.

Before submitting

evanlinjin and others added 7 commits June 27, 2026 03:12
`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) <noreply@anthropic.com>
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.
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.
**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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the feature/bdk_wallet_tx branch 5 times, most recently from d378d5f to edfd2fc Compare June 27, 2026 08:22
@evanlinjin evanlinjin self-assigned this Jun 27, 2026
@evanlinjin evanlinjin force-pushed the feature/bdk_wallet_tx branch 13 times, most recently from c26de18 to 7b38e3e Compare June 27, 2026 14:20
@evanlinjin evanlinjin marked this pull request as ready for review June 27, 2026 14:31
evanlinjin and others added 16 commits June 27, 2026 14:35
bitcoindevkit#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) <noreply@anthropic.com>
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<TxTemplate>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes bitcoindevkit#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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…te/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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…match`

`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the feature/bdk_wallet_tx branch 6 times, most recently from 2872626 to 6dae003 Compare June 28, 2026 11:09
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<AddressInfo>)` (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).

Ported from the bdk_wallet#502 PoC, re-expressed over the integrated bdk_tx API
and the wallet's public accessors. MTP is faked from the confirmation block
time; UTXO locking is honored; `longterm_feerate` lives on `SelectParams` and
feeds the waste-aware change policy for every strategy. End-to-end tests cover
the pipeline, candidate filtering, change commit, and error paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the feature/bdk_wallet_tx branch from 6dae003 to 59a29ff Compare June 28, 2026 11:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant