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>
217 lines
11 KiB
Python
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)
|