from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base 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 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() async def test_opens_a_round_when_none_exists(session_factory): async with session_factory() as session: round_ = await open_new_round_if_needed(session) await session.commit() assert round_.status == "open" async def test_reuses_existing_open_round(session_factory): async with session_factory() as session: first = await open_new_round_if_needed(session) await session.commit() first_id = first.id async with session_factory() as session: second = await open_new_round_if_needed(session) assert second.id == first_id @pytest.mark.parametrize("status", ["closing", "drawing", "paying_out"]) async def test_does_not_open_new_round_while_previous_is_in_progress(session_factory, status): async with session_factory() as session: session.add(Round(status=status)) await session.commit() async with session_factory() as session: active = await get_active_round(session) assert active is not None assert active.status == status # open_new_round_if_needed must return the in-progress round, not open a new one returned = await open_new_round_if_needed(session) assert returned.status == status async def test_opens_new_round_after_previous_is_closed(session_factory): async with session_factory() as session: session.add(Round(status="closed")) await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_.status == "open" async def test_withholds_new_round_during_cooldown(session_factory): async with session_factory() as session: session.add(Round(status="closed", closed_at=datetime.now(timezone.utc))) await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_ is None async def test_opens_new_round_once_cooldown_elapses(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)) await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_.status == "open" 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)) (await session.scalars(select(RoundConfig))).one().paused = True await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_ is None async def test_pause_does_not_interrupt_a_round_in_progress(session_factory): async with session_factory() as session: session.add(Round(status="drawing")) (await session.scalars(select(RoundConfig))).one().paused = True await session.commit() async with session_factory() as session: returned = await open_new_round_if_needed(session) assert returned is not None assert returned.status == "drawing" async def test_losing_the_open_race_reuses_the_winning_round(session_factory, monkeypatch): """B-09: open_new_round_if_needed was a read-then-insert with no lock, called from both the scheduler and every place_bet, so two callers could both see "no active round" and insert one — and a second stuck "open" row blocks every future round, since get_active_round matches on status. The race is forced deterministically: the round already exists and is committed, but this caller's first look is made to miss it (exactly what the loser of the race sees). The insert then hits ix_rounds_single_active, and the caller must recover by using the winner's round instead of raising at its caller — a bet must not fail because a scheduler tick beat it by a millisecond. """ from app.rounds import service as service_module async with session_factory() as session: session.add(Round(status="open")) await session.commit() real_get_active_round = service_module.get_active_round calls = {"n": 0} async def blind_first_look(session): calls["n"] += 1 if calls["n"] == 1: return None # what the loser of the race sees return await real_get_active_round(session) monkeypatch.setattr(service_module, "get_active_round", blind_first_look) async with session_factory() as session: round_ = await service_module.open_new_round_if_needed(session) await session.commit() assert round_ is not None # recovered, didn't raise async with session_factory() as session: rounds = (await session.scalars(select(Round))).all() assert len(rounds) == 1, f"expected one round, got {[(r.id, r.status) for r in rounds]}" assert round_.id == rounds[0].id # the winner's round, not a second one async def test_the_database_refuses_a_second_active_round(session_factory): """The guarantee itself, independent of the application code path.""" from sqlalchemy.exc import IntegrityError async with session_factory() as session: session.add(Round(status="open")) await session.commit() async with session_factory() as session: session.add(Round(status="drawing")) with pytest.raises(IntegrityError): await session.commit() async def test_closed_rounds_can_coexist_with_an_active_one(session_factory): async with session_factory() as session: session.add(Round(status="closed")) session.add(Round(status="closed")) session.add(Round(status="open")) await session.commit() async with session_factory() as session: 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 # B-79: the payability check also reads the amounts, which are column defaults only once # a row is flushed — so a bare object spells them out. amounts = {"bet_amount_sats": 1_000_000_000, "fee_rate_sat_vb": 1} assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS, **amounts)) is True assert rounds_can_open(RoundConfig(fee_address="", **amounts)) is False assert rounds_can_open(RoundConfig(fee_address=" ", **amounts)) is False # --- B-79 (RND-40): a configuration under which a round cannot be paid opens no round --- def test_payout_config_problem_at_both_extremes(): """The default config is payable; the boundary bet amounts are found by the same formula as the spec: net = bet - fee(50 inputs, 2 outputs) at the rate, checked for 1 participant and for MAX_PARTICIPANTS_PER_ROUND.""" from app.rounds.service import payout_config_problem, winner_share from app.wallet.psbt_builder import ( DUST_LIMIT_SATS, MAX_PARTICIPANTS_PER_ROUND, MAX_TX_INPUTS, estimate_vsize, payout_bump_reserve, ) assert payout_config_problem(1_000_000_000, 1) is None def feasible(bet: int, rate: int, n: int) -> bool: pool = n * (bet - estimate_vsize(MAX_TX_INPUTS, 2) * rate) share = winner_share(pool) return ( share - estimate_vsize(n, 3) * rate >= DUST_LIMIT_SATS and pool - share - payout_bump_reserve(n) >= DUST_LIMIT_SATS ) # binary-search the smallest viable bet at 1 sat/vB for each extreme, independently of # the function under test, then check it is exactly where payout_config_problem flips def smallest(n: int, rate: int) -> int: lo, hi = 1, 10_000_000 while lo < hi: mid = (lo + hi) // 2 lo, hi = (lo, mid) if feasible(mid, rate, n) else (mid + 1, hi) return lo for rate in (1, 10): edge = max(smallest(1, rate), smallest(MAX_PARTICIPANTS_PER_ROUND, rate)) assert payout_config_problem(edge, rate) is None assert payout_config_problem(edge - 1, rate) is not None # each extreme can be the one that fails: one participant is bounded by the platform share # (reserve + dust), the maximum by the reserve that grows with the inputs assert smallest(1, 1) != smallest(MAX_PARTICIPANTS_PER_ROUND, 1) assert payout_config_problem(1, 1) is not None # a bet that does not even pay its own fee async def test_no_round_opens_while_the_config_could_not_pay_one(session_factory): async with session_factory() as session: config = (await session.scalars(select(RoundConfig))).one() config.bet_amount_sats = 5_000 # fee address set, but the platform's 30% cannot cover the reserve 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() == [] async with session_factory() as session: (await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000 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_an_unpayable_config(session_factory): """Same scope as every other prerequisite: it only stops the *next* round.""" async with session_factory() as session: session.add(Round(status="open")) (await session.scalars(select(RoundConfig))).one().fee_rate_sat_vb = 5_000 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" # --- 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: 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: round_ = await open_new_round_if_needed(session) await session.commit() assert round_.duration_seconds == 120 assert round_.cooldown_seconds == 45 async def test_round_accepts_bets_uses_the_rounds_own_duration(session_factory): from app.rounds.service import round_accepts_bets opened_at = datetime.now(timezone.utc) - timedelta(seconds=100) still_open = Round(status="open", opened_at=opened_at, duration_seconds=600) expired = Round(status="open", opened_at=opened_at, duration_seconds=60) assert round_accepts_bets(still_open) is True assert round_accepts_bets(expired) is False async def test_cooldown_comes_from_the_round_that_closed(session_factory): """The gap a closing round announced is the gap that's honoured: shortening round_cooldown_seconds afterwards must not open the next round early, nor lengthening it hold the lottery shut.""" async with session_factory() as session: (await session.scalars(select(RoundConfig))).one().round_cooldown_seconds = 0 # just lowered session.add( Round( status="closed", opened_at=datetime.now(timezone.utc) - timedelta(seconds=200), closed_at=datetime.now(timezone.utc) - timedelta(seconds=10), cooldown_seconds=300, # what that round ran with ) ) await session.commit() async with session_factory() as session: assert await open_new_round_if_needed(session) is None # still cooling down # --- B-73 (RND-34, DAT-61, DAT-64, SEC-56): the commitment made when a round opens -------- async def test_a_new_round_commits_to_an_encrypted_secret_and_copies_draw_confirmations(session_factory): """The hash is published, the secret is stored only encrypted (and decrypts to the preimage of the hash), the plaintext column stays empty, and the number of confirmations is copied from the config at open — an edit applies from the next round.""" import hashlib from app.wallet.keystore import decrypt_round_seed async with session_factory() as session: (await session.scalars(select(RoundConfig))).one().draw_confirmations = 5 await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) await session.commit() round_id = round_.id async with session_factory() as session: round_ = await session.get(Round, round_id) assert round_.draw_confirmations == 5 assert round_.server_seed is None # DAT-64: no plaintext before the reveal assert round_.server_seed_enc and round_.server_seed_hash seed = decrypt_round_seed(round_.server_seed_enc) assert len(bytes.fromhex(seed)) == 32 assert hashlib.sha256(bytes.fromhex(seed)).hexdigest() == round_.server_seed_hash assert seed not in round_.server_seed_enc # an encrypted token, not the secret in disguise (await session.scalars(select(RoundConfig))).one().draw_confirmations = 9 # edited mid-round await session.commit() assert (await session.get(Round, round_id)).draw_confirmations == 5 async def test_every_round_gets_its_own_secret(session_factory): seeds = set() for _ in range(3): async with session_factory() as session: round_ = await open_new_round_if_needed(session) round_.status = "closed" round_.closed_at = datetime.now(timezone.utc) - timedelta(days=1) seeds.add(round_.server_seed_hash) await session.commit() assert len(seeds) == 3 async def test_the_secret_cannot_be_decrypted_with_another_key(session_factory, monkeypatch): """SEC-56: a copy of the database without XPRV_ENCRYPTION_KEY does not reveal it.""" from cryptography.fernet import Fernet, InvalidToken from app.config import settings from app.wallet.keystore import decrypt_round_seed async with session_factory() as session: round_ = await open_new_round_if_needed(session) await session.commit() token = round_.server_seed_enc monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) with pytest.raises(InvalidToken): decrypt_round_seed(token)