Skip to content

feat: introduce TxTemplate as an intermediate stage#73

Open
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/tx-template
Open

feat: introduce TxTemplate as an intermediate stage#73
evanlinjin wants to merge 7 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/tx-template

Conversation

@evanlinjin

@evanlinjin evanlinjin commented May 20, 2026

Copy link
Copy Markdown
Member

Description

Closes #57.

The transaction-building API had grown awkward, and a revisit traced the awkwardness back to how anti-fee-sniping (AFS) was bolted on. The concrete symptoms:

  • Non-AFS callers paid for AFS. Enabling AFS to live inside create_psbt meant everyone had to reckon with two extra error variants and an extra input, whether or not they used the feature.
  • AFS was filed under the wrong concept. AFS isn't a PSBT concern at all — it's just logic that decides a transaction's locktime and input sequences. It had no business living inside PSBT creation.
  • PsbtParams was doing two jobs. It carried both bitcoin-transaction-shape fields (version, min_locktime, sequence) and PSBT-specific emission options. Because AFS depended on those tx-shape fields, it was structurally pinned inside the PSBT step and couldn't move out.

The right place for AFS is before PSBT creation — it decides tx fields, then those feed into emission. That mirrors Bitcoin Core, where DiscourageFeeSniping runs as a step before the transaction is built. But AFS couldn't be moved without first untangling the tx-shape fields from PsbtParams — i.e. an architectural change, not a local one.

This PR introduces that change: a TxTemplate stage that sits between Selector and Psbt and owns the fully-resolved tx shape (version, locktime, fallback sequence, per-input sequence, ordering).

Selector::try_finalize() → TxTemplate → (Psbt, Finalizer) | Transaction

What this buys us:

  • Concerns are separated. Tx-shape decisions live on TxTemplate; PsbtBuildParams is now emission-only. AFS becomes one ordinary, chainable method on TxTemplate (apply_anti_fee_sniping) that just calls the same public setters anyone else would — it is no longer privileged.
  • Non-AFS callers stop paying for AFS. The extra error variants and input are gone from the common path.
  • Callers who don't want a PSBT aren't forced into one. TxTemplate can emit a bitcoin::Transaction directly, and PSBT v2 support (#48) now has an obvious home as a future TxTemplate::create_psbt_v2() — same construction logic, different emit step.

A secondary win: with create_psbt no longer needing an RNG, the library drops its rand dependency (only rand_core's RngCore trait remains).

Notes to the reviewers

Split into 5 commits:

  1. refactor!: rename Selection to TxTemplate — pure rename, no behaviour change. Sets up commit 2.
  2. feat(tx-template)!: route tx-shape decisions through TxTemplate — the meat. Adds the resolved tx-shape fields and their validating setters, splits PsbtParamsPsbtBuildParams, makes create_psbt return (Psbt, Finalizer), and turns AFS into a chainable pre-PSBT step. Closes Introduce TxTemplate as a state between Selection and Psbt #57.
  3. refactor!: drop rand dependency from the library — moves rand to dev-deps now that emission no longer needs it.
  4. feat: Add single_random_draw selection algorithm⚠️ scope creep. Adds selection_algorithm_single_random_draw, which shuffles candidates with a caller-supplied rng and selects until the target is met. Strictly speaking this is unrelated to the TxTemplate refactor; it's here because the downstream wallet PoC (bitcoindevkit/bdk_wallet#502) needs a selection algorithm to drive, and the new "randomness lives at the selection step, candidate construction stays deterministic" split made this a natural home for it.
  5. feat: Add InputCandidates::push_must_select / push_can_select⚠️ also out of scope. Incremental builders that add an Input to a resolved InputCandidates — to the must-select group, or as an optional can-select group — with outpoint de-duplication (InputCandidates was previously construct-once via new). Like commit 4, it's here only because the wallet PoC needs to push caller-supplied foreign inputs onto a candidate set after resolution.

Both (4) and (5) are independent of the TxTemplate change and added purely for downstream convenience — happy to peel them into their own PR(s) if reviewers would rather keep this one focused.

Two implementation points worth a look:

  • apply_anti_fee_sniping goes through the public set_locktime / Input::set_sequence path and .expect()s the results. The expects hold by construction: the locktime path only fires when afs_locktime >= current (both height-based, since AFS early-rejects time-based inputs), so the unit + ≥-CLTV checks always pass; the sequence path only touches taproot inputs without a relative timelock, so the CSV/CLTV-disable check can't fail. Worth a second pair of eyes on whether those invariants really are total.
  • TxTemplate::from_parts is pub(crate) — external users construct via Selector / InputCandidates. If hand-rolling a TxTemplate becomes a common ask, exposing it is a one-line change.

Out of scope:

Changelog notice

  • Renamed Selection to TxTemplate, now the single workspace for transaction shaping (version, locktime, fallback sequence, per-input sequence, ordering, anti-fee-sniping, emission).
  • Selector::try_finalize now returns Option<TxTemplate>; InputCandidates::into_selection is now into_tx_template, returning Result<TxTemplate, IntoTxTemplateError> (was IntoSelectionError).
  • Split PsbtParams into PsbtBuildParams (emission-only). Tx-shape options moved to TxTemplate setters: set_version, set_locktime, set_fallback_sequence, apply_anti_fee_sniping.
  • TxTemplate::create_psbt now returns (Psbt, Finalizer). shuffle_inputs is now consuming (returns Self).
  • Behaviour change: previously-silent locktime handling is now explicit. A wrong-unit min_locktime was silently ignored and a below-CLTV value silently clamped; set_locktime now returns SetLockTimeError::UnitMismatch / BelowInputCltv, and set_version returns SetVersionError::RelativeTimelockRequiresV2. The hardcoded ENABLE_RBF_NO_LOCKTIME fallback sequence is now configurable via set_fallback_sequence (default unchanged).
  • The library no longer depends on rand (only rand_core, for the RngCore trait).
  • Added selection_algorithm_single_random_draw, a single-random-draw coin selection algorithm for use with Selector::select_with_algorithm.
  • Added InputCandidates::push_must_select / push_can_select for adding an Input to a resolved InputCandidates (with outpoint de-duplication).

Before submitting

@evanlinjin

Copy link
Copy Markdown
Member Author

This is fully AI generated and not ready for review.

@evanlinjin evanlinjin force-pushed the feature/tx-template branch 3 times, most recently from 27084ae to e9c2962 Compare May 20, 2026 05:21
evanlinjin and others added 3 commits June 15, 2026 16:03
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>
evanlinjin and others added 4 commits June 16, 2026 22:26
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>
@evanlinjin evanlinjin force-pushed the feature/tx-template branch from 78bb570 to 0f1c8b2 Compare June 16, 2026 22:26
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.

Introduce TxTemplate as a state between Selection and Psbt

1 participant