Files

319 lines
15 KiB
Python
Raw Permalink Normal View History

"""B-82 (RND-22, RND-39, DAT-11, DAT-67, DAT-68, ELC-27, AUD-44, AUD-45): the coins of a round
are verified with the quorum before a payout is built.
A user can bet and then — with a private chain, or just by being unlucky in a reorganisation —
get the deposit that funded the bet erased. The bet leaves the chain but its participant stayed
`confirmed`, so the payout looked for coins that no longer existed and failed "insufficient pool
UTXOs" for ever, with every other player's money stuck. Now the bets that are gone leave the
round, the 70/30 split is computed on what is really there, and a winner whose own bet is gone
holds the payout and pauses the lottery.
"""
import pytest
from embit.transaction import Transaction
from sqlalchemy import select
from app.config import MIN_QUORUM_RESPONSES
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
from app.electrum.client import ElectrumEndpoint
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.rounds.scheduler import RoundScheduler
from app.rounds.service import payout_config_problem, winner_share
from app.wallet.hd import derive_pool_address
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_TX_INPUTS,
estimate_vsize,
payout_bump_reserve,
)
from tests.unit.test_scheduler import ( # noqa: F401 (fixture and helpers)
_FEE_ADDRESS,
_WINNER_ADDRESS,
FakePayoutClient,
FakePayoutListener,
_payout_bet_txid as _bet_txid,
_seed_paying_out_round,
payout_session_factory,
)
_BET = 999_000_000 # the net amount of a 10 PLM bet, roughly
def _scheduler(session_factory, listener=None):
listener = listener or FakePayoutListener(FakePayoutClient([]))
return RoundScheduler(session_factory, listener), listener
async def _state(session_factory):
async with session_factory() as session:
round_ = await session.get(Round, 1)
participants = {
p.bet_txid: p.status for p in (await session.scalars(select(RoundParticipant))).all()
}
events = [(e.event_type, e.payload_json) for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
pending = (await session.scalars(select(PendingTransaction))).all()
config = await session.scalar(select(RoundConfig))
return round_, participants, events, pending, config
def _names(events):
return [name for name, _ in events]
# --- everything is there -------------------------------------------------------------------------------
async def test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else(payout_session_factory):
"""RND-22: other coins at the pool address are not touched — the active connection is not
even asked for the address' coins, and the inputs are the participants' `bet_txid:bet_vout`."""
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
stray = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 10, "value": 5_000_000_000}]
listener.client = FakePayoutClient(stray)
await scheduler._trigger_payout(1)
assert listener.client.broadcasted
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert sorted((v.txid.hex(), v.vout) for v in tx.vin) == sorted((_bet_txid(i), 0) for i in range(3))
assert listener.verified_scripthashes == [address_to_scripthash(derive_pool_address(1))]
assert [(t, v, a) for t, v, a in listener.verified_outpoints[0]] == [(_bet_txid(i), 0, _BET) for i in range(3)]
round_, participants, events, pending, _ = await _state(payout_session_factory)
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 3 * _BET
assert "participant_reorged_out" not in _names(events) and "payout_blocked" not in _names(events)
# --- a bet that is gone (RND-39, DAT-67, AUD-45) ----------------------------------------------------------
async def test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET + 1, _BET + 2, _BET + 3])
scheduler, listener = _scheduler(payout_session_factory)
gone = (_bet_txid(2), 0) # a loser's bet, double-spent away
listener.pool_absent = {gone}
await scheduler._trigger_payout(1)
round_, participants, events, pending, _ = await _state(payout_session_factory)
remaining = _BET + (_BET + 1) + (_BET + 3)
assert participants[gone[0]] == "reorged_out"
assert [s for t, s in participants.items() if t != gone[0]] == ["confirmed"] * 3
assert round_.pool_amount_sats == remaining # DAT-67: rewritten, and only ever reduced
row = next(p for n, p in events if n == "participant_reorged_out")
assert f'"txid": "{gone[0]}"' in row and f'"pool_amount_sats": {remaining}' in row
# the transaction spends the three bets that are there, and splits exactly their sum
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert gone not in {(v.txid.hex(), v.vout) for v in tx.vin}
assert len(tx.vin) == 3
fee = estimate_vsize(3, 3)
reserve = payout_bump_reserve(3)
assert tx.vout[0].value == winner_share(remaining) - fee == round_.winner_amount_sats
assert tx.vout[1].value == remaining - winner_share(remaining) - reserve == round_.fee_amount_sats
assert tx.vout[2].value == reserve == round_.bump_reserve_sats
assert sum(o.value for o in tx.vout) + fee == remaining
async def test_several_bets_gone_at_once_each_get_a_row_in_one_transaction(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET] * 5)
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(1), 0), (_bet_txid(4), 0)}
await scheduler._trigger_payout(1)
round_, participants, events, _, _ = await _state(payout_session_factory)
assert sorted(t for t, s in participants.items() if s == "reorged_out") == sorted([_bet_txid(1), _bet_txid(4)])
assert _names(events).count("participant_reorged_out") == 2
assert round_.pool_amount_sats == 3 * _BET
# --- the winner's own bet is gone (DAT-68, AUD-44) -----------------------------------------------------------
async def test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery(payout_session_factory):
winner_id = await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0), (_bet_txid(2), 0)} # the winner's, and one more
await scheduler._trigger_payout(1)
round_, participants, events, pending, config = await _state(payout_session_factory)
assert listener.client.broadcasted == [] and pending == [] # nothing built, nothing sent
assert round_.payout_blocked_at is not None and round_.status == "paying_out"
assert (config.paused, config.paused_reason) == (True, "payout_blocked")
names = _names(events)
assert "payout_blocked" in names and "payout_failed" not in names # not a failure, not a retry clock
assert ("lottery_paused", '{"reason": "payout_blocked"}') in events
# the other missing bet is excluded now; the winner's stays `confirmed` so that it is
# verified again after the resume (OPS-30)
assert participants[_bet_txid(2)] == "reorged_out" and participants[_bet_txid(0)] == "confirmed"
blocked = next(p for n, p in events if n == "payout_blocked")
assert f'"txid": "{_bet_txid(0)}"' in blocked
async def test_a_held_payout_is_not_attempted_again_until_the_lottery_is_resumed(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0)}
await scheduler._trigger_payout(1)
asked = len(listener.verified_scripthashes)
for _ in range(3): # three scheduler steps over the held round
await scheduler._retry_payout_if_due(1)
await scheduler._trigger_payout(1)
assert len(listener.verified_scripthashes) == asked # no verification, no build, no failure rows
_, _, events, pending, _ = await _state(payout_session_factory)
assert pending == [] and "payout_failed" not in _names(events)
@pytest.mark.parametrize("still_gone", [True, False])
async def test_after_the_resume_the_verification_repeats(payout_session_factory, still_gone):
"""DAT-68: the operator's resume clears the mark; the next attempt verifies again. If the
bet is still gone the payout is held and the lottery paused again (a new row); if it is
back, the payout goes out."""
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0)}
await scheduler._trigger_payout(1)
async with payout_session_factory() as session: # what POST /admin/resume does
round_ = await session.get(Round, 1)
round_.payout_blocked_at = None
(await session.scalar(select(RoundConfig))).paused = False
await session.commit()
if not still_gone:
listener.pool_absent = set()
await scheduler._trigger_payout(1)
round_, _, events, pending, config = await _state(payout_session_factory)
if still_gone:
assert _names(events).count("payout_blocked") == 2
assert round_.payout_blocked_at is not None and config.paused is True and pending == []
else:
assert round_.payout_blocked_at is None and len(pending) == 1 and "payout_sent" in _names(events)
# --- no quorum (RND-25, ELC-27) ------------------------------------------------------------------------------
async def test_without_a_quorum_nothing_changes_and_the_attempt_is_a_payout_failed(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_unavailable = True
await scheduler._trigger_payout(1)
round_, participants, events, pending, config = await _state(payout_session_factory)
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 2 * _BET
assert pending == [] and listener.client.broadcasted == []
assert [p for n, p in events if n == "payout_failed"] and "pool verification unavailable" in dict(events)["payout_failed"]
assert config.paused is False and round_.payout_blocked_at is None
# --- the extremes of RND-40, through the scheduler ----------------------------------------------------------------
async def test_a_round_of_one_participant_at_the_smallest_viable_bet_is_paid(payout_session_factory):
"""The smallest net bet RND-40 allows (the config's own bound) still yields a payout whose
three outputs are all at least dust."""
bet = next(b for b in range(10_000, 200_000) if payout_config_problem(b + estimate_vsize(MAX_TX_INPUTS, 2), 1) is None)
await _seed_paying_out_round(payout_session_factory, bets=[bet])
scheduler, listener = _scheduler(payout_session_factory)
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert len(tx.vin) == 1 and len(tx.vout) == 3
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
assert sum(o.value for o in tx.vout) + estimate_vsize(1, 3) == bet
async def test_a_full_round_is_paid_with_the_reserve_fee_and_dust_all_covered(payout_session_factory):
bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2)
await _seed_paying_out_round(payout_session_factory, bets=[bet] * MAX_PARTICIPANTS_PER_ROUND)
scheduler, listener = _scheduler(payout_session_factory)
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert len(tx.vin) == MAX_PARTICIPANTS_PER_ROUND and len(tx.vout) == 3
assert tx.vout[2].value == payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND)
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
assert sum(o.value for o in tx.vout) + estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) == bet * MAX_PARTICIPANTS_PER_ROUND
# --- the quorum's verdict itself (ELC-27) -------------------------------------------------------------------------------
_ENDPOINTS = [
ElectrumEndpoint("first.example", 50002, True),
ElectrumEndpoint("second.example", 50002, True),
ElectrumEndpoint("third.example", 50002, True),
ElectrumEndpoint("fourth.example", 50002, True),
]
_OUTPOINT = ("aa" * 32, 0, 1_000)
class _Server:
def __init__(self, entries):
self._entries = entries
async def connect(self):
if self._entries is None:
raise ConnectionRefusedError("down")
async def listunspent(self, scripthash):
return self._entries
async def close(self):
pass
def _listener(answers: dict[str, object], session_factory=None):
servers = {host: _Server(entries) for host, entries in answers.items()}
return ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS)
def _listed(value=1_000, height=50):
return [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": height, "value": value}]
async def test_a_strict_majority_decides_each_outpoint():
# first.example is the active endpoint (never asked); second, third, fourth vote
present = _listener({"second.example": _listed(), "third.example": _listed(), "fourth.example": []})
assert await present.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): True}
absent = _listener({"second.example": _listed(), "third.example": [], "fourth.example": []})
assert await absent.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
async def test_another_amount_counts_as_absent_and_a_mempool_coin_as_neither():
other_amount = _listener({h: _listed(value=999) for h in ("second.example", "third.example", "fourth.example")})
assert await other_amount.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
unconfirmed = _listener({h: _listed(height=0) for h in ("second.example", "third.example", "fourth.example")})
assert await unconfirmed.verify_pool_outpoints("sh", [_OUTPOINT]) is None # may be mined again: no verdict
async def test_too_few_answers_or_a_tie_conclude_nothing():
assert MIN_QUORUM_RESPONSES == 2
one_answer = _listener({"second.example": [], "third.example": None, "fourth.example": None})
assert await one_answer.verify_pool_outpoints("sh", [_OUTPOINT]) is None
tie = _listener({"second.example": _listed(), "third.example": [], "fourth.example": None})
assert await tie.verify_pool_outpoints("sh", [_OUTPOINT]) is None
# nothing at all: the active server is never a substitute for the quorum
nobody = _listener({h: None for h in ("second.example", "third.example", "fourth.example")})
assert await nobody.verify_pool_outpoints("sh", [_OUTPOINT]) is None
async def test_the_active_server_is_not_asked_and_cannot_vouch_for_a_bet():
class _Hostile(_Server):
async def listunspent(self, scripthash):
raise AssertionError("the active server must not be asked")
servers = {"first.example": _Hostile(_listed()), "second.example": _Server([]), "third.example": _Server([]), "fourth.example": _Server([])}
listener = ElectrumListener(lambda endpoint: servers[endpoint.host], None, _ENDPOINTS)
listener._endpoint_index = 0
listener.client = object()
assert await listener.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}