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:
@@ -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
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user