diff --git a/BUGS.md b/BUGS.md
index 0eb1b1d..b8a7da5 100644
--- a/BUGS.md
+++ b/BUGS.md
@@ -40,18 +40,6 @@ remains the last prerequisite for running unattended.
## Medium — correctness and robustness
-### B-66 — nothing stops rounds from opening with no `fee_address` configured
-
-`app/rounds/config.py:12-17`, `app/rounds/scheduler.py:330-335`.
-
-A fresh instance starts with `fee_address = ""`. Rounds open, bets are accepted and
-confirm, and only then does the payout refuse to build — leaving the round in
-`paying_out`, retrying every 60 s, with the audit log as the only signal.
-
-Fix: refuse to open a round while `fee_address` is unset (and surface it on
-`/admin` and as a maintenance-style banner), so the failure happens before anyone's
-money is committed.
-
### B-67 — `/qr/{address}` is unauthenticated, synchronous and only shape-validated
`app/api/routes/qr.py:11-21`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 91f8388..d8d1177 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -101,6 +101,8 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner.
+**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time.
+
## Code map
| Package | Contents |
diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py
index 60cb0fb..26409f7 100644
--- a/app/api/routes/rounds.py
+++ b/app/api/routes/rounds.py
@@ -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,
)
diff --git a/app/rounds/service.py b/app/rounds/service.py
index 45ed8e1..c9eaf03 100644
--- a/app/rounds/service.py
+++ b/app/rounds/service.py
@@ -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:
diff --git a/app/static/admin.html b/app/static/admin.html
index 729c486..58c0893 100644
--- a/app/static/admin.html
+++ b/app/static/admin.html
@@ -62,6 +62,13 @@
+
+
+ ⚠️ Nessun fee address configurato: finché resta vuoto non si aprirà nessun round
+ (il payout non sarebbe costruibile). Impostalo qui sotto e salva.
+
diff --git a/app/static/admin.js b/app/static/admin.js
index 68b52b5..81a3db3 100644
--- a/app/static/admin.js
+++ b/app/static/admin.js
@@ -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;
diff --git a/app/static/app.js b/app/static/app.js
index a92a47d..480f528 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -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,
diff --git a/app/static/i18n.js b/app/static/i18n.js
index 2be3fdc..a0e765c 100644
--- a/app/static/i18n.js
+++ b/app/static/i18n.js
@@ -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. 存款',
diff --git a/app/static/index.html b/app/static/index.html
index e50f415..294e6b5 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -83,7 +83,10 @@
⚠️
- 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.
+
+
diff --git a/docs/guida-admin.md b/docs/guida-admin.md
index 34d8875..22572a6 100644
--- a/docs/guida-admin.md
+++ b/docs/guida-admin.md
@@ -36,7 +36,7 @@ business — quelli si toccano solo da qui.
| Campo | Significato |
|---|---|
-| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: i payout **non partono** se questo campo è vuoto. |
+| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: finché è vuoto **non si apre nessun round** (il payout non sarebbe costruibile, quindi il round accetterebbe scommesse per poi restare bloccato con i soldi già nel montepremi). Il pannello lo segnala con un avviso in cima ai Parametri, e la pagina utente mostra un banner "lotteria non ancora pronta". Un round già in corso non viene interrotto se svuoti il campo: chiude, estrae e paga normalmente. |
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). |
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py
index f86a2d9..a6293a5 100644
--- a/tests/unit/test_balance.py
+++ b/tests/unit/test_balance.py
@@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import place_bet
from app.config import settings
from app.db.base import Base
-from app.db.models import PendingTransaction, User, UtxoEvent
+from app.db.models import PendingTransaction, RoundConfig, User, UtxoEvent
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_user_address
+_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
+
+
class FakeElectrumClient:
async def broadcast(self, raw_tx_hex: str) -> str:
return "fake-network-txid"
@@ -31,6 +34,13 @@ async def session_factory(tmp_path, monkeypatch):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+
+ # B-66: a round only opens on an instance that could actually pay a winner, so
+ # every test that expects one needs a fee address configured — the column has no
+ # default on purpose (an operator must set their own).
+ async with async_sessionmaker(engine, expire_on_commit=False)() as session:
+ session.add(RoundConfig(fee_address=_FEE_ADDRESS))
+ await session.commit()
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
diff --git a/tests/unit/test_bets.py b/tests/unit/test_bets.py
index 79ccdfd..afdfe90 100644
--- a/tests/unit/test_bets.py
+++ b/tests/unit/test_bets.py
@@ -14,6 +14,9 @@ from app.wallet.hd import derive_user_address
from app.wallet.psbt_builder import MAX_PARTICIPANTS_PER_ROUND, MAX_TX_INPUTS
+_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
+
+
class FakeElectrumClient:
def __init__(self):
self.broadcasted: list[str] = []
@@ -35,6 +38,13 @@ async def session_factory(tmp_path, monkeypatch):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+
+ # B-66: a round only opens on an instance that could actually pay a winner, so
+ # every test that expects one needs a fee address configured — the column has no
+ # default on purpose (an operator must set their own).
+ async with async_sessionmaker(engine, expire_on_commit=False)() as session:
+ session.add(RoundConfig(fee_address=_FEE_ADDRESS))
+ await session.commit()
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
@@ -217,7 +227,8 @@ async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_
client = FakeElectrumClient()
async with session_factory() as session:
- session.add(RoundConfig(fee_address="", round_duration_seconds=60))
+ config = (await session.scalars(select(RoundConfig))).one() # seeded by the fixture
+ config.round_duration_seconds = 60
round_ = await open_new_round_if_needed(session)
round_.opened_at = datetime.now(timezone.utc) - timedelta(seconds=61)
await session.commit()
diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py
index 1fd07a2..c47b0f4 100644
--- a/tests/unit/test_rounds_route.py
+++ b/tests/unit/test_rounds_route.py
@@ -192,6 +192,34 @@ async def test_no_pending_bets_reported_once_every_bet_has_confirmed(client): #
assert body["pending_jackpot_sats"] == body["jackpot_sats"]
+async def test_lottery_configured_flags_a_missing_fee_address(client): # B-66
+ """The frontend has to tell "the next round is coming" apart from "nothing is
+ coming until the operator finishes setting this up" — the banner says different
+ things, and only one of them is worth waiting for."""
+ from sqlalchemy import select
+
+ from app.db.models import RoundConfig
+
+ ac, session_factory = client
+
+ async with session_factory() as session:
+ session.add(RoundConfig(fee_address=""))
+ await session.commit()
+
+ body = (await ac.get("/rounds/current")).json()
+ assert body["lottery_configured"] is False
+ assert body["lottery_paused"] is False # not a pause: a prerequisite that isn't met
+ assert body["round_id"] is None # and indeed no round was opened
+
+ async with session_factory() as session:
+ (await session.scalars(select(RoundConfig))).one().fee_address = (
+ "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
+ )
+ await session.commit()
+
+ assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
+
+
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
needs draw_waiting_since to show "still waiting" instead of implying a bounded
diff --git a/tests/unit/test_rounds_service.py b/tests/unit/test_rounds_service.py
index 0aa86b9..dfcad3b 100644
--- a/tests/unit/test_rounds_service.py
+++ b/tests/unit/test_rounds_service.py
@@ -9,6 +9,7 @@ from app.db.models import Round, RoundConfig
from app.rounds.service import get_active_round, open_new_round_if_needed
ROUND_COOLDOWN_SECONDS = 30 # matches RoundConfig.round_cooldown_seconds' column default
+_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
@pytest.fixture
@@ -16,6 +17,14 @@ async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+
+ # B-66: no fee address means no round may open at all, which would make most of
+ # the assertions below pass for the wrong reason. Seeded once here so every test
+ # in this file runs against an instance that could actually pay a winner, and the
+ # ones that care about other config values edit this same single row.
+ async with async_sessionmaker(engine, expire_on_commit=False)() as session:
+ session.add(RoundConfig(fee_address=_FEE_ADDRESS))
+ await session.commit()
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
@@ -88,7 +97,7 @@ async def test_withholds_new_round_while_paused(session_factory):
stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1)
async with session_factory() as session:
session.add(Round(status="closed", closed_at=stale_close))
- session.add(RoundConfig(fee_address="", paused=True))
+ (await session.scalars(select(RoundConfig))).one().paused = True
await session.commit()
async with session_factory() as session:
@@ -99,7 +108,7 @@ async def test_withholds_new_round_while_paused(session_factory):
async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
async with session_factory() as session:
session.add(Round(status="drawing"))
- session.add(RoundConfig(fee_address="", paused=True))
+ (await session.scalars(select(RoundConfig))).one().paused = True
await session.commit()
async with session_factory() as session:
@@ -173,12 +182,73 @@ async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
assert len((await session.scalars(select(Round))).all()) == 3
+# --- B-66: no round opens on an instance that could not pay its winner ------------
+
+
+async def test_withholds_new_round_while_no_fee_address_is_configured(session_factory): # B-66
+ """A fresh instance starts with no fee_address, and the payout pays the 30%
+ commission to it — so a round opened without one takes bets, confirms them, and
+ only then discovers it cannot be paid, wedging in "paying_out" with money already
+ in the pool and needing manual recovery. Every round, until an operator notices."""
+ async with session_factory() as session:
+ (await session.scalars(select(RoundConfig))).one().fee_address = ""
+ await session.commit()
+
+ async with session_factory() as session:
+ assert await open_new_round_if_needed(session) is None
+
+ async with session_factory() as session:
+ assert (await session.scalars(select(Round))).all() == [] # nothing opened at all
+
+
+async def test_opens_a_round_as_soon_as_a_fee_address_is_set(session_factory): # B-66
+ async with session_factory() as session:
+ (await session.scalars(select(RoundConfig))).one().fee_address = ""
+ await session.commit()
+
+ async with session_factory() as session:
+ assert await open_new_round_if_needed(session) is None
+
+ async with session_factory() as session:
+ (await session.scalars(select(RoundConfig))).one().fee_address = _FEE_ADDRESS
+ await session.commit()
+
+ async with session_factory() as session:
+ round_ = await open_new_round_if_needed(session)
+ await session.commit()
+ assert round_ is not None and round_.status == "open"
+
+
+async def test_a_round_in_progress_survives_the_fee_address_being_cleared(session_factory): # B-66
+ """Same rule as pausing: an unmet prerequisite only stops the *next* round. The
+ one in progress keeps its participants and still has to be drawn and paid — and
+ clearing the address is exactly the mistake an operator might make mid-round."""
+ async with session_factory() as session:
+ session.add(Round(status="open"))
+ (await session.scalars(select(RoundConfig))).one().fee_address = ""
+ await session.commit()
+
+ async with session_factory() as session:
+ returned = await open_new_round_if_needed(session)
+ assert returned is not None and returned.status == "open"
+
+
+def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66
+ from app.rounds.service import rounds_can_open
+
+ assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True
+ assert rounds_can_open(RoundConfig(fee_address="")) is False
+ assert rounds_can_open(RoundConfig(fee_address=" ")) is False
+
+
# --- B-61: a round runs by the timing it opened with, not by the live config ------
async def test_a_new_round_snapshots_the_current_config_timing(session_factory):
async with session_factory() as session:
- session.add(RoundConfig(fee_address="", round_duration_seconds=120, round_cooldown_seconds=45))
+ config = (await session.scalars(select(RoundConfig))).one()
+ config.round_duration_seconds = 120
+ config.round_cooldown_seconds = 45
await session.commit()
async with session_factory() as session:
@@ -205,7 +275,7 @@ async def test_cooldown_comes_from_the_round_that_closed(session_factory):
round_cooldown_seconds afterwards must not open the next round early, nor
lengthening it hold the lottery shut."""
async with session_factory() as session:
- session.add(RoundConfig(fee_address="", round_cooldown_seconds=0)) # just lowered to 0
+ (await session.scalars(select(RoundConfig))).one().round_cooldown_seconds = 0 # just lowered
session.add(
Round(
status="closed",
diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py
index 7bc435e..6dd59b1 100644
--- a/tests/unit/test_withdrawals.py
+++ b/tests/unit/test_withdrawals.py
@@ -5,12 +5,15 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import place_bet
from app.config import settings
from app.db.base import Base
-from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
+from app.db.models import PendingTransaction, RoundConfig, User, UtxoEvent, Withdrawal
from app.rounds.events import broadcaster
from app.wallet.hd import derive_user_address
from app.withdrawals.service import WithdrawalError, request_withdrawal
+_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
+
+
class FakeElectrumClient:
def __init__(self):
self.broadcasted: list[str] = []
@@ -40,6 +43,13 @@ async def session_factory(tmp_path, monkeypatch):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+
+ # B-66: a round only opens on an instance that could actually pay a winner, so
+ # every test that expects one needs a fee address configured — the column has no
+ # default on purpose (an operator must set their own).
+ async with async_sessionmaker(engine, expire_on_commit=False)() as session:
+ session.add(RoundConfig(fee_address=_FEE_ADDRESS))
+ await session.commit()
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None