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>
442 lines
19 KiB
Python
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"
|