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:
@@ -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
|
||||
|
||||
+16
-5
@@ -27,12 +27,19 @@ class RbfError(Exception):
|
||||
|
||||
|
||||
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
|
||||
"""Pure decision: has this pending tx been unconfirmed for longer than the
|
||||
configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the
|
||||
I/O-heavy bump_fee() so it's trivially unit-testable."""
|
||||
"""Pure decision: has this pending tx gone unconfirmed for longer than the
|
||||
configured timeout (RoundConfig.rbf_timeout_seconds) *since it was last
|
||||
broadcast*? Kept separate from the I/O-heavy bump_fee() so it's trivially
|
||||
unit-testable.
|
||||
|
||||
Deliberately measured from last_broadcast_at, not broadcast_at: this decides
|
||||
whether *another* bump is due, which should reset after every bump (a tx just
|
||||
rebroadcast at a higher fee deserves the same grace period again) — unlike
|
||||
reconcile.py's abandon check, which must measure from the *first* broadcast so
|
||||
repeated bumping can't indefinitely postpone ever giving up on a tx (B-27)."""
|
||||
if pending.status != "pending":
|
||||
return False
|
||||
return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
||||
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
||||
|
||||
|
||||
async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple:
|
||||
@@ -119,7 +126,11 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi
|
||||
pending.raw_tx_hex = raw_hex
|
||||
pending.fee_rate_sat_vb = new_fee_rate
|
||||
pending.attempt_count += 1
|
||||
pending.broadcast_at = datetime.now(timezone.utc)
|
||||
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
|
||||
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
|
||||
# from it — overwriting it here used to reset that clock on every bump, so a
|
||||
# repeatedly-bumped-but-never-mined tx was never abandoned.
|
||||
pending.last_broadcast_at = datetime.now(timezone.utc)
|
||||
await _retarget_txid_references(session, pending, old_txid, new_txid)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -129,6 +129,12 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl
|
||||
|
||||
|
||||
def _is_due(row: PendingTransaction, now: datetime) -> bool:
|
||||
# Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an
|
||||
# RBF bump used to overwrite this same field, which reset this grace period on
|
||||
# every bump and meant a repeatedly-bumped-but-never-mined tx was never
|
||||
# abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches
|
||||
# last_broadcast_at, so this keeps measuring from when the tx first appeared,
|
||||
# no matter how many times it's since been bumped.
|
||||
grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS
|
||||
return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user