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:
+12
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user