Files
plm-lottery/app/api/routes/rounds.py
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00

333 lines
15 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.api.client_ip import client_ip
from app.api.errors import http_error
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_optional_user
from app.db.models import Round, RoundParticipant, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
from app.rounds.service import get_active_round, round_deadline, rounds_can_open, winner_share
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.
Concurrent streams are additionally capped per client IP (B-38): past
MAX_SUBSCRIBERS_PER_IP, opening one more evicts that IP's own oldest
connection rather than refusing the new one or letting a single source
exhaust the global cap and degrade every other user.
"""
try:
queue = broadcaster.subscribe(client_ip(request))
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:
item = await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
if item is EVICTED:
break # this IP opened another stream past its per-IP cap
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
# B-80 (RND-35): this round's own pool address, so anyone can verify its money on
# an explorer; null when there is no active round.
pool_address: str | None = None
opened_at: str | None = None
closes_at: str | None = None
# B-65: confirmed participants only — the ones the draw actually picks from and
# whose sats are actually in the pool. The pending_* pair below is the same
# confirmed/in-flight split the balance already exposes (see
# wallet/balance.py's balance_sats vs pending_balance_sats), and for the same
# reason: the authoritative number must be the one that will be paid, while the
# player who just bet still needs to see their own bet somewhere.
participant_count: int = 0
bet_amount_sats: int
jackpot_sats: int = 0
# Inclusive of bets still building/broadcast, exactly like pending_balance_sats
# is inclusive of unconfirmed change — not deltas. Equal to the confirmed
# figures above when nothing is in flight, which is what has_pending_bets says.
pending_participant_count: int = 0
pending_jackpot_sats: int = 0
has_pending_bets: bool = False
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
# B-36: set only while status == "drawing", so the frontend can show "still
# waiting for a block" rather than a countdown implying a bounded wait — this
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
draw_waiting_since: str | None = None
# B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed
# on entering "drawing". The secret itself is never in this response (API-45 has it, once
# revealed).
server_seed_hash: str | None = None
participants_hash: str | None = None
draw_height: int | None = None
draw_confirmations: int | None = None
chain_tip_height: int | None = None
lottery_paused: bool = False
paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused
# B-66: false while the instance is missing configuration a round cannot run
# without (today: fee_address) — no round will open until it's set, so this is
# the difference between "wait, the next round is coming" and "nothing is coming
# until the operator finishes setting this up". Distinct from lottery_paused,
# which is a deliberate operator action rather than an unmet prerequisite.
lottery_configured: bool = True
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,
paused_reason=config.paused_reason,
lottery_configured=rounds_can_open(config),
)
# 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).
#
# Split confirmed from in-flight (B-65): the draw only picks from confirmed
# participants and the payout only spends their sats, so counting every row
# advertised a jackpot larger than the one that would be paid, and made a
# participant appear and then vanish again if their bet was later abandoned.
counts = (
await session.execute(
select(
func.count(),
func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0),
func.count().filter(RoundParticipant.status == "confirmed"),
func.coalesce(
func.sum(RoundParticipant.bet_amount_sats).filter(
RoundParticipant.status == "confirmed"
),
0,
),
).where(RoundParticipant.round_id == round_.id)
)
).one()
all_count, all_pool_sats, participant_count, pool_amount_sats = counts
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
# B-61: from the round's own duration — the countdown clients are watching must
# not jump because an operator edited the config mid-round.
closes_at = round_deadline(round_)
# 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 = winner_share(pool_amount_sats)
return CurrentRoundResponse(
server_time=datetime.now(timezone.utc).isoformat(),
round_id=round_.id,
status=round_.status,
pool_address=round_.pool_address or None,
opened_at=opened_at.isoformat(),
closes_at=closes_at.isoformat(),
participant_count=participant_count,
bet_amount_sats=round_.bet_amount_sats, # B-95 (API-21): the round's own, fixed at open
jackpot_sats=jackpot_sats,
pending_participant_count=all_count,
pending_jackpot_sats=winner_share(all_pool_sats),
has_pending_bets=all_count > participant_count,
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,
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
server_seed_hash=round_.server_seed_hash,
participants_hash=round_.participants_hash,
draw_height=round_.draw_height,
draw_confirmations=round_.draw_confirmations,
chain_tip_height=chain_tip_height,
lottery_paused=config.paused,
paused_reason=config.paused_reason,
lottery_configured=rounds_can_open(config),
user_played=user_played,
)
class ProofParticipant(BaseModel):
draw_position: int
txid: str
vout: int
address: str
amount_sats: int
status: str
class RoundProofResponse(BaseModel):
round_id: int
status: str
algorithm: str
pool_address: str | None
server_seed_hash: str | None
server_seed: str | None
draw_height: int | None
draw_confirmations: int
participants: list[ProofParticipant]
participants_hash: str | None
draw_block_hash: str | None
seed: str | None
winner_index: int | None
winner_address: str | None
payout_txid: str | None
@router.get("/{round_id}/proof", response_model=RoundProofResponse)
async def round_proof(round_id: int, session: AsyncSession = Depends(get_session)) -> RoundProofResponse:
"""B-73 (RND-37, API-45): everything needed to recompute a round's winner without
trusting the server — public, unauthenticated, for a round in any state. A field that
does not exist yet is null (or an empty list).
`server_seed` is the plaintext column, which is written only by the reveal (the same
transaction that stores the winner, or the close of a round without participants), so
there is no state of the round in which this endpoint can return it early.
"""
round_ = await session.get(Round, round_id)
if round_ is None:
raise http_error(404, "round_not_found", "round not found")
rows = (
await session.execute(
select(RoundParticipant, User.address)
.join(User, User.id == RoundParticipant.user_id)
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
.order_by(RoundParticipant.draw_position)
)
).all()
participants = [
ProofParticipant(
draw_position=p.draw_position,
txid=p.bet_txid,
vout=p.bet_vout,
address=address,
amount_sats=p.bet_amount_sats,
status=p.status,
)
for p, address in rows
]
seed_decimal = round_.seed_int
winner_index = None
winner_address = None
if seed_decimal is not None and participants:
winner_index = int(seed_decimal) % len(participants)
winner_address = participants[winner_index].address
return RoundProofResponse(
round_id=round_.id,
status=round_.status,
algorithm="commit-reveal-v1" if round_.server_seed_hash else "block-hash-v0",
pool_address=round_.pool_address or None,
server_seed_hash=round_.server_seed_hash,
server_seed=round_.server_seed,
draw_height=round_.draw_height,
draw_confirmations=round_.draw_confirmations,
participants=participants,
participants_hash=round_.participants_hash,
draw_block_hash=round_.draw_block_hash,
seed=seed_decimal,
winner_index=winner_index,
winner_address=winner_address,
payout_txid=round_.payout_txid,
)