Skip to content

fix(afs)!: preserve input timelock requirements - #65

Merged
evanlinjin merged 2 commits into
bitcoindevkit:masterfrom
aagbotemi:fix/afs-locktime
May 16, 2026
Merged

evanlinjin merged 2 commits into
bitcoindevkit:masterfrom
aagbotemi:fix/afs-locktime

Conversation

@aagbotemi

@aagbotemi aagbotemi commented May 12, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Fixes consensus-validity issues in BIP-326 anti-fee-sniping.

Closes #62
Closes #63
Closes #64

Bugs

  • The nLockTime strategy could overwrite tx.lock_time with a value lower than the accumulated input CLTV.
  • The nSequence strategy zeroed tx.lock_time, also breaking accumulated CLTV.
  • The nSequence strategy could select a Taproot input that already had a relative-timelock (CSV) requirement and overwrite its sequence.

Fixes

  • The locktime path only updates tx.lock_time when the AFS-proposed value still satisfies the existing requirement.
  • The sequence path leaves tx.lock_time alone and filters CSV-bearing Taproot inputs out of the candidate pool.

Breaking changes

  • PsbtParams::enable_anti_fee_sniping: bool + fallback_locktime → anti_fee_sniping: Option<absolute::Height> + min_locktime. The tip height is now required when AFS is enabled, and time-based tips are unrepresentable.
  • apply_anti_fee_sniping is now pub(crate); rbf_enabled param removed (derived from tx.is_explicitly_rbf()).
  • New AntiFeeSnipingError type, wrapped under CreatePsbtError::AntiFeeSniping.

@evanlinjin evanlinjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for working on this.

PR description is misleading - none of these bugs were flagged in the post-merge review. It also says this "fixes two consensus-validity issues", however this PR attempts to close 3 issues?

Comment thread src/utils.rs Outdated
Comment thread src/selection.rs
Comment thread src/utils.rs Outdated
Comment thread src/utils.rs

@evanlinjin evanlinjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There are two versions of the random_probability and random_range methods. The std versions add no meaningful value imo - I would just remove them.

@aagbotemi

Copy link
Copy Markdown
Collaborator Author

Thank you for the review

@aagbotemi
aagbotemi requested a review from evanlinjin May 13, 2026 13:29
evanlinjin

This comment was marked as outdated.

Comment thread src/utils.rs Outdated
@aagbotemi
aagbotemi force-pushed the fix/afs-locktime branch 2 times, most recently from f3d7e60 to 3b72e6d Compare May 14, 2026 09:59
@aagbotemi
aagbotemi requested a review from evanlinjin May 14, 2026 10:02
Comment thread src/selection.rs
@aagbotemi
aagbotemi force-pushed the fix/afs-locktime branch 2 times, most recently from 3b72e6d to b0b0eb1 Compare May 14, 2026 13:56

@evanlinjin evanlinjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Make apply_anti_fee_sniping explicitly private!

Comment thread src/utils.rs Outdated
Comment thread src/utils.rs Outdated
Comment thread src/utils.rs Outdated
Comment thread src/utils.rs Outdated
Comment thread src/utils.rs Outdated
Comment thread src/utils.rs Outdated
@aagbotemi
aagbotemi force-pushed the fix/afs-locktime branch 3 times, most recently from 15943a8 to adf472f Compare May 14, 2026 15:35
Comment thread examples/anti_fee_sniping.rs
@evanlinjin

Copy link
Copy Markdown
Member

We are lacking commit messages, PR description has factual errors, missing ! to signal breaking change.

I'll push it forward!

@evanlinjin evanlinjin changed the title fix(afs): preserve accumulated locktime fix(afs)!: preserve input timelock requirements May 15, 2026
@evanlinjin
evanlinjin force-pushed the fix/afs-locktime branch 2 times, most recently from b8671c3 to c83079a Compare May 15, 2026 18:59
@evanlinjin
evanlinjin requested a review from nymius May 15, 2026 20:01
@evanlinjin

Copy link
Copy Markdown
Member

@aagbotemi I pushed a Claude-generated test commit on top, let me know what you think.

Comment thread src/selection.rs Outdated
@evanlinjin
evanlinjin force-pushed the fix/afs-locktime branch 3 times, most recently from 059752f to 2fcd3c5 Compare May 16, 2026 12:31
aagbotemi and others added 2 commits May 16, 2026 12:41
Anti-fee-sniping could produce consensus-invalid transactions when
inputs had timelock requirements:

- The nLockTime strategy could overwrite `tx.lock_time` with a value
  lower than the accumulated input CLTV.
- The nSequence strategy zeroed `tx.lock_time`, also breaking
  accumulated CLTV.
- The nSequence strategy could select a Taproot input that already
  had a relative-timelock (CSV) requirement and overwrite its
  sequence.

The locktime path now only updates `tx.lock_time` when the proposed
AFS value still satisfies the existing requirement. The sequence
path leaves `tx.lock_time` alone and filters CSV-bearing Taproot
inputs out of the candidate pool.

API changes:

- `PsbtParams::enable_anti_fee_sniping: bool` and `fallback_locktime`
  are replaced with `anti_fee_sniping: Option<absolute::Height>` and
  `min_locktime`. The tip height is now required when AFS is enabled
  and time-based tips are unrepresentable.
- `apply_anti_fee_sniping` is now `pub(crate)`; the `rbf_enabled`
  parameter is removed (derived from `tx.is_explicitly_rbf()`).
- New `AntiFeeSnipingError` type, wrapped under
  `CreatePsbtError::AntiFeeSniping`.

Closes bitcoindevkit#62
Closes bitcoindevkit#63
Closes bitcoindevkit#64
Three new tests, each verified to fail on the pre-fix algorithm:

- `test_anti_fee_sniping_preserves_input_cltv` — input CLTV above tip
  must not be lowered.
- `test_anti_fee_sniping_skips_taproot_csv_input` — a Taproot input
  carrying a CSV requirement must be excluded from the nSequence
  candidate pool; a regular Taproot input remains to ensure the
  sequence path is still reachable.
- `test_anti_fee_sniping_rejects_time_based_locktime` — a time-based
  CLTV must surface `AntiFeeSnipingError::UnsupportedLockTime`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@evanlinjin evanlinjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ACK 5df9bb1

@evanlinjin
evanlinjin merged commit 8b51d07 into bitcoindevkit:master May 16, 2026
7 checks passed
@ValuedMammal ValuedMammal mentioned this pull request Jun 2, 2026
Comment thread src/utils.rs
.iter()
.find(|input| input.prev_outpoint() == txin.previous_output)?;
if input.prev_txout().script_pubkey.is_p2tr() {
if input.prev_txout().script_pubkey.is_p2tr() && input.relative_timelock().is_none() {

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.

BIP326 is under-specified when it comes to inputs that already come with a CSV attached. We chose a conservative reading by excluding all inputs with a relative timelock, but a refinement could be to admit block-based CSV inputs whose requirement is dominated by their confirmation depth.

Then eligibility becomes "is p2tr, and either has no relative timelock, or has a block-based one whose value is < input.confirmations(tip_height)" at which point the logic collapses to just confirmations (except that the 10% random backoff must floor at max(csv_blocks, 1) instead of 1).

Comment thread src/utils.rs
}
} else {
// Use Sequence
tx.lock_time = LockTime::ZERO;

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.

The sequence branch exists so your transaction resembles an off-chain settlement spending a timelock path, and those transactions have nLockTime = 0 with a CSV-driven sequence. A transaction carrying both a near-tip locktime and a confirmation-depth sequence resembles neither an ordinary AFS wallet spend nor a contract closing. You've built a third fingerprint, which is worse than either branch alone. So the current code isn't just semantically loose, it actively undercuts the branch's only purpose. The sequence branch is only ever coherent when tx.lock_time is already zero, so make that a precondition as part of must_use_locktime.

let must_use_locktime = tx.lock_time != LockTime::ZERO
    || taproot_inputs.is_empty()
    || inputs.iter().any(|input| { /* ... */ });

Comment thread src/selection.rs
/// an input's CLTV, provided the locktime units agree. If `min_locktime` uses a different unit
/// (block-height vs. time) than an input's CLTV, it is ignored — a height-based `min_locktime`
/// will not be combined with a time-based CLTV (and vice versa).
pub min_locktime: absolute::LockTime,

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.

There is essentially no value of min_locktime that is simultaneously meaningful and compatible with AFS. That's a strong signal the two fields are answering the same question - "who decides nLockTime when the inputs don't force it, and should both be settable?"

  • Below tip - 100: AFS always wins the max, so the floor does nothing
  • Within (tip - 100, tip]: The conflict zone, truncates or erases the 10% backoff
  • Above tip: The floor always wins, so AFS does nothing

So I'd fold them into a LockTimePolicy with the default as Min(LockTime::ZERO), replacing both PsbtParams fields. Input CLTV requirements still max on top in either arm; document that once in the enum's doc, instead of scattered across two fields that have to cross-reference each other.

pub enum LockTimePolicy {
    /// Floor `tx.lock_time` at this value.
    Min(absolute::LockTime),
    /// Apply BIP-326 anti-fee-sniping using this chain tip.
    AntiFeeSniping(absolute::Height),
}

evanlinjin added a commit to evanlinjin/bdk-tx that referenced this pull request Aug 20, 2026
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: Claude Opus 5 <noreply@anthropic.com>
evanlinjin added a commit to evanlinjin/bdk-tx that referenced this pull request Aug 20, 2026
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 added a commit that referenced this pull request Aug 21, 2026
e05146e fix(afs): require zero locktime for the nSequence path (志宇)

Pull request description:

  ### Description

  Fixes #65 (comment) — point 2 of #73 (comment).

  The nSequence path exists so the tx resembles an off-chain settlement spending a timelock path, and those carry `nLockTime = 0` with a CSV-driven sequence.

  #65 removed the `tx.lock_time = LockTime::ZERO` that path used to perform — correctly, since it regressed input CLTVs — but added no precondition in its place. So a tx whose locktime is already pinned, by an input's CLTV or by `min_locktime`, can 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.

  The path is only coherent at `lock_time == 0`, so make that a precondition:

  ```rust
  let must_use_locktime = tx.lock_time != LockTime::ZERO
      || taproot_inputs.is_empty()
      || inputs.iter().any(|input| { /* ... */ });
  ```

  ### Changelog notice

  ```md
  Fixed:
  - Anti-fee-sniping only takes the `nSequence` path when `tx.lock_time` is zero. A locktime pinned by an input's CLTV or by `min_locktime` now always takes the `nLockTime` path.
  ```

  ### Before submitting

  - [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk-tx/blob/master/CONTRIBUTING.md)
  - [ ] This PR breaks the existing API

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

ACKs for top commit:
  ValuedMammal:
    tACK e05146e

Tree-SHA512: 2c2e0f8630d5ab33dbf6a848325040d175ac4d10d116b5813bde51f668230308443cfd659b0658fc36ac0ad4ab4150de90e3c4a9600eea2a9a17aef96068e874
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants