Close the window where a bet pays into a round it was left out of (B-53)
place_bet commits its participant row as "building" before broadcasting (B-08's two-phase write), while the scheduler flips the round "open" -> "closing" in one transaction and counts in-flight participants in another. A bet whose deadline check passed just before that flip could commit in between: the count saw zero, so the round drew and paid out over the "confirmed" participants only, while the bet confirmed normally and its sats landed in the pool address — credited to no round, to no participant, with no refund path, silently improving the next round's payout change. Two locks on the same door: - place_bet re-checks the deadline after building and signing (the first check happens before the UTXO scan, so a slow build could carry a bet past it), then commits the participant row behind a compare-and-set on the round's own row, UPDATE rounds ... WHERE status = 'open'. That UPDATE takes SQLite's write lock, so the two transactions can no longer interleave: either the bet commits first and the scheduler's in-flight count sees it, or the flip commits first and the guard matches zero rows and refuses the bet with round_closing before anything is broadcast. A write-snapshot conflict (OperationalError) is the same situation and gets the same answer. Nothing has been broadcast at that point, so the rollback releases the UTXOs and leaves no rows behind. - _close_and_draw re-counts in-flight bets in the same session it snapshots the participants from, and returns with the round still "closing" if it finds any. Redundant given the CAS, and cheap: it fails safe and the next tick retries. No new error code — a bet refused this way is exactly the "round is closing" case the user already sees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,14 @@ Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
|
||||
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
|
||||
fixed finding, B-51.
|
||||
|
||||
Nothing in this list is fixed yet — it is the analysis pass only. Per CLAUDE.md's
|
||||
convention each entry gets its own commit with its own regression test, and the
|
||||
`B-nn` marker goes in a comment next to the fix so `git log --all --grep 'B-nn'`
|
||||
finds it later.
|
||||
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
|
||||
removed from this file once it is fixed. Per CLAUDE.md's convention each entry gets
|
||||
its own commit with its own regression test, and the `B-nn` marker goes in a comment
|
||||
next to the fix so `git log --all --grep 'B-nn'` finds it later.
|
||||
|
||||
Already fixed and removed: B-52 (a round past ~50 participants deadlocked the
|
||||
payout — `025754c`), B-53 (a bet could pay into the pool of a round it was left
|
||||
out of — `PLACEHOLDER`).
|
||||
|
||||
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
|
||||
still empty; withdrawal and the RBF bump path still never live-broadcast.
|
||||
@@ -28,70 +32,6 @@ Severity is about consequence, not likelihood:
|
||||
|
||||
## Critical
|
||||
|
||||
### B-52 — a round with more than ~50 participants deadlocks the platform permanently — **FIXED**
|
||||
|
||||
`app/wallet/psbt_builder.py:42` (`MAX_TX_INPUTS = 50`),
|
||||
`app/rounds/scheduler.py:349-373`.
|
||||
|
||||
Every confirmed bet leaves exactly one UTXO on the pool address, and the payout's
|
||||
selection target is `winner_share + commission`, i.e. the whole pool — so it needs
|
||||
*all* n bet UTXOs as inputs. At n ≥ 51 `select_utxos` raises `too_many_inputs`,
|
||||
`_trigger_payout` records `payout_failed`, and `_retry_payout_if_due` re-attempts
|
||||
every 60 s forever. The round stays `paying_out`, so `open_new_round_if_needed`
|
||||
never opens another round: the lottery halts, the pool is unspendable through the
|
||||
normal path, and the only way out is a manual consolidation with the pool key.
|
||||
|
||||
CLAUDE.md presents B-48's input cap purely as a fragmented *user* address problem.
|
||||
The pool case is structural rather than an edge case: participant count alone
|
||||
causes it, with the default bet amount and no unusual deposit pattern.
|
||||
|
||||
**Fixed** by moving the limit from where it was *discovered* to where it can still be
|
||||
*enforced*:
|
||||
|
||||
- `select_utxos` takes the cap as a parameter. Bets and withdrawals keep
|
||||
`MAX_TX_INPUTS = 50` (a user-protection limit: the fee comes out of the amount
|
||||
they are moving); the payout uses the new `MAX_PAYOUT_TX_INPUTS = 500`, since the
|
||||
pool's UTXO count is just the number of bets and the fee comes out of a 70% share
|
||||
of the whole pool. 500 inputs is ~34 kvB, about a third of the 100 kvB relay
|
||||
standardness budget; signing that many costs ~0.4 s of event loop, once per round,
|
||||
in a background task.
|
||||
- `place_bet` refuses the bet past `MAX_PARTICIPANTS_PER_ROUND = 400` with a new
|
||||
`round_full` error (400, translated into all 7 languages), counting every
|
||||
participant row rather than only the confirmed ones. The cap sits below the input
|
||||
cap so the payout keeps headroom for pool change accumulated by earlier rounds.
|
||||
|
||||
The invariant is now "a round can always be paid out", enforced before any of the
|
||||
401st player's money moves. A round already wedged with 51–499 participants pays out
|
||||
by itself on the next `_retry_payout_if_due` tick.
|
||||
|
||||
Not addressed, and deliberately so: periodic pool consolidation, which is what would
|
||||
be needed to go beyond this order of magnitude (see the audit discussion — it needs a
|
||||
new PendingTransaction kind, must not run mid-round, and would force the payout math
|
||||
to tolerate a pool short of its exact target). Raising these two constants covers
|
||||
anything up to ~1400 participants first.
|
||||
|
||||
### B-53 — a bet can pay into the pool and still be left out of the draw
|
||||
|
||||
`app/bets/service.py:80-92`, `app/rounds/scheduler.py:101-129`.
|
||||
|
||||
`place_bet` commits its participant row as `building` *before* broadcasting
|
||||
(the deliberate two-phase write of B-08). The scheduler counts in-flight
|
||||
participants in one session and then reads the `confirmed` participants in a
|
||||
second, separate session. A bet that passed `round_accepts_bets` just before the
|
||||
deadline can commit its phase-1 row *between* those two queries: the count saw
|
||||
zero, so the round draws and pays out, while the new row — not yet `confirmed` —
|
||||
is excluded from `participants`. The bet then confirms normally and its sats land
|
||||
in the pool address, credited to no round and to no participant. There is no
|
||||
refund path, and the money silently improves the *next* round's payout change.
|
||||
|
||||
The window is one task switch wide, but both queries do real DB I/O, so it is
|
||||
reachable rather than theoretical.
|
||||
|
||||
Fix directions: re-check the in-flight count inside the same transaction that
|
||||
snapshots the participants (and abort the close if it is non-zero), or make the
|
||||
deadline authoritative at the row level so a bet cannot commit against a round
|
||||
whose timer has expired.
|
||||
|
||||
### (not new) `drawing` does not resume after a restart
|
||||
|
||||
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
|
||||
|
||||
Reference in New Issue
Block a user