Files
plm-lottery/tests/unit/test_bets.py
T
davideandClaude Opus 5 23d58796b6 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>
2026-08-04 14:13:00 +02:00

442 lines
19 KiB
Python

from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import BetError, place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed
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] = []
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
return "fake-network-txid"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(settings, "xprv_encryption_key", __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode())
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
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
async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int:
async with session_factory() as session:
address = derive_user_address(index)
user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address)
session.add(user)
await session.commit()
session.add(
UtxoEvent(
user_id=user.id,
txid=f"{index:02x}" * 32,
vout=0,
amount_sats=funded_sats,
confirmed_height=100,
)
)
await session.commit()
return user.id
async def test_place_bet_broadcasts_and_records_participant(session_factory):
user_id = await _make_funded_user(session_factory, 0, 1_500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert client.broadcasted # a raw tx was broadcast
assert participant.status == "broadcast"
assert participant.bet_txid
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid == participant.bet_txid
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.kind == "bet"
audit_events = (await session.scalars(select(AuditLog))).all()
assert any(e.event_type == "bet_placed" for e in audit_events)
assert pending.current_txid == participant.bet_txid
async def test_place_bet_rejects_insufficient_balance(session_factory):
user_id = await _make_funded_user(session_factory, 1, 1_000_000) # below bet_amount_sats
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="insufficient balance"):
await place_bet(session, client, user)
async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_factory): # B-48
# 100 x 0.15 PLM = 15 PLM, plenty for a 10 PLM bet, but the 50 largest inputs
# only add up to 7.5 PLM — so the build must fail with its own code, not with
# the "you have no funds" one, and must carry the cap for the translation.
user_id = await _make_funded_user(session_factory, 20, 15_000_000)
async with session_factory() as session:
for i in range(99):
session.add(
UtxoEvent(
user_id=user_id,
txid=f"{i:064x}",
vout=0,
amount_sats=15_000_000,
confirmed_height=100,
)
)
await session.commit()
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
assert not client.broadcasted
async def _fill_round_with_participants(session_factory, round_id: int, count: int) -> None:
"""Participant rows only, no real bets: what the cap counts is rows, and building
`count` genuine transactions would just make the test slow without exercising
anything the other tests don't already cover."""
async with session_factory() as session:
for i in range(count):
session.add(
RoundParticipant(
round_id=round_id,
user_id=10_000 + i, # placeholder ids; the cap check never joins users
bet_amount_sats=1_000_000_000,
bet_txid=f"{i:064x}",
status="confirmed",
)
)
await session.commit()
async def test_place_bet_rejects_the_bet_past_the_participant_cap(session_factory): # B-52
"""The payout has to spend one pool UTXO per bet, so a round is only ever allowed
to grow to what a single payout transaction can drain. Enforced here, before the
player's money moves — not discovered at payout time, when the bets are already in
the pool and the round can no longer be paid at all."""
user_id = await _make_funded_user(session_factory, 30, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "round_full"
assert excinfo.value.params == {"max_participants": MAX_PARTICIPANTS_PER_ROUND}
assert not client.broadcasted
# Refused cleanly: no participant row, and the user's UTXO is still spendable.
async with session_factory() as session:
assert await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_id)
) == MAX_PARTICIPANTS_PER_ROUND
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid is None
async def test_place_bet_still_accepts_the_last_slot_under_the_cap(session_factory): # B-52
user_id = await _make_funded_user(session_factory, 31, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
await _fill_round_with_participants(session_factory, round_id, MAX_PARTICIPANTS_PER_ROUND - 1)
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert participant.status == "broadcast"
assert client.broadcasted
async def test_place_bet_rejects_second_bet_same_round(session_factory):
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, client, user)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="already"):
await place_bet(session, client, user)
async with session_factory() as session:
participants = (await session.scalars(select(RoundParticipant))).all()
assert len(participants) == 1
async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_factory):
"""The scheduler only flips status "open" -> "closing" on its next tick (up
to a few seconds late) — place_bet must independently refuse bets once the
round's own deadline has passed, so no new player can sneak in during that
gap (see rounds/service.round_accepts_bets)."""
user_id = await _make_funded_user(session_factory, 3, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
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()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="closing"):
await place_bet(session, client, user)
async with session_factory() as session:
participants = (await session.scalars(select(RoundParticipant))).all()
assert len(participants) == 0
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "open" # scheduler hasn't ticked — status is unchanged, only the check is deadline-aware
class RejectingElectrumClient:
"""A node that refuses the transaction — fee too low, dust output, mempool
conflict, or simply an unreachable server."""
async def broadcast(self, raw_tx_hex: str) -> str:
raise RuntimeError("min relay fee not met")
async def test_failed_broadcast_leaves_nothing_behind(session_factory):
"""B-07/B-08: the broadcast used to happen before anything was written, so a
rejection left the UTXOs marked spent with no rows to explain it, and the caller
got an opaque HTTP 500. Now it's a translatable error and a full rollback."""
user_id = await _make_funded_user(session_factory, 4, 3_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="refused"):
await place_bet(session, RejectingElectrumClient(), user)
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid is None # released, so the user can bet again
assert (await session.scalars(select(RoundParticipant))).all() == []
assert (await session.scalars(select(PendingTransaction))).all() == []
user = await session.get(User, user_id)
assert user.cached_balance_sats == 3_000_000_000
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "bet_broadcast_failed" in events
assert "bet_placed" not in events
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
"""The rollback moves as much state as the successful path does, so it must ping
the dashboards the same way — otherwise the phantom bet stays on screen until the
next poll."""
user_id = await _make_funded_user(session_factory, 21, 3_000_000_000)
async with session_factory() as session:
# Open the round up front: place_bet would otherwise open it itself, and that
# publish() would satisfy the assertion below whether or not the rollback ever
# published one of its own.
await open_new_round_if_needed(session)
await session.commit()
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="refused"):
await place_bet(session, RejectingElectrumClient(), user)
assert not queue.empty()
finally:
broadcaster.unsubscribe(queue)
async def test_failed_broadcast_reports_the_broadcast_failed_code(session_factory):
user_id = await _make_funded_user(session_factory, 5, 3_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
try:
await place_bet(session, RejectingElectrumClient(), user)
assert False, "expected BetError"
except BetError as exc:
assert exc.code == "broadcast_failed"
async def test_bet_is_persisted_before_it_is_broadcast(session_factory):
"""The ordering guarantee behind B-08: by the time the network call happens, the
rows already exist, so a crash there is recoverable rather than silent."""
user_id = await _make_funded_user(session_factory, 6, 3_000_000_000)
seen: dict[str, object] = {}
class ObservingClient:
async def broadcast(self, raw_tx_hex: str) -> str:
# Read committed state from an independent session, mid-broadcast.
async with session_factory() as probe:
seen["pending"] = [
(p.kind, p.status) for p in (await probe.scalars(select(PendingTransaction))).all()
]
seen["participants"] = [
(p.status) for p in (await probe.scalars(select(RoundParticipant))).all()
]
return "network-txid"
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, ObservingClient(), user)
assert seen["pending"] == [("bet", "building")]
assert seen["participants"] == ["building"]
# --- B-53: a bet must never pay into the pool of a round it was left out of ------
async def _assert_bet_left_no_trace(session_factory, user_id: int, balance_before: int) -> None:
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid is None # nothing reserved, so the user can bet next round
assert (await session.scalars(select(RoundParticipant))).all() == []
assert (await session.scalars(select(PendingTransaction))).all() == []
user = await session.get(User, user_id)
assert user.cached_balance_sats == balance_before # the rollback undid the recompute too
async def test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit(
session_factory, monkeypatch
): # B-53
"""The scheduler flips "open" -> "closing" in a transaction of its own and only
then counts in-flight bets. A bet whose deadline check passed just before that
flip must not be able to commit its participant row afterwards: it would be
excluded from the draw (only "confirmed" participants are drawn) while its sats
still landed in the pool address — credited to no round, with no refund path.
round_accepts_bets is forced to pass so the refusal can only come from the
compare-and-set on the round row, which is the part that survives the race the
wall-clock check cannot see."""
user_id = await _make_funded_user(session_factory, 40, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
monkeypatch.setattr("app.bets.service.round_accepts_bets", lambda *args, **kwargs: True)
async with session_factory() as session:
# What the scheduler's own tick would have committed a moment earlier.
(await session.get(Round, round_id)).status = "closing"
await session.commit()
async with session_factory() as session:
user = await session.get(User, user_id)
balance_before = user.cached_balance_sats
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "round_closing"
assert not client.broadcasted # refused before any money moved
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
async def test_place_bet_rechecks_the_deadline_after_building_the_transaction(
session_factory, monkeypatch
): # B-53
"""The first deadline check happens before the UTXO scan and the signing, so a
slow build could carry a bet past the round's deadline. It is re-checked against
the clock as it is at commit time."""
user_id = await _make_funded_user(session_factory, 41, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
await open_new_round_if_needed(session)
await session.commit()
checks: list[bool] = []
def _accepts_then_expires(*args, **kwargs) -> bool:
checks.append(True)
return len(checks) == 1 # open when the bet arrived, expired by the time it was built
monkeypatch.setattr("app.bets.service.round_accepts_bets", _accepts_then_expires)
async with session_factory() as session:
user = await session.get(User, user_id)
balance_before = user.cached_balance_sats
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert len(checks) == 2 # the re-check really ran
assert excinfo.value.code == "round_closing"
assert not client.broadcasted
await _assert_bet_left_no_trace(session_factory, user_id, balance_before)
async def test_place_bet_still_succeeds_while_the_round_is_open(session_factory): # B-53
"""The guard must not refuse the normal path: an open, in-time round still takes
bets, and the round's status is left untouched by the compare-and-set."""
user_id = await _make_funded_user(session_factory, 42, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert participant.status == "broadcast"
async with session_factory() as session:
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "open"