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
+32 -22
View File
@@ -1,13 +1,13 @@
# Known bugs
A second full-codebase audit on 2026-07-27 found **25 further issues** (4 critical, 6 high,
7 medium, 8 low), listed below as B-25 … B-49. B-25 and B-26 are fixed as of 2026-07-27; the
other 23 are open. The 139-test suite was green at the time of the audit, so none of these
were caught by existing coverage — every fix lands with a regression test (B-25 and B-26
together brought the suite from 139 to 148).
7 medium, 8 low), listed below as B-25 … B-49. B-25, B-26 and B-27 are fixed as of 2026-07-27;
the other 22 are open. The 139-test suite was green at the time of the audit, so none of these
were caught by existing coverage — every fix lands with a regression test (B-25, B-26 and B-27
together brought the suite from 139 to 151).
The recurring pattern across B-27, B-29 and B-36 is worth stating once: the code is rigorous
about the failure modes that have actually been hit, and silent about the ones that have not.
The recurring pattern across B-29 and B-36 is worth stating once: the code is rigorous about
the failure modes that have actually been hit, and silent about the ones that have not.
Outgoing transactions reconcile; deposits do not.
**`paying_out` is now fully recoverable, not just idempotent.** B-25 made a payout retry
@@ -27,22 +27,6 @@ single-process assumptions, no user-facing history, etc.), see "Known gaps / TOD
## Critical
### B-27 — Every RBF bump resets the reconciler's abandon clock, so it never fires
`tx/broadcast.py:122` sets `pending.broadcast_at = now` on each bump, but
`tx/reconcile.py:133` computes the 6-hour abandon deadline **from that same field**.
With the default `rbf_timeout_seconds = 900`, a transaction that is successfully bumped every
15 minutes but never mined resets the counter long before it can reach 6 hours: it is **never
abandoned**, its UTXOs never return to the user, and if it is a bet the round stays in
`closing` indefinitely (`scheduler.py:90-91`). `reconcile.py` exists precisely to prevent
this, and the bumper disarms it.
**Proposed fix.** Split the field: keep `broadcast_at` as the *first* broadcast (never
rewritten — it is what `_is_due` must use) and add `last_broadcast_at`, updated by
`bump_fee` and used by `should_bump`. Alembic migration backfilling `last_broadcast_at =
broadcast_at`.
### B-28 — A hostile Electrum server (or a MITM) can choose the winner
`electrum/listener.py:167-186` accepts any header whose `height >= tip_height`: no
@@ -421,6 +405,32 @@ Regression tests: `tests/unit/test_scheduler.py`
`test_tick_throttles_retry_after_a_recent_payout_failure`,
`test_tick_retries_once_the_throttle_window_has_elapsed`).
### B-27 — Every RBF bump resets the reconciler's abandon clock, so it never fires
`tx/broadcast.py` used to set `pending.broadcast_at = now` on each bump, but
`tx/reconcile.py`'s `_is_due` computes the 6-hour abandon deadline **from that same field**.
With the default `rbf_timeout_seconds = 900`, a transaction that is successfully bumped every
15 minutes but never mined reset the counter long before it could reach 6 hours: it was
**never abandoned**, its UTXOs never returned to the user, and if it was a bet the round stayed
in `closing` indefinitely (`scheduler.py:90-91`). `reconcile.py` exists precisely to prevent
this, and the bumper disarmed it.
**Fixed:** the field is split, exactly as proposed. `PendingTransaction` gained a
`last_broadcast_at` column (Alembic migration `861e76aaf34c`, backfilled from the existing
`broadcast_at` for every pre-existing row, then made `NOT NULL`). `broadcast_at` is now never
rewritten after creation — it stays the *first* broadcast, which is what `reconcile.py:_is_due`
already read and continues to read unchanged. `bump_fee` (`tx/broadcast.py`) now updates
`last_broadcast_at` instead, and `should_bump` reads `last_broadcast_at` rather than
`broadcast_at` — correctly, since *that* decision (is another bump due?) should reset after
every bump, unlike the reconciler's abandon check, which must not.
Regression tests: `tests/unit/test_broadcast.py`
(`test_should_bump_measures_from_last_broadcast_not_first`,
`test_bump_fee_leaves_broadcast_at_untouched`) and `tests/unit/test_reconcile.py`
(`test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast`, the direct proof that
a tx bumped minutes ago but first broadcast 7 hours ago is still abandoned).
A full-codebase audit on 2026-07-26 (commit `d4e0974`) found 24 bugs across every Python
module under `app/`, both static frontends, and the Docker/Caddy deployment — 5 critical,
7 high, 7 medium, 5 low. All 24 were fixed and verified against the current code on