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:
davideandClaude Sonnet 5.5 committed 2026-10-16 14:25:41 +02:00
1 parent daa85cf4f0
commit 54dbffb552
42 files changed
+1360 -270

No files matched your search

+319
View File
@@ -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}