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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user