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:
+44
-3
@@ -1,12 +1,13 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embit import script
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
@@ -26,6 +27,11 @@ class BetError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
class _RoundClosedDuringBuild(Exception):
|
||||
"""Internal signal (B-53): the round stopped accepting bets while this one was
|
||||
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
|
||||
|
||||
|
||||
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
if round_ is None:
|
||||
@@ -115,7 +121,42 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
session.add(participant)
|
||||
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
|
||||
# B-53: the deadline check at the top of this function happened before the UTXO
|
||||
# scan and the signing above, so re-check it here against the clock as it is now —
|
||||
# a slow build must not sneak a bet past the round's deadline.
|
||||
#
|
||||
# And then the part the clock can't cover: a compare-and-set on the round's own
|
||||
# row, in the *same* transaction as the participant insert. The scheduler flips
|
||||
# "open" -> "closing" in a transaction of its own and only counts in-flight
|
||||
# participants afterwards, so without this a bet could commit its "building" row
|
||||
# in between and be paid into the pool while the round drew and paid out without
|
||||
# it — money credited to no round, no participant and no refund path. The UPDATE
|
||||
# takes SQLite's write lock, so the two transactions can no longer interleave:
|
||||
# either this commits first and the scheduler's subsequent in-flight count sees
|
||||
# the row, or the flip commits first and this matches zero rows and refuses the
|
||||
# bet before anything is broadcast.
|
||||
try:
|
||||
if not round_accepts_bets(round_, config.round_duration_seconds):
|
||||
raise _RoundClosedDuringBuild
|
||||
guard = await session.execute(
|
||||
update(Round)
|
||||
.where(Round.id == round_.id, Round.status == "open")
|
||||
.values(status="open")
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if guard.rowcount != 1:
|
||||
raise _RoundClosedDuringBuild
|
||||
await session.commit()
|
||||
except (_RoundClosedDuringBuild, OperationalError) as exc:
|
||||
# OperationalError here is SQLite's write-snapshot conflict: the round row
|
||||
# changed under us, which is the same situation as the guard matching nothing.
|
||||
# Nothing has been broadcast yet, so the rollback undoes phase 1 entirely —
|
||||
# the UTXOs stay unspent and no participant row survives.
|
||||
await session.rollback()
|
||||
raise BetError(
|
||||
"round_closing", "the current round is closing, please try again shortly"
|
||||
) from exc
|
||||
|
||||
# --- Phase 2: broadcast, then promote both rows to their live state ---------
|
||||
try:
|
||||
|
||||
@@ -120,6 +120,27 @@ class RoundScheduler:
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
|
||||
# B-53: re-check for in-flight bets in the *same* session that snapshots
|
||||
# the participants. _tick's check ran in a session of its own, so a bet
|
||||
# committing its "building" row in between was counted by neither: the
|
||||
# round drew and paid out without it, while its sats still landed in the
|
||||
# pool. place_bet's compare-and-set on the round row is what makes that
|
||||
# window unreachable; this is the cheap second lock on the same door, and
|
||||
# it fails safe — the round stays "closing" and the next tick retries.
|
||||
pending_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(RoundParticipant)
|
||||
.where(
|
||||
RoundParticipant.round_id == round_id,
|
||||
RoundParticipant.status.in_(("building", "broadcast")),
|
||||
)
|
||||
)
|
||||
if pending_count:
|
||||
logger.info(
|
||||
"round %s: %s bet(s) still in flight at close time, waiting", round_id, pending_count
|
||||
)
|
||||
return
|
||||
|
||||
participants = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
|
||||
Reference in New Issue
Block a user