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
+11 -1
View File
@@ -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
+12 -1
View File
@@ -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()
+28
View File
@@ -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
+74 -4
View File
@@ -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",
+11 -1
View File
@@ -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