319 lines
14 KiB
Python
319 lines
14 KiB
Python
"""B-80: every round has its own pool address (RND-35, DAT-60, DAT-11, REG-14, SEC-08,
|
|||
|
|
BET-12, RND-22, WDR-14, API-23, API-36).
|
||
|
|
|
||
|
|
All bets of all rounds used to pay one address `m/84'/746'/0'/1/0`: its history grew
|
||
|
|
without bound, other rounds' money and stray coins mixed with a round's own, and the
|
||
|
|
payout picked from everything it held. Now round N pays `m/84'/746'/0'/2/<N>`, stored
|
||
|
|
on the round; the old shared address survives as the *reserve* address and as the pool
|
||
|
|
of the rounds opened before the change.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import os
|
||
|
|
import sqlite3
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from embit import script
|
||
|
|
from embit.ec import PublicKey
|
||
|
|
from embit.transaction import Transaction
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
|
|
|
||
|
|
from app.bets.service import place_bet
|
||
|
|
from app.config import settings
|
||
|
|
from app.db.base import Base
|
||
|
|
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User, UtxoEvent, Withdrawal
|
||
|
|
from app.electrum.scripthash import address_to_scripthash
|
||
|
|
from app.rounds.scheduler import RoundScheduler
|
||
|
|
from app.rounds.service import open_new_round_if_needed
|
||
|
|
from app.wallet import hd
|
||
|
|
from app.wallet.hd import derive_pool_address, derive_pool_key, derive_round_pool_key, derive_user_address
|
||
|
|
from app.wallet.plm_network import PLM_MAINNET
|
||
|
|
from app.withdrawals.service import WithdrawalError, request_withdrawal
|
||
|
|
from tests.unit.test_scheduler import ( # noqa: F401 (fixture + helpers shared with the payout tests)
|
||
|
|
_FEE_ADDRESS,
|
||
|
|
_POOL_AMOUNT_SATS,
|
||
|
|
FakePayoutListener,
|
||
|
|
_seed_paying_out_round,
|
||
|
|
payout_session_factory,
|
||
|
|
)
|
||
|
|
|
||
|
|
_ROOT = Path(__file__).resolve().parents[2]
|
||
|
|
_EXTERNAL = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
|
||
|
|
|
||
|
|
|
||
|
|
def _address_of(key) -> str:
|
||
|
|
return script.p2wpkh(key.to_public()).address(network=PLM_MAINNET)
|
||
|
|
|
||
|
|
|
||
|
|
# --- derivation ---------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_each_round_has_its_own_branch_2_address_and_the_reserve_is_branch_1():
|
||
|
|
"""SEC-08 / REG-14: round N is m/84'/746'/0'/2/N, no round id is the reserve 1/0."""
|
||
|
|
account = hd._load_account_key()
|
||
|
|
assert _address_of(derive_pool_key(5)) == _address_of(account.derive("2/5"))
|
||
|
|
assert derive_pool_address(5) == _address_of(account.derive("2/5"))
|
||
|
|
assert derive_pool_address() == _address_of(account.derive("1/0")) # the reserve
|
||
|
|
assert len({derive_pool_address(1), derive_pool_address(2), derive_pool_address()}) == 3
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
derive_pool_key(0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_signing_key_must_control_the_stored_address():
|
||
|
|
"""DAT-60 / RND-22: a round's own address is signed with its own key, a round opened
|
||
|
|
before B-80 (reserve address stored) with the reserve key, anything else is refused."""
|
||
|
|
assert _address_of(derive_round_pool_key(3, derive_pool_address(3))) == derive_pool_address(3)
|
||
|
|
legacy = derive_round_pool_key(3, derive_pool_address()) # id 3, but the shared address
|
||
|
|
assert _address_of(legacy) == derive_pool_address()
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
derive_round_pool_key(3, derive_pool_address(4)) # another round's address
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
derive_round_pool_key(3, "")
|
||
|
|
|
||
|
|
|
||
|
|
# --- opening a round ----------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@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)
|
||
|
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||
|
|
session.add(RoundConfig(fee_address=_FEE_ADDRESS, round_cooldown_seconds=0))
|
||
|
|
await session.commit()
|
||
|
|
yield async_sessionmaker(engine, expire_on_commit=False)
|
||
|
|
await engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
async def test_opening_a_round_stores_its_own_pool_address(session_factory):
|
||
|
|
"""RND-35 / DAT-60: written in the transaction that inserts the round, from its id."""
|
||
|
|
async with session_factory() as session:
|
||
|
|
first = await open_new_round_if_needed(session)
|
||
|
|
await session.commit()
|
||
|
|
first_id, first_address = first.id, first.pool_address
|
||
|
|
first.status = "closed"
|
||
|
|
first.closed_at = first.opened_at
|
||
|
|
await session.commit()
|
||
|
|
|
||
|
|
async with session_factory() as session:
|
||
|
|
second = await open_new_round_if_needed(session)
|
||
|
|
await session.commit()
|
||
|
|
second_id, second_address = second.id, second.pool_address
|
||
|
|
|
||
|
|
assert first_id != second_id
|
||
|
|
assert first_address == derive_pool_address(first_id)
|
||
|
|
assert second_address == derive_pool_address(second_id)
|
||
|
|
assert first_address != second_address
|
||
|
|
assert "" not in (first_address, second_address)
|
||
|
|
|
||
|
|
|
||
|
|
# --- bets ---------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class _RecordingClient:
|
||
|
|
def __init__(self):
|
||
|
|
self.broadcasted: list[str] = []
|
||
|
|
|
||
|
|
async def broadcast(self, raw_tx_hex: str) -> str:
|
||
|
|
self.broadcasted.append(raw_tx_hex)
|
||
|
|
return "network-txid"
|
||
|
|
|
||
|
|
|
||
|
|
async def _funded_user(session_factory, index: int, sats: int) -> int:
|
||
|
|
async with session_factory() as session:
|
||
|
|
user = User(
|
||
|
|
username=f"user{index}", password_hash="x", derivation_index=index, address=derive_user_address(index)
|
||
|
|
)
|
||
|
|
session.add(user)
|
||
|
|
await session.commit()
|
||
|
|
session.add(UtxoEvent(user_id=user.id, txid=f"{index + 1:02x}" * 32, vout=0, amount_sats=sats, confirmed_height=1))
|
||
|
|
await session.commit()
|
||
|
|
return user.id
|
||
|
|
|
||
|
|
|
||
|
|
async def test_a_bet_pays_the_pool_address_of_its_round(session_factory):
|
||
|
|
"""BET-12 / B-80: the bet's recipient output is round.pool_address, not the reserve."""
|
||
|
|
user_id = await _funded_user(session_factory, 0, 2_000_000_000)
|
||
|
|
client = _RecordingClient()
|
||
|
|
async with session_factory() as session:
|
||
|
|
user = await session.get(User, user_id)
|
||
|
|
await place_bet(session, client, user)
|
||
|
|
round_ = (await session.scalars(select(Round))).one()
|
||
|
|
pool_address = round_.pool_address
|
||
|
|
|
||
|
|
assert pool_address == derive_pool_address(round_.id)
|
||
|
|
outputs = {o.script_pubkey.address(network=PLM_MAINNET) for o in Transaction.parse(bytes.fromhex(client.broadcasted[0])).vout}
|
||
|
|
assert pool_address in outputs
|
||
|
|
assert derive_pool_address() not in outputs # the reserve address gets no bets any more
|
||
|
|
|
||
|
|
|
||
|
|
# --- payout -------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class _RecordingPayoutClient:
|
||
|
|
def __init__(self, entries):
|
||
|
|
self._entries = entries
|
||
|
|
self.scripthashes: list[str] = []
|
||
|
|
self.broadcasted: list[str] = []
|
||
|
|
|
||
|
|
async def listunspent(self, scripthash):
|
||
|
|
self.scripthashes.append(scripthash)
|
||
|
|
return self._entries
|
||
|
|
|
||
|
|
async def broadcast(self, raw_tx_hex):
|
||
|
|
self.broadcasted.append(raw_tx_hex)
|
||
|
|
return "network-txid"
|
||
|
|
|
||
|
|
|
||
|
|
def _signer_address(raw_tx_hex: str) -> str:
|
||
|
|
witness = Transaction.parse(bytes.fromhex(raw_tx_hex)).vin[0].witness.items
|
||
|
|
return script.p2wpkh(PublicKey.parse(witness[1])).address(network=PLM_MAINNET)
|
||
|
|
|
||
|
|
|
||
|
|
async def _payout(payout_session_factory, pool_address: str, round_id: int = 1):
|
||
|
|
await _seed_paying_out_round(payout_session_factory, round_id)
|
||
|
|
async with payout_session_factory() as session:
|
||
|
|
round_ = await session.get(Round, round_id)
|
||
|
|
round_.pool_address = pool_address
|
||
|
|
await session.commit()
|
||
|
|
client = _RecordingPayoutClient(
|
||
|
|
[{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}]
|
||
|
|
)
|
||
|
|
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(round_id)
|
||
|
|
return client
|
||
|
|
|
||
|
|
|
||
|
|
async def test_the_payout_reads_and_signs_with_the_round_own_pool_address(payout_session_factory):
|
||
|
|
"""RND-22 / DAT-11 / B-80: only the round's address is asked about, and its key signs."""
|
||
|
|
client = await _payout(payout_session_factory, derive_pool_address(1))
|
||
|
|
assert client.scripthashes == [address_to_scripthash(derive_pool_address(1))]
|
||
|
|
assert _signer_address(client.broadcasted[0]) == derive_pool_address(1)
|
||
|
|
|
||
|
|
|
||
|
|
async def test_a_round_opened_before_b80_is_still_paid_from_the_reserve_address(payout_session_factory):
|
||
|
|
"""DAT-60 / RND-22: its stored pool_address is the old shared one, signed with 1/0."""
|
||
|
|
client = await _payout(payout_session_factory, derive_pool_address(), round_id=6)
|
||
|
|
assert client.scripthashes == [address_to_scripthash(derive_pool_address())]
|
||
|
|
assert _signer_address(client.broadcasted[0]) == derive_pool_address()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("stored", ["", "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"])
|
||
|
|
async def test_a_payout_never_signs_for_an_address_its_keys_do_not_control(payout_session_factory, stored):
|
||
|
|
"""RND-22 / B-80: an empty or foreign stored address builds nothing and sends nothing."""
|
||
|
|
client = await _payout(payout_session_factory, stored)
|
||
|
|
assert client.broadcasted == []
|
||
|
|
assert client.scripthashes == []
|
||
|
|
async with payout_session_factory() as session:
|
||
|
|
reasons = (await session.scalars(select(AuditLog.payload_json).where(AuditLog.event_type == "payout_failed"))).all()
|
||
|
|
assert len(reasons) == 1 and "payout build failed" in reasons[0]
|
||
|
|
|
||
|
|
|
||
|
|
async def test_an_rbf_bump_of_a_payout_uses_the_round_own_key(payout_session_factory):
|
||
|
|
"""TX-11 / B-80: tx/broadcast.py:_signing_context follows the round's pool address."""
|
||
|
|
from app.tx.broadcast import RbfError, _signing_context
|
||
|
|
|
||
|
|
await _seed_paying_out_round(payout_session_factory, 4)
|
||
|
|
async with payout_session_factory() as session:
|
||
|
|
round_ = await session.get(Round, 4)
|
||
|
|
round_.pool_address = derive_pool_address(4)
|
||
|
|
await session.commit()
|
||
|
|
_key, _script, address = await _signing_context(session, "payout", None, 4)
|
||
|
|
assert address == derive_pool_address(4)
|
||
|
|
|
||
|
|
round_.pool_address = ""
|
||
|
|
with pytest.raises(RbfError):
|
||
|
|
await _signing_context(session, "payout", None, 4)
|
||
|
|
|
||
|
|
|
||
|
|
# --- withdrawals --------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("which", ["round_pool", "reserve", "fee"])
|
||
|
|
async def test_a_withdrawal_to_an_address_of_the_platform_is_refused(session_factory, which):
|
||
|
|
"""WDR-14 / SEC-43 / B-80: refused with withdrawal_to_platform_address before any coin
|
||
|
|
is selected or marked spent."""
|
||
|
|
user_id = await _funded_user(session_factory, 3, 3_000_000_000)
|
||
|
|
async with session_factory() as session:
|
||
|
|
round_ = await open_new_round_if_needed(session)
|
||
|
|
await session.commit()
|
||
|
|
destination = {
|
||
|
|
"round_pool": round_.pool_address,
|
||
|
|
"reserve": derive_pool_address(),
|
||
|
|
"fee": _FEE_ADDRESS,
|
||
|
|
}[which]
|
||
|
|
|
||
|
|
client = _RecordingClient()
|
||
|
|
async with session_factory() as session:
|
||
|
|
user = await session.get(User, user_id)
|
||
|
|
with pytest.raises(WithdrawalError) as excinfo:
|
||
|
|
await request_withdrawal(session, client, user, destination, 1_000_000_000)
|
||
|
|
assert excinfo.value.code == "withdrawal_to_platform_address"
|
||
|
|
# Case does not get around it: bech32 is case-insensitive.
|
||
|
|
with pytest.raises(WithdrawalError) as excinfo:
|
||
|
|
await request_withdrawal(session, client, user, destination.upper(), 1_000_000_000)
|
||
|
|
assert excinfo.value.code in ("withdrawal_to_platform_address", "invalid_address")
|
||
|
|
|
||
|
|
assert client.broadcasted == []
|
||
|
|
async with session_factory() as session:
|
||
|
|
assert (await session.scalars(select(UtxoEvent.spent_txid))).all() == [None]
|
||
|
|
assert (await session.scalars(select(Withdrawal))).all() == []
|
||
|
|
|
||
|
|
|
||
|
|
async def test_the_old_pool_addresses_of_closed_rounds_stay_refused(session_factory):
|
||
|
|
"""WDR-14: a *closed* round's address still belongs to the platform."""
|
||
|
|
user_id = await _funded_user(session_factory, 3, 3_000_000_000)
|
||
|
|
async with session_factory() as session:
|
||
|
|
session.add(Round(id=40, status="closed", pool_address=derive_pool_address(40)))
|
||
|
|
await session.commit()
|
||
|
|
async with session_factory() as session:
|
||
|
|
user = await session.get(User, user_id)
|
||
|
|
with pytest.raises(WithdrawalError) as excinfo:
|
||
|
|
await request_withdrawal(session, _RecordingClient(), user, derive_pool_address(40), 1_000_000_000)
|
||
|
|
assert excinfo.value.code == "withdrawal_to_platform_address"
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_platform_address_error_is_translated_in_every_language():
|
||
|
|
"""The `error.withdrawal_to_platform_address` code needs all 7 languages (CLAUDE.md)."""
|
||
|
|
source = (_ROOT / "app" / "static" / "i18n.js").read_text(encoding="utf-8")
|
||
|
|
assert source.count("'error.withdrawal_to_platform_address'") == 7
|
||
|
|
|
||
|
|
|
||
|
|
# --- migration ----------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_migration_backfills_existing_rounds_with_the_reserve_address(tmp_path):
|
||
|
|
"""DAT-60 / B-80: rounds that exist at upgrade time all paid the shared address, so they
|
||
|
|
get the reserve address as pool_address; the expression indexes survive (B-97)."""
|
||
|
|
key_path = tmp_path / "master.xprv.enc"
|
||
|
|
db_path = tmp_path / "old.db"
|
||
|
|
# The autouse fixture already put a throwaway key in `settings`: write it where the
|
||
|
|
# subprocess will read it and hand it the same encryption key.
|
||
|
|
key_path.write_bytes(Path(settings.master_key_path).read_bytes())
|
||
|
|
env = {
|
||
|
|
**os.environ,
|
||
|
|
"DATABASE_URL": f"sqlite+aiosqlite:///{db_path}",
|
||
|
|
"MASTER_KEY_PATH": str(key_path),
|
||
|
|
"XPRV_ENCRYPTION_KEY": settings.xprv_encryption_key,
|
||
|
|
}
|
||
|
|
|
||
|
|
def alembic(*args):
|
||
|
|
result = subprocess.run(
|
||
|
|
[sys.executable, "-m", "alembic", *args], cwd=_ROOT, env=env, capture_output=True, text=True, timeout=120
|
||
|
|
)
|
||
|
|
assert result.returncode == 0, result.stderr[-3000:]
|
||
|
|
|
||
|
|
alembic("upgrade", "283844a44b4a") # the revision just before pool_address
|
||
|
|
with sqlite3.connect(db_path) as conn:
|
||
|
|
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 ('open', '2026-01-02 00:00:00')")
|
||
|
|
alembic("upgrade", "head")
|
||
|
|
|
||
|
|
with sqlite3.connect(db_path) as conn:
|
||
|
|
addresses = [row[0] for row in conn.execute("SELECT pool_address FROM rounds ORDER BY id")]
|
||
|
|
indexes = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'index'")}
|
||
|
|
assert addresses == [derive_pool_address()] * 2
|
||
|
|
assert {"ix_rounds_single_active", "ix_users_username_lower"} <= indexes
|