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>
431 lines
18 KiB
Python
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)
|