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