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>
250 lines
12 KiB
Python
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
|