The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
333 lines
14 KiB
Python
333 lines
14 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=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,
|
|
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,
|
|
)
|