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:
2026-08-04 14:13:00 +02:00
co-authored by Claude Opus 5
parent c4b2dc3ea2
commit 23d58796b6
15 changed files with 214 additions and 28 deletions
+7
View File
@@ -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>
+5
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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. 存款',
+4 -1
View File
@@ -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">