Files
plm-lottery/tests/unit/test_round_snapshot.py
T

249 lines
12 KiB
Python
Raw Normal View History

"""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