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