Decouple the RBF abandon clock from the bump clock (B-27)

bump_fee (tx/broadcast.py) used to overwrite PendingTransaction.
broadcast_at on every fee bump, but reconcile.py's abandon-after-N-
hours grace period is measured from that same column. A transaction
successfully bumped every rbf_timeout_seconds (900s by default) but
never mined reset that clock before it could ever reach the 6-hour
abandon window, so it was never abandoned: its UTXOs never returned to
the user, and if it was a bet the round stayed in "closing"
indefinitely.

PendingTransaction gains a last_broadcast_at column (migration
861e76aaf34c, backfilled from broadcast_at for existing rows before
the NOT NULL constraint is applied). broadcast_at is now never
rewritten after creation, so reconcile.py's _is_due keeps measuring
from the first broadcast unchanged. bump_fee updates last_broadcast_at
instead, and should_bump now reads last_broadcast_at rather than
broadcast_at — correct, since whether another bump is due should reset
after every bump, unlike the reconciler's abandon check, which must
not.

BUGS.md moves B-27 to "Previously fixed" with the fix description; the
suite grows from 148 to 151 tests, including a direct proof that a tx
bumped a minute ago but first broadcast 7 hours ago still gets
abandoned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 09:39:38 +02:00
co-authored by Claude Sonnet 5
parent 50a43ae3ca
commit 933760e948
7 changed files with 208 additions and 29 deletions
+7
View File
@@ -144,7 +144,14 @@ class PendingTransaction(Base):
current_txid: Mapped[str] = mapped_column(String(64))
fee_rate_sat_vb: Mapped[int]
raw_tx_hex: Mapped[str] = mapped_column(String)
# The *first* broadcast — never rewritten by a bump — since this is what the
# reconciler's abandon-after-N-hours grace period (app/tx/reconcile.py) measures
# from. Bumping used to overwrite this field, which reset that clock on every
# bump and meant a repeatedly-bumped-but-never-mined tx was never abandoned
# (B-27). last_broadcast_at is the one bump_fee updates, and the one should_bump
# (app/tx/broadcast.py) reads to decide whether another bump is due.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
last_broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
status: Mapped[str] = mapped_column(String(16), default="pending")
# The txid this row had *before* its most recent RBF bump (bump_fee rewrites
# current_txid in place). Despite the name reading forwards, it points