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
+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",