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 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) 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: session.add(RoundConfig(fee_address="", 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"