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