Files
plm-lottery/app/api/routes/rounds.py
T
davideandClaude Opus 5 daf66fd6bc 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>
2026-07-27 00:32:22 +02:00

175 lines
7.4 KiB
Python

import asyncio
import json
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_optional_user
from app.db.models import RoundParticipant, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.events import RoundEventCapacityError, broadcaster
from app.rounds.service import get_active_round
router = APIRouter(prefix="/rounds", tags=["rounds"])
# How often request.is_disconnected() gets (re-)checked while idle — bounds
# how long a subscriber slot lingers after a client goes away without a clean
# TCP close (e.g. the network just vanishes). Kept short since the check
# itself is cheap; it does NOT control how often anything is sent on the wire.
_SSE_DISCONNECT_CHECK_SECONDS = 5
# How often to send an SSE keep-alive comment on an otherwise-idle connection —
# well under any reasonable reverse-proxy/load-balancer idle-connection timeout
# (Caddy's default is 5 minutes) so the stream isn't silently dropped. Expressed
# as a multiple of the disconnect-check interval above.
_SSE_KEEPALIVE_TICKS = 4 # 4 * 5s = 20s between keep-alive comments
@router.get("/stream")
async def round_stream(request: Request) -> Response:
"""Server-Sent Events channel: pushes a content-free "update" notification
the instant round/bet/balance state changes anywhere (see app/rounds/events.py
for the publish() call sites), instead of clients only finding out on their
next poll. No payload and no auth: it's a public "go refetch" signal, and
the actual data still comes from the normal per-user REST endpoints, which
is where authorization and personalization (e.g. user_played) already live.
Frontend polling (app/static/index.html) is left in place as a fallback —
this is purely additive, so a dropped/blocked SSE connection degrades to
the pre-existing polling behavior rather than losing updates outright.
That's also what happens past MAX_SUBSCRIBERS (app/rounds/events.py): this
returns 503 rather than opening a stream, and the browser's EventSource
just retries later while the frontend keeps working off polling meanwhile.
"""
try:
queue = broadcaster.subscribe()
except RoundEventCapacityError:
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
async def event_generator():
ticks_since_keepalive = 0
try:
while True:
if await request.is_disconnected():
break
try:
await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
yield "event: update\ndata: {}\n\n".format(json.dumps({}))
ticks_since_keepalive = 0
except asyncio.TimeoutError:
ticks_since_keepalive += 1
if ticks_since_keepalive >= _SSE_KEEPALIVE_TICKS:
yield ": keep-alive\n\n"
ticks_since_keepalive = 0
finally:
broadcaster.unsubscribe(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
class CurrentRoundResponse(BaseModel):
server_time: str
round_id: int | None = None
status: str | None = None
opened_at: str | None = None
closes_at: str | None = None
participant_count: int = 0
bet_amount_sats: int
jackpot_sats: int = 0
draw_animation_seconds: int
winner_user_id: int | None = None
winner_amount_sats: int | None = None
draw_block_height: int | None = None
draw_block_hash: str | None = None
chain_tip_height: int | None = None
lottery_paused: bool = False
user_played: bool = False
@router.get("/current", response_model=CurrentRoundResponse)
async def current_round(
request: Request,
session: AsyncSession = Depends(get_session),
user: User | None = Depends(get_optional_user),
) -> CurrentRoundResponse:
config = await get_round_config(session)
round_ = await get_active_round(session)
listener = request.app.state.electrum_listener
chain_tip_height = listener.tip_height or None
if round_ is None:
await session.commit()
return CurrentRoundResponse(
server_time=datetime.now(timezone.utc).isoformat(),
bet_amount_sats=config.bet_amount_sats,
draw_animation_seconds=config.draw_animation_seconds,
chain_tip_height=chain_tip_height,
lottery_paused=config.paused,
)
participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
) 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)
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
# Lets the frontend show the personalized win/lose reveal only to players in
# this round — everyone else (not logged in, or logged in but didn't bet)
# just sees the generic phase progress instead of a "non hai vinto" that
# wouldn't mean anything to them.
user_played = False
if user is not None:
user_played = (
await session.scalar(
select(RoundParticipant).where(
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
)
)
) is not None
await session.commit()
# Shown to players as "jackpot": the winner's 70% share of the pool (same split
# rounds/scheduler.py applies at payout time), not the full pool. It remains an
# upper bound by the payout tx's own fee, which is deducted from the winner's
# 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
return CurrentRoundResponse(
server_time=datetime.now(timezone.utc).isoformat(),
round_id=round_.id,
status=round_.status,
opened_at=opened_at.isoformat(),
closes_at=closes_at.isoformat(),
participant_count=participant_count,
bet_amount_sats=config.bet_amount_sats,
jackpot_sats=jackpot_sats,
draw_animation_seconds=config.draw_animation_seconds,
winner_user_id=round_.winner_user_id,
winner_amount_sats=round_.winner_amount_sats,
draw_block_height=round_.draw_block_height,
draw_block_hash=round_.draw_block_hash,
chain_tip_height=chain_tip_height,
lottery_paused=config.paused,
user_played=user_played,
)