fee_address has no column default, because an operator has to supply their own — and the payout pays the 30% commission to it, so build_payout_transaction cannot even be built without one. A fresh instance nonetheless opened rounds happily: each took bets, confirmed them, and only then discovered it was unpayable, wedging in "paying_out" and retrying every 60s with money already in the pool. One manual recovery per round, until somebody noticed. open_new_round_if_needed now checks rounds_can_open(config) alongside `paused`: no payout address, no round. Nothing has moved yet at that point, which is the whole difference. Same scope as pausing — a round already in progress still closes, draws and pays out, since clearing the address mid-round is exactly the operator slip that must not strand a live round. Surfaced rather than silent, in the two places that matter: lottery_configured on GET /rounds/current, which makes / show a *different* banner from the maintenance one (telling a player "come back later" would be false — nothing is coming until setup finishes), and a warning at the top of /admin's Parametri card, the one screen that can fix it. rounds_can_open is where any future would-make-a-round-unpayable prerequisite belongs, instead of being discovered at payout time. The test churn is the finding restated: 26 tests expected a round to open on an instance with no payout address. Their fixtures now seed one, so each goes back to testing what it says — several would otherwise have passed for the wrong reason, returning None because of the missing address rather than because of the cooldown or pause under test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
10 KiB
Python
227 lines
10 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.timeutil import isoformat_utc
|
|
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 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
|
|
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
|
|
chain_tip_height: int | None = None
|
|
lottery_paused: bool = False
|
|
# 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,
|
|
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,
|
|
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,
|
|
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,
|
|
chain_tip_height=chain_tip_height,
|
|
lottery_paused=config.paused,
|
|
lottery_configured=rounds_can_open(config),
|
|
user_played=user_played,
|
|
)
|