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
+100
View File
@@ -328,3 +328,103 @@ async def test_bet_is_persisted_before_it_is_broadcast(session_factory):
assert seen["pending"] == [("bet", "building")]
assert seen["participants"] == ["building"]
# --- B-53: a bet must never pay into the pool of a round it was left out of ------
async def _assert_bet_left_no_trace(session_factory, user_id: int, balance_before: int) -> None:
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid is None # nothing reserved, so the user can bet next round
assert (await session.scalars(select(RoundParticipant))).all() == []
assert (await session.scalars(select(PendingTransaction))).all() == []
user = await session.get(User, user_id)
assert user.cached_balance_sats == balance_before # the rollback undid the recompute too
async def test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit(
session_factory, monkeypatch
): # B-53
"""The scheduler flips "open" -> "closing" in a transaction of its own and only
then counts in-flight bets. A bet whose deadline check passed just before that
flip must not be able to commit its participant row afterwards: it would be
excluded from the draw (only "confirmed" participants are drawn) while its sats
still landed in the pool address — credited to no round, with no refund path.
round_accepts_bets is forced to pass so the refusal can only come from the
compare-and-set on the round row, which is the part that survives the race the
wall-clock check cannot see."""
user_id = await _make_funded_user(session_factory, 40, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
monkeypatch.setattr("app.bets.service.round_accepts_bets", lambda *args, **kwargs: True)
async with session_factory() as session:
# What the scheduler's own tick would have committed a moment earlier.
(await session.get(Round, round_id)).status = "closing"
await session.commit()
async with session_factory() as session:
user = await session.get(User, user_id)
balance_before = user.cached_balance_sats
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "round_closing"
assert not client.broadcasted # refused before any money moved
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
async def test_place_bet_rechecks_the_deadline_after_building_the_transaction(
session_factory, monkeypatch
): # B-53
"""The first deadline check happens before the UTXO scan and the signing, so a
slow build could carry a bet past the round's deadline. It is re-checked against
the clock as it is at commit time."""
user_id = await _make_funded_user(session_factory, 41, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
await open_new_round_if_needed(session)
await session.commit()
checks: list[bool] = []
def _accepts_then_expires(*args, **kwargs) -> bool:
checks.append(True)
return len(checks) == 1 # open when the bet arrived, expired by the time it was built
monkeypatch.setattr("app.bets.service.round_accepts_bets", _accepts_then_expires)
async with session_factory() as session:
user = await session.get(User, user_id)
balance_before = user.cached_balance_sats
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert len(checks) == 2 # the re-check really ran
assert excinfo.value.code == "round_closing"
assert not client.broadcasted
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
async def test_place_bet_still_succeeds_while_the_round_is_open(session_factory): # B-53
"""The guard must not refuse the normal path: an open, in-time round still takes
bets, and the round's status is left untouched by the compare-and-set."""
user_id = await _make_funded_user(session_factory, 42, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert participant.status == "broadcast"
async with session_factory() as session:
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "open"
+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