Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a private chain: the bet left the chain but its participant stayed `confirmed`, and the payout failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs were also credited at 1 confirmation (maturity is 120). Deposits - `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the threshold for a deposit; an output of a platform transaction (any txid of a pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120, recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached). `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no session is open across the lookup. - The quorum must also place the coin deep enough on our own tip (ELC-17). - Confirmed outputs short of their threshold are pending deposits (memory only, replaced at every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal message), never spendable. No scripthash notification announces a maturing deposit, so each new block refreshes only the users that have one, one task per user (DEP-09 now says so). Payout - `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same transaction, and the 70/30 split is computed on what is left. - Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93, with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears the mark and the next attempt verifies again. /admin/rounds returns it. - Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server `listunspent` choice, since that address holds no single round's bets. Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says "1 confirmation" (7 languages, same keys); guides updated. Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0 now set a tip past every threshold; the payout fixtures seed confirmed participants (the new source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the verification, and test_round_pool_address asserts the quorum is asked about the round's address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with the split recomputed, winner gone held and paused, held payouts untouched until the resume, re-verification after it, no quorum, a payout with 1 and with 400 participants against reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored coinbase, no height bound, no verification and no winner hold each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
daa85cf4f0
commit
54dbffb552
42 files changed
+1360
-270
No files matched your search
@@ -0,0 +1,319 @@
|
||||
"""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}
|
||||
Reference in new issue
Block a user