Fix the round-open race, the advertised jackpot, and payout error handling

Round opening (B-09). open_new_round_if_needed now handles the IntegrityError
from ix_rounds_single_active (previous commit) by rolling back and using the
winner's round. Deviation from the plan in BUGS.md, which proposed making the
scheduler the only writer: that would mean the first bet after a cooldown
couldn't open a round, so both callers stay and a bounded retry was added
instead — a conflict where nothing is active yet just means the winner hadn't
committed, and a bet must not fail on that timing. get_active_round also logs
loudly if it ever sees more than one active round rather than silently picking
the newest.

The jackpot (B-11). It was participant_count * the *current* bet_amount_sats,
which overstated the pool (each stored bet is already net of that bet's network
fee) and silently rewrote the advertised jackpot of a round in progress whenever
an operator edited the bet amount. It now sums the participants' stored
bet_amount_sats. The remaining imprecision — the payout tx's own fee, deducted
from the winner's share and unknowable until the payout is built — is documented
in the code rather than promised away, since the comment there claimed exactness.

Payout (B-05, B-18). _trigger_payout is split into read / build+broadcast /
persist, so no DB session is held across a network call (on SQLite that meant
holding the write lock for two unbounded round-trips). That restructuring is also
what makes the error handling placeable: it now catches Exception around the
chain work and writes a payout_failed audit entry, where a malformed fee_address
used to raise EmbitError all the way to the scheduler's catch-all, leaving the
round stuck in paying_out with nothing recorded about why. Automatic payout retry
remains an open gap.

The scheduler also counts "building" participants as in-flight when deciding
whether a round may close, matching the two-phase bet write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:32:22 +02:00
co-authored by Claude Opus 5
parent b4d70385a6
commit daf66fd6bc
5 changed files with 279 additions and 44 deletions
+15 -4
View File
@@ -118,6 +118,16 @@ async def current_round(
participant_count = await session.scalar( participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id) select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
) or 0 ) or 0
# The pool is the sum of what the participants' bets actually paid into the pool
# address — each one is already net of that bet's network fee. Deriving it from
# participant_count * the *current* bet_amount_sats instead overstated it, and
# silently changed the advertised jackpot of a round in progress whenever an
# operator edited the bet amount (B-11).
pool_amount_sats = await session.scalar(
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
RoundParticipant.round_id == round_.id
)
) or 0
opened_at = round_.opened_at.replace(tzinfo=timezone.utc) opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds) closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
@@ -137,10 +147,11 @@ async def current_round(
await session.commit() await session.commit()
# Shown to players as "jackpot": the winner's 70% share of the pool (same # Shown to players as "jackpot": the winner's 70% share of the pool (same split
# split rounds/scheduler.py applies at payout time), not the full pool # rounds/scheduler.py applies at payout time), not the full pool. It remains an
# what's displayed should match what the winner actually receives. # upper bound by the payout tx's own fee, which is deducted from the winner's
pool_amount_sats = participant_count * config.bet_amount_sats # share and isn't knowable until the payout is built — a few hundred sat on a
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
jackpot_sats = pool_amount_sats * 70 // 100 jackpot_sats = pool_amount_sats * 70 // 100
return CurrentRoundResponse( return CurrentRoundResponse(
+76 -29
View File
@@ -75,10 +75,17 @@ class RoundScheduler:
broadcaster.publish() broadcaster.publish()
async with self._session_factory() as session: async with self._session_factory() as session:
# "building" counts as in-flight too: it's a bet mid-broadcast (see
# bets/service.py's two-phase write). A bet that never confirms is
# eventually removed by app/tx/reconcile.py, which is what stops this
# wait from being unbounded.
pending_count = await session.scalar( pending_count = await session.scalar(
select(func.count()) select(func.count())
.select_from(RoundParticipant) .select_from(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "broadcast") .where(
RoundParticipant.round_id == round_id,
RoundParticipant.status.in_(("building", "broadcast")),
)
) )
if pending_count: if pending_count:
return # wait for in-flight bets to confirm before closing; stays "closing" return # wait for in-flight bets to confirm before closing; stays "closing"
@@ -154,49 +161,73 @@ class RoundScheduler:
return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex) return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex)
async def _trigger_payout(self, round_id: int) -> None: async def _trigger_payout(self, round_id: int) -> None:
"""Three phases, so no DB session is held across a network call (B-18): read
what's needed, do the chain work, then persist the outcome."""
client = self._listener.client client = self._listener.client
if client is None: if client is None:
logger.error("round %s payout deferred: not connected", round_id) logger.error("round %s payout deferred: not connected", round_id)
return return
# --- Phase 1: read (session closed before any network I/O) ---------------
async with self._session_factory() as session: async with self._session_factory() as session:
round_ = await session.get(Round, round_id) round_ = await session.get(Round, round_id)
config = await get_round_config(session) config = await get_round_config(session)
if not config.fee_address: fee_address = config.fee_address
logger.error( fee_rate = config.fee_rate_sat_vb
"round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id pool_amount_sats = round_.pool_amount_sats
) winner_user_id = round_.winner_user_id
return winner = await session.get(User, winner_user_id)
winner_address = winner.address if winner is not None else None
await session.commit() # get_round_config may have created the row
winner = await session.get(User, round_.winner_user_id) if not fee_address:
winner_share = round_.pool_amount_sats * 70 // 100 logger.error(
commission_share = round_.pool_amount_sats - winner_share # remainder from rounding goes to fees "round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id
)
return
if winner_address is None:
logger.error("round %s payout blocked: winner user %s not found", round_id, winner_user_id)
return
winner_share = pool_amount_sats * 70 // 100
commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees
# --- Phase 2: build and broadcast ----------------------------------------
try:
pool_key = derive_pool_key() pool_key = derive_pool_key()
pool_script_obj = script.p2wpkh(pool_key.to_public()) pool_script_obj = script.p2wpkh(pool_key.to_public())
pool_address = pool_script_obj.address(network=PLM_MAINNET) pool_address = pool_script_obj.address(network=PLM_MAINNET)
pool_scripthash = address_to_scripthash(pool_address) entries = await client.listunspent(address_to_scripthash(pool_address))
entries = await client.listunspent(pool_scripthash)
utxos = [Utxo(e["tx_hash"], e["tx_pos"], e["value"]) for e in entries if e["height"] > 0] utxos = [Utxo(e["tx_hash"], e["tx_pos"], e["value"]) for e in entries if e["height"] > 0]
try: built = build_payout_transaction(
built = build_payout_transaction( signing_key=pool_key,
signing_key=pool_key, from_script=pool_script_obj,
from_script=pool_script_obj, utxos=utxos,
utxos=utxos, winner_address=winner_address,
winner_address=winner.address, winner_share_sats=winner_share,
winner_share_sats=winner_share, fee_address=fee_address,
fee_address=config.fee_address, commission_sats=commission_share,
commission_sats=commission_share, change_address=pool_address,
change_address=pool_address, fee_rate_sat_vb=fee_rate,
fee_rate_sat_vb=config.fee_rate_sat_vb, )
)
except InsufficientFundsError:
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
return
await client.broadcast(built.raw_hex) await client.broadcast(built.raw_hex)
except InsufficientFundsError:
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
return
except Exception:
# Anything else — a malformed fee_address (EmbitError), a rejected
# broadcast, a dead connection. This used to escape all the way to
# run()'s catch-all, which logged it without recording anything, leaving
# no trace of *why* the round was stuck (B-05). The round stays in
# "paying_out" either way: automatic payout retry is still an open gap.
logger.exception("round %s payout failed", round_id)
await self._log_payout_failure(round_id, winner_user_id)
return
# --- Phase 3: persist -----------------------------------------------------
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
round_.winner_amount_sats = built.winner_sats round_.winner_amount_sats = built.winner_sats
round_.fee_amount_sats = built.commission_sats round_.fee_amount_sats = built.commission_sats
round_.payout_txid = built.txid round_.payout_txid = built.txid
@@ -205,7 +236,7 @@ class RoundScheduler:
kind="payout", kind="payout",
round_id=round_id, round_id=round_id,
current_txid=built.txid, current_txid=built.txid,
fee_rate_sat_vb=config.fee_rate_sat_vb, fee_rate_sat_vb=fee_rate,
raw_tx_hex=built.raw_hex, raw_tx_hex=built.raw_hex,
status="pending", status="pending",
) )
@@ -214,9 +245,25 @@ class RoundScheduler:
session, session,
"payout_sent", "payout_sent",
{"txid": built.txid, "winner_sats": built.winner_sats, "commission_sats": built.commission_sats}, {"txid": built.txid, "winner_sats": built.winner_sats, "commission_sats": built.commission_sats},
user_id=round_.winner_user_id, user_id=winner_user_id,
round_id=round_id, round_id=round_id,
) )
await session.commit() await session.commit()
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid) logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
async def _log_payout_failure(self, round_id: int, winner_user_id: int | None) -> None:
"""Leaves an operator-visible trace in the audit log for a round stuck in
"paying_out" — the logs alone don't show up in /admin."""
try:
async with self._session_factory() as session:
await write_audit_log(
session,
"payout_failed",
{"round_id": round_id},
user_id=winner_user_id,
round_id=round_id,
)
await session.commit()
except Exception:
logger.exception("could not record the payout failure of round %s", round_id)
+55 -11
View File
@@ -1,20 +1,43 @@
import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Round from app.db.models import Round
from app.rounds.config import get_round_config from app.rounds.config import get_round_config
from app.rounds.events import broadcaster from app.rounds.events import broadcaster
logger = logging.getLogger(__name__)
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out") _ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
# Bounded: a conflict means someone else is opening a round right now, so a couple
# of retries is plenty. Unbounded retries could spin if the invariant were ever
# broken in a way we don't anticipate.
_OPEN_ROUND_ATTEMPTS = 3
async def get_active_round(session: AsyncSession) -> Round | None: async def get_active_round(session: AsyncSession) -> Round | None:
"""The round currently in progress (in any non-closed state), if any. Rounds """The round currently in progress (in any non-closed state), if any. Rounds
never overlap: a new round only opens once the previous one is fully closed never overlap: a new round only opens once the previous one is fully closed
(payout confirmed, or no participants to pay out).""" (payout confirmed, or no participants to pay out).
return await session.scalar(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
The database enforces "at most one active round" (ix_rounds_single_active, see
app/db/models.py), so the ordering below is belt-and-braces; if it ever does
see two, that's a broken invariant and worth a loud log rather than silently
picking one."""
active = (
await session.scalars(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
).all()
if len(active) > 1:
logger.error(
"invariant violated: %s rounds are active at once (ids=%s) — using the newest",
len(active),
[r.id for r in active],
)
return active[0] if active else None
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool: def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
@@ -55,12 +78,33 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds): if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
return None return None
round_ = Round(status="open") for attempt in range(_OPEN_ROUND_ATTEMPTS):
session.add(round_) round_ = Round(status="open")
await session.flush() session.add(round_)
# Published pre-commit (the caller commits right after) — acceptable: this try:
# only tells subscribers "go refetch", and by the time an SSE client's await session.flush()
# refetch request actually lands, this in-process commit (microseconds except IntegrityError:
# away) has essentially always already happened. # Another caller (the scheduler tick, or a concurrent place_bet) got
broadcaster.publish() # there first — ix_rounds_single_active turns what used to be two live
return round_ # rounds into a clean failure here. Roll our insert back and use theirs.
# Safe to roll back: this runs before its callers have written anything
# else in this session.
await session.rollback()
existing = await get_active_round(session)
if existing is not None:
logger.info("lost the race to open a round; using round %s", existing.id)
return existing
# Nothing active *and* the insert conflicted: the winner's transaction
# hadn't committed yet when we looked. Try again rather than failing the
# caller — a bet shouldn't 500 because of a scheduler tick's timing.
logger.info("round-open conflict with nothing active yet (attempt %s), retrying", attempt + 1)
continue
# Published pre-commit (the caller commits right after) — acceptable: this
# only tells subscribers "go refetch", and by the time an SSE client's
# refetch request actually lands, this in-process commit (microseconds
# away) has essentially always already happened.
broadcaster.publish()
return round_
logger.error("could not open a round after %s attempts", _OPEN_ROUND_ATTEMPTS)
return await get_active_round(session)
+67
View File
@@ -94,3 +94,70 @@ async def test_user_played_true_only_for_participants(client):
resp = await ac.get("/rounds/current") # no auth at all — logged-out chain-only view resp = await ac.get("/rounds/current") # no auth at all — logged-out chain-only view
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["user_played"] is False assert resp.json()["user_played"] is False
async def test_jackpot_comes_from_the_participants_actual_bets(client):
"""B-11: the jackpot was participant_count * the *current* bet_amount_sats, which
overstated it (each stored bet is already net of that bet's network fee) and
silently rewrote the advertised jackpot of a round in progress whenever an
operator edited the bet amount."""
from sqlalchemy import select
from app.db.models import Round, RoundConfig, RoundParticipant
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
session.add(Round(id=50, status="open"))
await session.flush()
# Two bets that actually paid 999_800_000 each (fee deducted), not 1_000_000_000.
session.add(
RoundParticipant(round_id=50, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
)
session.add(
RoundParticipant(round_id=50, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="confirmed")
)
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["participant_count"] == 2
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
# Changing the configured bet amount must not move a running round's jackpot.
async with session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
config.bet_amount_sats = 5_000_000_000
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
async def test_unhandled_errors_use_the_structured_detail_shape(client):
"""B-24: the catch-all handler answered with a bare-string `detail`, while
app/api/errors.py documents detail as {"code", "message", "params"}. Clients then
had to special-case exactly the responses they understand least."""
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.main import log_unhandled_exception
app = FastAPI()
app.add_exception_handler(Exception, log_unhandled_exception)
@app.get("/boom")
async def boom():
raise RuntimeError("secret internal detail")
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get("/boom")
assert resp.status_code == 500
detail = resp.json()["detail"]
assert detail["code"] == "internal_error"
assert detail["message"] == "internal server error"
assert detail["params"] == {}
# The exception text belongs in logs/app.log, never in the response body.
assert "secret internal detail" not in resp.text
+66
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base from app.db.base import Base
@@ -105,3 +106,68 @@ async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
returned = await open_new_round_if_needed(session) returned = await open_new_round_if_needed(session)
assert returned is not None assert returned is not None
assert returned.status == "drawing" assert returned.status == "drawing"
async def test_losing_the_open_race_reuses_the_winning_round(session_factory, monkeypatch):
"""B-09: open_new_round_if_needed was a read-then-insert with no lock, called from
both the scheduler and every place_bet, so two callers could both see "no active
round" and insert one — and a second stuck "open" row blocks every future round,
since get_active_round matches on status.
The race is forced deterministically: the round already exists and is committed,
but this caller's first look is made to miss it (exactly what the loser of the
race sees). The insert then hits ix_rounds_single_active, and the caller must
recover by using the winner's round instead of raising at its caller — a bet must
not fail because a scheduler tick beat it by a millisecond.
"""
from app.rounds import service as service_module
async with session_factory() as session:
session.add(Round(status="open"))
await session.commit()
real_get_active_round = service_module.get_active_round
calls = {"n": 0}
async def blind_first_look(session):
calls["n"] += 1
if calls["n"] == 1:
return None # what the loser of the race sees
return await real_get_active_round(session)
monkeypatch.setattr(service_module, "get_active_round", blind_first_look)
async with session_factory() as session:
round_ = await service_module.open_new_round_if_needed(session)
await session.commit()
assert round_ is not None # recovered, didn't raise
async with session_factory() as session:
rounds = (await session.scalars(select(Round))).all()
assert len(rounds) == 1, f"expected one round, got {[(r.id, r.status) for r in rounds]}"
assert round_.id == rounds[0].id # the winner's round, not a second one
async def test_the_database_refuses_a_second_active_round(session_factory):
"""The guarantee itself, independent of the application code path."""
from sqlalchemy.exc import IntegrityError
async with session_factory() as session:
session.add(Round(status="open"))
await session.commit()
async with session_factory() as session:
session.add(Round(status="drawing"))
with pytest.raises(IntegrityError):
await session.commit()
async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
async with session_factory() as session:
session.add(Round(status="closed"))
session.add(Round(status="closed"))
session.add(Round(status="open"))
await session.commit()
async with session_factory() as session:
assert len((await session.scalars(select(Round))).all()) == 3