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
+36 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints
from app.wallet.psbt_builder import MAX_TX_INPUTS
@@ -493,3 +493,38 @@ async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(s
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
assert len(entries) == 1
assert entries[0].round_id == 1
async def test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check(session_factory): # B-53
"""_tick counts in-flight bets in a session of its own, so a "building" row that
commits between that count and the participant snapshot used to be invisible to
both: the round drew and paid out without the bet, while its sats still landed in
the pool. _close_and_draw re-checks in the same session it snapshots from, and
must leave the round in "closing" for the next tick rather than draw."""
async with session_factory() as session:
session.add(RoundConfig(fee_address=""))
session.add(Round(status="closing", opened_at=datetime.now(timezone.utc)))
await session.commit()
round_ = (await session.scalars(select(Round))).one()
session.add(
RoundParticipant(
round_id=round_.id,
user_id=1,
bet_amount_sats=1_000_000_000,
bet_txid="ab" * 32,
status="building", # committed a moment after _tick counted zero
)
)
await session.commit()
round_id = round_.id
scheduler = RoundScheduler(session_factory, FakeListener())
await scheduler._close_and_draw(round_id)
async with session_factory() as session:
round_ = await session.get(Round, round_id)
assert round_.status == "closing" # not drawn, and not closed as participant-less
assert round_.winner_user_id is None
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "round_closed" not in events
assert "winner_drawn" not in events