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:
@@ -15,7 +15,7 @@ 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, winner_share
|
||||
from app.rounds.service import get_active_round, round_deadline, rounds_can_open, winner_share
|
||||
|
||||
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
||||
|
||||
@@ -117,6 +117,12 @@ class CurrentRoundResponse(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
@@ -138,6 +144,7 @@ async def current_round(
|
||||
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
|
||||
@@ -214,5 +221,6 @@ async def current_round(
|
||||
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,
|
||||
)
|
||||
|
||||
+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:
|
||||
|
||||
@@ -62,6 +62,13 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- B-66: no fee_address means no round can open at all (the payout pays the
|
||||
30% commission to it, so it cannot even be built). Shown here because
|
||||
this is the one screen that can fix it. -->
|
||||
<div class="warning-banner hidden" id="admin-fee-address-warning">
|
||||
⚠️ Nessun fee address configurato: finché resta vuoto <strong>non si aprirà nessun round</strong>
|
||||
(il payout non sarebbe costruibile). Impostalo qui sotto e salva.
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
|
||||
|
||||
@@ -141,6 +141,11 @@ async function adminLoadConfig() {
|
||||
try {
|
||||
const data = await callAdmin('GET', '/admin/config');
|
||||
document.getElementById('admin-fee-address').value = data.fee_address;
|
||||
// B-66: an empty fee address blocks every future round, so say so here rather
|
||||
// than leaving an empty field to be noticed.
|
||||
document
|
||||
.getElementById('admin-fee-address-warning')
|
||||
.classList.toggle('hidden', !!(data.fee_address || '').trim());
|
||||
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
|
||||
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
|
||||
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
||||
|
||||
+12
-1
@@ -235,7 +235,18 @@ function renderChainStatusBar() {
|
||||
label.textContent = t(CHAIN_STATUS_KEYS[labelKey]);
|
||||
block.textContent = t('chain.block', { n: data.chain_tip_height != null ? '#' + data.chain_tip_height : '—' });
|
||||
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !data.lottery_paused);
|
||||
// Two separate reasons no round will open, and telling them apart matters to the
|
||||
// reader: a pause ends when the operator resumes, while an unconfigured instance
|
||||
// (B-66) won't produce a round at all until it's set up — "come back later" would
|
||||
// be a lie. `=== false` so an older server that doesn't send the field at all
|
||||
// can't flash the banner. A pause takes precedence: it's the deliberate action.
|
||||
const notConfigured = data.lottery_configured === false;
|
||||
const noRoundsComing = !!data.lottery_paused || notConfigured;
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !noRoundsComing);
|
||||
if (noRoundsComing) {
|
||||
document.getElementById('maintenance-banner-text').textContent =
|
||||
data.lottery_paused ? t('maintenance.banner') : t('maintenance.notConfigured');
|
||||
}
|
||||
}
|
||||
|
||||
// After a couple of consecutive failed polls (network blip, server restart,
|
||||
|
||||
@@ -41,6 +41,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Block {n}',
|
||||
'chain.connectionLost': 'Connection to server lost — retrying…',
|
||||
'maintenance.banner': 'Scheduled maintenance: the current round completes normally (winner included), but the next round will not open until maintenance ends.',
|
||||
'maintenance.notConfigured': 'This lottery is not ready to play yet: the operator still has to finish setting it up, and no round will open until then.',
|
||||
|
||||
'hero.lead': 'Deposit PLM, join the round with a fixed entry fee, and if your number is drawn you win the jackpot.',
|
||||
'hero.step1.title': '1. Deposit',
|
||||
@@ -208,6 +209,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Blocco {n}',
|
||||
'chain.connectionLost': 'Connessione al server persa — riprovo…',
|
||||
'maintenance.banner': 'Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.',
|
||||
'maintenance.notConfigured': 'Questa lotteria non è ancora pronta: l\'operatore deve completare la configurazione, e fino a quel momento non si aprirà nessun round.',
|
||||
|
||||
'hero.lead': 'Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.',
|
||||
'hero.step1.title': '1. Deposita',
|
||||
@@ -372,6 +374,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Bloque {n}',
|
||||
'chain.connectionLost': 'Conexión con el servidor perdida — reintentando…',
|
||||
'maintenance.banner': 'Mantenimiento programado: la ronda actual se completa con normalidad (ganador incluido), pero la siguiente ronda no se abrirá hasta que finalice el mantenimiento.',
|
||||
'maintenance.notConfigured': 'Esta lotería todavía no está lista: el operador tiene que terminar de configurarla y, hasta entonces, no se abrirá ninguna ronda.',
|
||||
|
||||
'hero.lead': 'Deposita PLM, únete a la ronda con una cuota fija de entrada, y si sale tu número ganas el bote.',
|
||||
'hero.step1.title': '1. Deposita',
|
||||
@@ -536,6 +539,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Bloc {n}',
|
||||
'chain.connectionLost': 'Connexion au serveur perdue — nouvelle tentative…',
|
||||
'maintenance.banner': "Maintenance programmée : le round en cours se termine normalement (gagnant inclus), mais le round suivant ne s'ouvrira qu'une fois la maintenance terminée.",
|
||||
'maintenance.notConfigured': "Cette loterie n'est pas encore prête : l'opérateur doit terminer la configuration, et aucun round ne s'ouvrira avant.",
|
||||
|
||||
'hero.lead': 'Déposez des PLM, rejoignez le round avec une mise fixe, et si votre numéro est tiré vous remportez le jackpot.',
|
||||
'hero.step1.title': '1. Déposez',
|
||||
@@ -700,6 +704,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Block {n}',
|
||||
'chain.connectionLost': 'Verbindung zum Server verloren — erneuter Versuch…',
|
||||
'maintenance.banner': 'Geplante Wartung: Die laufende Runde wird regulär abgeschlossen (Gewinner inklusive), aber die nächste Runde öffnet erst, wenn die Wartung beendet ist.',
|
||||
'maintenance.notConfigured': 'Diese Lotterie ist noch nicht spielbereit: der Betreiber muss die Einrichtung abschließen, bis dahin öffnet keine Runde.',
|
||||
|
||||
'hero.lead': 'Zahle PLM ein, nimm mit einem festen Einsatz an der Runde teil, und wenn deine Zahl gezogen wird, gewinnst du den Jackpot.',
|
||||
'hero.step1.title': '1. Einzahlen',
|
||||
@@ -864,6 +869,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Блок {n}',
|
||||
'chain.connectionLost': 'Соединение с сервером потеряно — повторная попытка…',
|
||||
'maintenance.banner': 'Запланировано техобслуживание: текущий раунд завершится в обычном порядке (включая победителя), но следующий раунд не откроется до окончания техобслуживания.',
|
||||
'maintenance.notConfigured': 'Эта лотерея пока не готова: оператору нужно завершить настройку, до этого ни один раунд не откроется.',
|
||||
|
||||
'hero.lead': 'Внесите PLM, вступите в раунд с фиксированной ставкой, и если выпадет ваш номер — вы выиграете джекпот.',
|
||||
'hero.step1.title': '1. Внесите депозит',
|
||||
@@ -1028,6 +1034,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': '区块 {n}',
|
||||
'chain.connectionLost': '与服务器的连接已断开——正在重试…',
|
||||
'maintenance.banner': '计划维护:当前回合将照常完成(包括中奖者),但下一回合要等维护结束后才会开启。',
|
||||
'maintenance.notConfigured': '本彩票尚未就绪:运营方还需完成配置,在此之前不会开启任何回合。',
|
||||
|
||||
'hero.lead': '存入 PLM,以固定金额参与本回合,若抽中你的号码即可赢得奖池。',
|
||||
'hero.step1.title': '1. 存款',
|
||||
|
||||
@@ -83,7 +83,10 @@
|
||||
|
||||
<div class="maintenance-banner hidden" id="maintenance-banner">
|
||||
<span>⚠️</span>
|
||||
<span data-i18n="maintenance.banner">Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.</span>
|
||||
<!-- Filled by renderChainStatusBar(): the text depends on *why* no round will
|
||||
open — a deliberate pause, or an instance the operator hasn't finished
|
||||
configuring (B-66) — so it renders through t() and carries no data-i18n. -->
|
||||
<span id="maintenance-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<section id="landing-hero" class="hero">
|
||||
|
||||
Reference in New Issue
Block a user