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:
2026-08-03 22:06:21 +02:00
co-authored by Claude Opus 5
parent 025754c860
commit 64f62291d2
6 changed files with 212 additions and 75 deletions
+8 -68
View File
@@ -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 51499 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