Refuse to open a round that could not pay its winner (B-66)
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>
This commit is contained in:
+31
-5
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Round
|
||||
from app.db.models import Round, RoundConfig
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
|
||||
@@ -68,13 +68,29 @@ def round_accepts_bets(round_: Round) -> bool:
|
||||
return datetime.now(timezone.utc) < round_deadline(round_)
|
||||
|
||||
|
||||
def rounds_can_open(config: RoundConfig) -> bool:
|
||||
"""Whether the instance is configured well enough to run a round at all (B-66).
|
||||
|
||||
Only fee_address today, and only because a round without one is unpayable: the
|
||||
payout pays the 30% commission to it, so build_payout_transaction cannot even be
|
||||
built. It has no column default for exactly this reason (rounds/config.py) — an
|
||||
operator must set their own, and until they do there is nothing to guess.
|
||||
|
||||
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."""
|
||||
return bool(config.fee_address.strip())
|
||||
|
||||
|
||||
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, or the lottery is paused for maintenance — in either case
|
||||
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.
|
||||
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
|
||||
@@ -86,6 +102,16 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user