Files
plm-lottery/app/rounds/service.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

217 lines
11 KiB
Python

import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Round, RoundConfig
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.rounds.draw import new_server_seed, server_seed_commitment
from app.wallet.hd import derive_pool_address
from app.wallet.keystore import encrypt_round_seed
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_TX_INPUTS,
estimate_vsize,
payout_bump_reserve,
)
logger = logging.getLogger(__name__)
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
# Bounded: a conflict means someone else is opening a round right now, so a couple
# of retries is plenty. Unbounded retries could spin if the invariant were ever
# broken in a way we don't anticipate.
_OPEN_ROUND_ATTEMPTS = 3
# 70% winner / 30% fees. Hardcoded by design (see CLAUDE.md) — changing the split
# is a code change, not an admin-editable setting. Single source of truth so the
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
def _new_round_secret() -> dict:
"""B-73 (RND-34): a fresh secret for a round about to open, as the columns that hold it —
its published commitment and its encrypted form, never the plaintext."""
server_seed = new_server_seed()
return {
"server_seed_hash": server_seed_commitment(server_seed),
"server_seed_enc": encrypt_round_seed(server_seed),
}
def winner_share(pool_amount_sats: int) -> int:
return pool_amount_sats * 70 // 100
async def get_active_round(session: AsyncSession) -> Round | None:
"""The round currently in progress (in any non-closed state), if any. Rounds
never overlap: a new round only opens once the previous one is fully closed
(payout confirmed, or no participants to pay out).
The database enforces "at most one active round" (ix_rounds_single_active, see
app/db/models.py), so the ordering below is belt-and-braces; if it ever does
see two, that's a broken invariant and worth a loud log rather than silently
picking one."""
active = (
await session.scalars(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
).all()
if len(active) > 1:
logger.error(
"invariant violated: %s rounds are active at once (ids=%s) — using the newest",
len(active),
[r.id for r in active],
)
return active[0] if active else None
def round_deadline(round_: Round) -> datetime:
"""When this round stops accepting bets. B-61: from the round's own snapshotted
duration, not from the live config — an operator editing round_duration_seconds
mid-round must not move a deadline clients are already counting down to, nor
close an in-progress round on the spot."""
return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds)
def round_accepts_bets(round_: Round) -> bool:
"""The authoritative "yellow light" check: once a round's timer has expired,
no new bet may be accepted, even though its DB status is still "open" (the
scheduler only flips it to "closing" on its next tick, up to
_TICK_INTERVAL_SECONDS later — see rounds/scheduler.py). Bets already placed
before the deadline are unaffected: the round still waits for them to confirm
before actually closing."""
if round_.status != "open":
return False
return datetime.now(timezone.utc) < round_deadline(round_)
def payout_config_problem(bet_amount_sats: int, fee_rate_sat_vb: int) -> str | None:
"""Why a round could not be paid out under this bet amount and fee rate, or None (B-79,
RND-40). Checked for the two extremes — 1 participant (the smallest platform share) and
MAX_PARTICIPANTS_PER_ROUND (the largest bump reserve, which grows with the inputs):
the winner's 70% must cover the network fee of the payout and still be at least dust, and
the platform's 30% must cover the bump reserve and still leave the fee address at least
dust. The smallest bet that can reach the pool is the amount minus the fee of the biggest
transaction a user may build (MAX_TX_INPUTS inputs, 2 outputs), so that is what every
participant is assumed to have contributed."""
net_bet = bet_amount_sats - estimate_vsize(MAX_TX_INPUTS, 2) * fee_rate_sat_vb
for participants in (1, MAX_PARTICIPANTS_PER_ROUND):
pool = participants * net_bet
share = winner_share(pool)
if share - estimate_vsize(participants, 3) * fee_rate_sat_vb < DUST_LIMIT_SATS:
return "winner_share_below_network_fee"
if pool - share - payout_bump_reserve(participants) < DUST_LIMIT_SATS:
return "commission_below_dust_limit"
return None
def rounds_can_open(config: RoundConfig) -> bool:
"""Whether the instance is configured well enough to run a round at all (B-66, B-79).
Two prerequisites, both because a round without them is unpayable: a fee_address — the
payout pays the platform's share to it, so build_payout_transaction cannot even be built,
and it has no column default (rounds/config.py) because an operator must set their own —
and a bet amount and fee rate under which a round of 1 or of
MAX_PARTICIPANTS_PER_ROUND participants can cover the payout's fee, the bump reserve and
dust outputs (payout_config_problem, RND-40). PUT /admin/config refuses the same
combinations, so this only trips on a configuration that predates the check.
Anything else that would make a round unpayable belongs here too, next to it,
rather than being discovered at payout time. Deliberately not about *pausing*,
which is a decision an operator took (RoundConfig.paused) rather than a
prerequisite they haven't met yet."""
if not config.fee_address.strip():
return False
return payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb) is None
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
"""Returns the active round if one exists (whatever its status). Otherwise
opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
hasn't elapsed yet, the lottery is paused for maintenance, or the instance isn't
configured well enough to pay a winner — in any of those cases returns None.
Callers that need to attach a bet must additionally check the returned round's
status == "open" — a round in closing/drawing/paying_out isn't accepting new bets,
but a new round can't open until it's done.
Pausing never touches a round already in progress: it only suppresses opening
the *next* one, so the current round still closes, draws, and pays out the
winner normally (see admin.py's /admin/pause and /admin/resume)."""
active = await get_active_round(session)
if active is not None:
return active
config = await get_round_config(session)
if config.paused:
return None
if not rounds_can_open(config):
# B-66: a fresh instance starts with no fee_address, and a round opened
# without one takes bets, confirms them, and only then discovers that the
# payout cannot be built — leaving the round wedged in "paying_out",
# retrying every 60s, with money already in the pool. Every round would
# need its own manual recovery. Refusing to open costs nothing by
# comparison: no money has moved yet, and it is the operator's own missing
# setup, surfaced through GET /rounds/current's lottery_configured and the
# admin panel rather than discovered a round too late.
return None
last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc()))
if last_closed is not None and last_closed.closed_at is not None:
closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc)
# B-61: the cooldown the closing round announced is the one honoured, so
# editing the config never retroactively shortens or extends a gap already
# under way. The new value applies from the next round on.
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds):
return None
for attempt in range(_OPEN_ROUND_ATTEMPTS):
# B-61: the timing this round will run by, fixed at open time.
round_ = Round(
status="open",
duration_seconds=config.round_duration_seconds,
cooldown_seconds=config.round_cooldown_seconds,
draw_confirmations=config.draw_confirmations, # B-73 (DAT-61)
# B-95 (DAT-61, RND-05): everything else that governs the round, fixed now — a later
# edit from /admin applies to the next round, never to this one.
bet_amount_sats=config.bet_amount_sats,
fee_address=config.fee_address.strip(),
max_round_pool_sats=config.max_round_pool_sats,
# B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the
# plaintext column stays NULL until the reveal.
**_new_round_secret(),
)
session.add(round_)
try:
await session.flush()
except IntegrityError:
# Another caller (the scheduler tick, or a concurrent place_bet) got
# there first — ix_rounds_single_active turns what used to be two live
# rounds into a clean failure here. Roll our insert back and use theirs.
# Safe to roll back: this runs before its callers have written anything
# else in this session.
await session.rollback()
existing = await get_active_round(session)
if existing is not None:
logger.info("lost the race to open a round; using round %s", existing.id)
return existing
# Nothing active *and* the insert conflicted: the winner's transaction
# hadn't committed yet when we looked. Try again rather than failing the
# caller — a bet shouldn't 500 because of a scheduler tick's timing.
logger.info("round-open conflict with nothing active yet (attempt %s), retrying", attempt + 1)
continue
# Published pre-commit (the caller commits right after) — acceptable: this
# only tells subscribers "go refetch", and by the time an SSE client's
# refetch request actually lands, this in-process commit (microseconds
# away) has essentially always already happened.
# B-80 (RND-35, DAT-60): the round's own pool address, now that its id exists —
# still inside the caller's transaction, so no committed round ever lacks one.
round_.pool_address = derive_pool_address(round_.id)
broadcaster.publish()
return round_
logger.error("could not open a round after %s attempts", _OPEN_ROUND_ATTEMPTS)
return await get_active_round(session)