Files
plm-lottery/tests/unit/test_rounds_service.py
T
davideandClaude Sonnet 5.5 daa85cf4f0 Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
The pool normally holds exactly the round's bet outputs and the payout spent them all for
exactly the pool amount, so it had no change output: bump_fee could never accelerate it,
and a payout stuck in a mempool (never abandoned while a server knows it) kept the round in
`paying_out` and the whole lottery stopped.

- build_payout_transaction always has three outputs: winner, fee address, and a bump reserve
  of max(DUST_LIMIT_SATS, estimate_vsize(inputs, 3) * 10 sat/vB) paid to the reserve address
  m/84'/746'/0'/1/0. The reserve comes out of the platform's 30%, the network fee still out
  of the winner's 70%. Input value beyond winner share + commission (only rounds opened
  before the per-round pool address) is folded into the reserve output if >= dust, left to
  the network fee otherwise. `change_address` is replaced by `reserve_address`.
- `rounds.bump_reserve_sats` (Alembic e5a91c3d7b26, plain add_column) is written with the
  payout intent before the broadcast; /admin/rounds returns it.
- bump_fee takes the increase from the reserve output for a payout
  (_find_reducible_output; a payout already in flight with a change to the pool still falls
  back to that), never from the winner or fee-address outputs.
- payout_config_problem / rounds_can_open (RND-40): a bet amount and fee rate under which a
  round of 1 or of MAX_PARTICIPANTS_PER_ROUND participants could not cover the network fee,
  the reserve and dust outputs opens no round (lottery_configured=false); PUT /admin/config
  refuses such a bet amount or fee rate with 422 config_not_payable, judged on the resulting
  values, since the payout reads the fee rate live.

Ambiguity resolved: the reserve output and any excess input are one output (RND-23 says one
output to the reserve address), and bump_reserve_sats records that output's initial value.
DAT-22's "abandon clears all amounts" stays with B-96.

Tests changed because the spec changed: the four test_payout_builder tests asserted the old
two/three-output shape with change to the pool and the fee address receiving the whole 30%;
the whitespace fee_address test now spells out the amounts rounds_can_open reads. New: reserve
sizing, payout with 1 and with 400 participants (inputs = outputs + fee to the satoshi),
exact commission/reserve/dust boundary, excess folded or left to the fee, a real payout bumped
out of its reserve with the other outputs byte-identical and bumped five times, the old
reserve-less shape still raising RbfError, the scheduler output addresses and recorded
amounts, payability boundaries found independently by bisection, no round / unconfigured flag
for an unpayable config, a round in progress surviving it, PUT /admin/config refusals and the
migration column. Mutation-checked: dropping the reserve lookup in bump_fee and the payability
check in rounds_can_open each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:55:51 +02:00

431 lines
18 KiB
Python

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)