Files
plm-lottery/tests/unit/test_round_snapshot.py
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

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

250 lines
12 KiB
Python

"""B-95 (RND-05, RND-06, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-31): a round
runs by the parameters it opened with.
Only the duration and the cooldown were copied onto the round when it opened; the cost of a bet
and the fee address were read live, so an edit from /admin in the middle of a round gave the
players of that round different stakes for the same chance, and redirected the commission of
the round in progress. The cap on the pool is new.
"""
import sqlite3
from pathlib import Path
import pytest
from embit.transaction import Transaction
from sqlalchemy import select
from app.bets.service import BetError, place_bet
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
from app.rounds.scheduler import RoundScheduler
from app.rounds.service import open_new_round_if_needed
from app.wallet.plm_network import PLM_MAINNET
from tests.unit.test_bets import FakeElectrumClient, _make_funded_user, session_factory # noqa: F401 (fixture)
from tests.unit.test_migrations import _alembic, _index_sql
from tests.unit.test_scheduler import FakePayoutClient, FakePayoutListener, _FEE_ADDRESS as _SEEDED_FEE_ADDRESS
_OTHER_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
async def _edit_config(session_factory, **values) -> None:
async with session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
for name, value in values.items():
setattr(config, name, value)
await session.commit()
async def _open_round(session_factory) -> Round:
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
return round_
# --- the copy (RND-05, DAT-61) ---------------------------------------------------------------------------------------
async def test_a_round_copies_cost_fee_address_and_cap_when_it_opens(session_factory):
await _edit_config(session_factory, bet_amount_sats=700_000_000, max_round_pool_sats=5_000_000_000)
round_ = await _open_round(session_factory)
assert (round_.bet_amount_sats, round_.fee_address, round_.max_round_pool_sats) == (
700_000_000,
_SEEDED_FEE_ADDRESS,
5_000_000_000,
)
assert round_.draw_confirmations == 3 and round_.duration_seconds == 600 # what was already copied
async def test_an_edit_from_admin_never_reaches_the_round_in_progress_but_the_next_one(session_factory):
first = await _open_round(session_factory)
await _edit_config(
session_factory, bet_amount_sats=2_000_000_000, fee_address=_OTHER_FEE_ADDRESS, max_round_pool_sats=9_000_000_000
)
async with session_factory() as session:
again = await session.get(Round, first.id)
assert (again.bet_amount_sats, again.fee_address, again.max_round_pool_sats) == (
1_000_000_000,
_SEEDED_FEE_ADDRESS,
0,
)
again.status = "closed"
again.closed_at = None # no cooldown to wait for
await session.commit()
second = await _open_round(session_factory)
assert (second.bet_amount_sats, second.fee_address, second.max_round_pool_sats) == (
2_000_000_000,
_OTHER_FEE_ADDRESS,
9_000_000_000,
)
# --- the cost (BET-21, DAT-26) -----------------------------------------------------------------------------------------
@pytest.mark.parametrize("edited", [3_000_000_000, 500_000_000], ids=["raised", "lowered"])
async def test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile(session_factory, edited):
first = await _make_funded_user(session_factory, 0, 5_000_000_000)
second = await _make_funded_user(session_factory, 1, 5_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
await place_bet(session, client, await session.get(User, first))
await _edit_config(session_factory, bet_amount_sats=edited)
async with session_factory() as session:
await place_bet(session, client, await session.get(User, second))
amounts = []
for raw in client.broadcasted:
tx = Transaction.parse(bytes.fromhex(raw))
amounts.append(tx.vout[0].value)
# both pay the same stake (cost 10 PLM minus the same network fee), not the edited one
assert amounts[0] == amounts[1]
assert 1_000_000_000 - 10_000 < amounts[0] <= 1_000_000_000
async with session_factory() as session:
stakes = (await session.scalars(select(RoundParticipant.bet_amount_sats))).all()
assert len(set(stakes)) == 1
# --- the commission goes where the round said (RND-06) ---------------------------------------------------------------
async def test_the_payout_pays_the_fee_address_the_round_opened_with(payout_session_factory):
from app.wallet.hd import derive_pool_address
from tests.unit.test_scheduler import _seed_paying_out_round
edited_to = derive_pool_address(4242) # a valid address that is neither the winner's nor the seeded one
await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
(await session.scalars(select(RoundConfig))).one().fee_address = edited_to # edited mid-round
await session.commit()
client = FakePayoutClient([])
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
assert tx.vout[1].script_pubkey.address(network=PLM_MAINNET) == _SEEDED_FEE_ADDRESS
assert edited_to not in {o.script_pubkey.address(network=PLM_MAINNET) for o in tx.vout}
async def test_clearing_the_fee_address_mid_round_does_not_stop_its_payout(payout_session_factory):
from tests.unit.test_scheduler import _seed_paying_out_round
await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
(await session.scalars(select(RoundConfig))).one().fee_address = "" # the operator slip of B-66
await session.commit()
client = FakePayoutClient([])
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(1)
assert client.broadcasted and not [
e for e in (await _events(payout_session_factory)) if e == "payout_failed"
]
async def _events(session_factory) -> list[str]:
async with session_factory() as session:
return list((await session.scalars(select(AuditLog.event_type))).all())
# the fixture of the payout tests (the in-memory database with a master key)
from tests.unit.test_scheduler import payout_session_factory # noqa: E402,F401
# --- the cap on the pool (BET-20, DAT-27) ------------------------------------------------------------------------------------
async def _bet(session_factory, user_id: int, client=None):
async with session_factory() as session:
return await place_bet(session, client or FakeElectrumClient(), await session.get(User, user_id))
async def test_a_bet_is_refused_once_the_pool_has_reached_the_cap_and_only_then(session_factory):
# one bet nets ~ 999.9k sat: a cap of 1.5 bets lets exactly two bets in (the second starts under it)
await _edit_config(session_factory, max_round_pool_sats=1_500_000_000)
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(3)]
await _bet(session_factory, users[0]) # pool 0 < cap
await _bet(session_factory, users[1]) # pool ~1.0e9 < 1.5e9: accepted, takes the pool past the cap
with pytest.raises(BetError) as refused:
await _bet(session_factory, users[2]) # pool ~2.0e9 >= cap
assert refused.value.code == "round_full" and refused.value.params["max_round_pool_sats"] == 1_500_000_000
async with session_factory() as session: # nothing moved for the refused player
assert (await session.scalars(select(PendingTransaction.user_id))).all().count(users[2]) == 0
assert len((await session.scalars(select(RoundParticipant))).all()) == 2
from app.db.models import UtxoEvent
refused_coins = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == users[2]))).all()
assert refused_coins and all(coin.spent_txid is None for coin in refused_coins)
async def test_a_pool_exactly_at_the_cap_has_reached_it(session_factory):
""""Reached" is `>=`: a pool that equals the cap is full."""
first = await _make_funded_user(session_factory, 0, 2_000_000_000)
second = await _make_funded_user(session_factory, 1, 2_000_000_000)
await _bet(session_factory, first)
async with session_factory() as session:
net = (await session.scalars(select(RoundParticipant.bet_amount_sats))).one()
(await session.scalars(select(Round))).one().max_round_pool_sats = net # the round's own cap, set to the pool
await session.commit()
with pytest.raises(BetError) as refused:
await _bet(session_factory, second)
assert refused.value.code == "round_full"
async def test_the_cap_counts_bets_still_in_flight(session_factory):
await _edit_config(session_factory, max_round_pool_sats=900_000_000)
first = await _make_funded_user(session_factory, 0, 2_000_000_000)
second = await _make_funded_user(session_factory, 1, 2_000_000_000)
await _bet(session_factory, first) # `broadcast`, not confirmed
async with session_factory() as session:
assert (await session.scalars(select(RoundParticipant.status))).all() == ["broadcast"]
with pytest.raises(BetError) as refused:
await _bet(session_factory, second)
assert refused.value.code == "round_full"
async def test_without_a_cap_nothing_limits_the_pool_but_the_participants(session_factory):
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)]
for user_id in users:
await _bet(session_factory, user_id)
async with session_factory() as session:
assert len((await session.scalars(select(RoundParticipant))).all()) == 4
async def test_raising_or_lowering_the_cap_mid_round_does_not_change_that_round(session_factory):
await _edit_config(session_factory, max_round_pool_sats=1_500_000_000)
users = [await _make_funded_user(session_factory, i, 2_000_000_000) for i in range(4)]
await _bet(session_factory, users[0])
await _edit_config(session_factory, max_round_pool_sats=100_000_000_000) # raised: this round keeps 1.5e9
await _bet(session_factory, users[1])
with pytest.raises(BetError):
await _bet(session_factory, users[2])
await _edit_config(session_factory, max_round_pool_sats=1) # lowered below everything: no effect either
async with session_factory() as session:
assert (await session.scalars(select(Round.max_round_pool_sats))).all() == [1_500_000_000]
# --- the existing rounds at the migration (DAT-55) -------------------------------------------------------------------------
def test_the_migration_fills_existing_rounds_with_the_current_configuration(tmp_path):
db_path = tmp_path / "old.db"
_alembic(db_path, "upgrade", "f2c64a8d1e93")
with sqlite3.connect(db_path) as conn:
conn.execute(
"INSERT INTO round_config (fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, "
"fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, paused, deposit_confirmations, "
"draw_confirmations, updated_at) "
f"VALUES ('{_OTHER_FEE_ADDRESS}', 750000000, 600, 30, 1, 900, 20, 0, 6, 3, '2026-01-01 00:00:00')"
)
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('paying_out', '2026-01-02 00:00:00')")
_alembic(db_path, "upgrade", "head")
with sqlite3.connect(db_path) as conn:
rows = conn.execute("SELECT bet_amount_sats, fee_address, max_round_pool_sats FROM rounds").fetchall()
cap = conn.execute("SELECT max_round_pool_sats FROM round_config").fetchone()[0]
assert rows == [(750_000_000, _OTHER_FEE_ADDRESS, 0)] * 2 and cap == 0
assert _index_sql(Path(db_path), "ix_rounds_single_active") is not None
assert _index_sql(Path(db_path), "ix_users_username_lower") is not None