Skip to content

feat: introduce TxTemplate as an intermediate stage - #73

Draft
evanlinjin wants to merge 9 commits into
bitcoindevkit:masterfrom
evanlinjin:feature/tx-template
Draft

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

Conversation

@evanlinjin

@evanlinjin evanlinjin commented May 20, 2026

Copy link
Copy Markdown
Member

Description

Closes #57. Based on #87.

PsbtParams was doing two jobs: it carried bitcoin-transaction-shape fields (version, min_locktime, sequence) and PSBT emission options. Anti-fee-sniping depended on the tx-shape half, so it was structurally pinned inside create_psbt — which meant every caller reckoned with two AFS error variants and an extra input whether they wanted AFS or not.

AFS isn't a PSBT concern. It decides a transaction's locktime and input sequences, so it belongs before emission — as in Bitcoin Core, where DiscourageFeeSniping runs as a step before the transaction is built. It couldn't move without first splitting the tx-shape fields out of PsbtParams.

This PR renames Selection to TxTemplate and gives it those fields, so the tx shape is owned and observable before anything is emitted:

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

Mostly ergonomics, with one exception. On master, PsbtParams { version: ONE, .. } with a relative-timelock input builds a v1 transaction whose OP_CSV can never be satisfied — BIP-68 sequence locks only apply from v2 — and you find out when it's rejected. That's now SetVersionError::RelativeTimelockRequiresV2.

Notes to the reviewers

Commit 1 is a pure rename — TxTemplate is Selection, not a new layer between it and the PSBT.

Three commits at the end respond to review:

  • discourage_fee_sniping (was apply_anti_fee_sniping). apply_* reads as an independent transformation that composes with the other shaping methods, but AFS shares the lock_time slot with set_locktime. Also matches Core's naming.
  • A docs commit stating how those two compose. set_locktime is a floor: tx.lock_time only moves up from there, AFS may raise it toward the tip but never lowers it — the same way input CLTVs already compose. I considered making AFS authoritative instead (last-write-wins) and decided against it: having AFS silently discard a value the caller explicitly set is the footgun this PR exists to remove, and it would make AFS the one operation in TxTemplate that can lower lock_time.
  • AFS is sealed: discourage_fee_sniping consumes the template and returns a SealedTxTemplate exposing only reads and emission. This is narrower than it sounds — only AFS seals, so set_locktime before it still works. It rules out one concrete case: AFS may protect the transaction by setting an input's nSequence rather than the locktime, which it only does while lock_time is zero, and a set_locktime afterwards would rebuild a transaction carrying both a near-tip locktime and a confirmation-depth sequence — exactly the fingerprint fix(afs): require zero locktime for the nSequence path #87 prevents. A compile_fail doctest pins it.

Points 1 and 3 of #73 (comment) are not addressed here; point 2 is #87, which this builds on.

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 BuildPsbtParams (emission-only). Tx-shape options moved to TxTemplate setters: set_version, set_locktime, set_fallback_sequence, discourage_fee_sniping.
  • PSBT emission is build_psbt (renamed from create_psbt) and returns (Psbt, Finalizer); its params/error are BuildPsbtParams / BuildPsbtError. shuffle_inputs is now consuming (returns Self).
  • Anti-fee-sniping is TxTemplate::discourage_fee_sniping (was the PsbtParams::anti_fee_sniping field). It and set_locktime write the same tx.lock_time slot and compose monotonically: a value passed to set_locktime is a floor that AFS may raise toward the chain tip but never lowers.
  • Anti-fee-sniping is terminal: discourage_fee_sniping consumes the TxTemplate and returns a new SealedTxTemplate that exposes only reads and emission, so the tx shape cannot be mutated after AFS. TxTemplate derefs to SealedTxTemplate.
  • 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).

Before submitting

@evanlinjin

This comment was marked as outdated.

@evanlinjin
evanlinjin force-pushed the feature/tx-template branch 3 times, most recently from 27084ae to e9c2962 Compare May 20, 2026 05:21
@evanlinjin
evanlinjin force-pushed the feature/tx-template branch from e9c2962 to de8bfc5 Compare June 15, 2026 16:07
@evanlinjin
evanlinjin marked this pull request as ready for review June 15, 2026 18:12
@evanlinjin evanlinjin self-assigned this Jun 15, 2026
@evanlinjin
evanlinjin force-pushed the feature/tx-template branch 2 times, most recently from 78bb570 to 0f1c8b2 Compare June 16, 2026 22:26

@noahjoeris noahjoeris left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

cACK 0f1c8b2

Nice improvements, thanks.

Comment thread examples/synopsis.rs
Comment thread src/tx_template.rs Outdated
Comment thread src/input_candidates.rs Outdated
@ValuedMammal

Copy link
Copy Markdown
Contributor

I'm not sold on the idea of TxTemplate I think it adds a layer of indirection with little benefit.

@evanlinjin

evanlinjin commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

@ValuedMammal TxTemplate is Selection repurposed. Commit 1 is that pure-rename commit. There is no added indirection.

Could you be a bit more specific on where you think the proposed API is problematic? Which costs are you weighing?

@nymius

nymius commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

I agree with the merits of this change. It detaches AFS, version, locktime and sequence from the PSBT creation, and TxTemplate ensures the invariants for a single party.
For multi party setups we can make SealedTxTemplate check the invariants of its own subset of inputs/outputs for a Psbt too (like SealedTxTemplate::check_psbt). In this case it would be nice to have SealedTxTemplate implement serialization/deserialization to match Psbts with their original templates.
I've only been considering this from a conceptual level. Before proceeding with more review, I would like to be sure there are not blocking concerns about this. @ValuedMammal ?

@ValuedMammal

Copy link
Copy Markdown
Contributor

@ValuedMammal TxTemplate is Selection repurposed. Commit 1 is that pure-rename commit. There is no added indirection.

Oh, the impression that TxTemplate is an indirection may have come from it being proposed as an intermediate stage; I can see that TxTemplate replaced the Selection type. The issue cites the anti-fee-sniping logic as an awkward API, but so far the shape of the API hasn't prevented me from creating PSBTs, so I don't know if I'm missing something.

I'm also not satisfied with the AFS logic, but for different reasons

  1. Exclusion of p2tr inputs with a CSV condition fix(afs)!: preserve input timelock requirements #65 (comment)
  2. Non-zero LockTime in the Sequence branch fix(afs)!: preserve input timelock requirements #65 (comment)
  3. Unintuitive min_locktime interaction fix(afs)!: preserve input timelock requirements #65 (comment)

The nSequence path exists so the tx resembles an off-chain settlement
spending a timelock path, and those carry nLockTime = 0. bitcoindevkit#65 dropped the
`tx.lock_time = ZERO` that the path used to perform (correctly, since it
regressed input CLTVs) but added no precondition in its place, so a tx whose
locktime was already pinned — by an input's CLTV or by `min_locktime` — could
come out with both a near-tip locktime and a confirmation-depth sequence.
That matches neither an ordinary wallet spend nor a contract close: a third
fingerprint, worse than either branch alone.

Co-Authored-By: ValuedMammal <95981133+ValuedMammal@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanlinjin
evanlinjin force-pushed the feature/tx-template branch from d5ef9f6 to 04c5fc9 Compare August 20, 2026 08:57
@evanlinjin

evanlinjin commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

so far the shape of the API hasn't prevented me from creating PSBTs

@ValuedMammal You're right that the API in master does not stop you from creating PSBTs. This PR is not claiming to fix broken functionality. It is about improving ergonomics.

The new TxTemplate API provides a lot more transparency by returning the result and error at the call site. This also means the error types returned have fewer variants and would always be relevant to the caller. It is also easier to observe the resolved tx shape -- you can argue that the caller can build the PSBT and read it back out, but I would say that is awkward and it's not clear which parameter in PsbtParams did what to the final result.

Also mentioned already in the PR description (kinda):

  • rand input is only applicable to AFS so it's better to not contaminate calls which don't intend on doing AFS with the extra param. The TxTemplate architecture makes it more transparent where and how it's used.
  • Conceptually, it's cleaner to decouple AFS from PSBT-creation as AFS is not a PSBT concept.

In terms of the three points you raised:

  1. This seems to be a nice-to-have, not a bug. Not relevant to this PR. Would you like me to create a ticket?

  2. This is a serious bug and thanks for identifying it. Since it seems urgent, I've created a PR for it here: fix(afs): require zero locktime for the nSequence path #87 and rebased this PR on top.

  3. This is relevant to the PR. The behavior I employed for TxTemplate is last-write-wins between set_locktime and discourage_fee_sniping: 04c5fc9. The other solution would be to have TxTemplate::set_locktime_policy which takes in a LocktimePolicy (like you suggested), but with rand as part of the AFS variant.

    Edit: I decided to keep the PR's original behavior because a monotonically increasing locktime is predictable and expected. locktime will compose like input CLTVs which is easy to reason about and the caller's setting is never silently discarded.

evanlinjin and others added 3 commits August 20, 2026 09:35
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 3 commits August 20, 2026 09:36
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>
…iping`

`apply_*` reads as an independent transformation that composes with the other
shaping methods, but AFS shares the `lock_time` slot with `set_locktime`.
`discourage_fee_sniping` reads as a decision rather than a pass, and matches
Bitcoin Core's `DiscourageFeeSniping`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanlinjin
evanlinjin force-pushed the feature/tx-template branch from 04c5fc9 to b3bdf7c Compare August 20, 2026 09:38
Both write `tx.lock_time`, so the rule needs saying: the value set is a
floor, anti-fee-sniping may raise it toward the tip but never lowers it.
Also notes the two consequences — a value within ~100 blocks of the tip
leaves AFS's random backoff no room, and setting a locktime *after* AFS can
undo the nSequence path's premise that `tx.lock_time` is zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanlinjin
evanlinjin marked this pull request as draft August 20, 2026 09:58
@evanlinjin
evanlinjin force-pushed the feature/tx-template branch from b3bdf7c to a22218b Compare August 20, 2026 09:58
…fee-sniping

discourage_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 concrete case this rules out: AFS may protect the transaction by setting an
input's nSequence instead of the locktime, which it only does while lock_time is
zero. A set_locktime afterwards would rebuild a tx carrying both a near-tip
locktime and a confirmation-depth sequence — the fingerprint bitcoindevkit#87 exists to
prevent. A compile_fail doctest pins that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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

5 participants