Files
plm-lottery/tests/unit/test_deposit_confirmations.py
davideandClaude Sonnet 5.5 54dbffb552 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>
2026-10-16 14:25:41 +02:00

313 lines
14 KiB
Python

"""B-82 (DEP-03, DEP-04, DEP-12, DEP-14, ELC-17, DAT-10, DAT-62, API-31): a deposit is
credited at `deposit_confirmations` (default 6), a coinbase output at its maturity of 120, and
only an output of a platform transaction at 1 — the deposit being the one input a user can
double-spend with a private chain.
The real listener is driven here: a fake active connection, fake "other" servers for the
quorum, real transactions so the coinbase check reads (and hash-checks) an actual raw tx.
"""
import pytest
from embit import script
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy import select
from app.db.models import AuditLog, PendingTransaction, PendingTransactionTxid, RoundConfig, User, UtxoEvent
from app.deposits.service import (
COINBASE_MATURITY,
DepositPolicy,
classify_new_deposits,
find_new_credit_candidates,
)
from app.electrum.listener import ElectrumListener
from app.wallet.balance import compute_pending_balance
from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture)
_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
_OUT = script.Script.from_address(_ADDRESS)
def _plain_tx(seed: int = 1) -> Transaction:
return Transaction(
vin=[TransactionInput(bytes([seed]) * 32, 0)], vout=[TransactionOutput(1_000_000_000, _OUT)]
)
def _coinbase_tx() -> Transaction:
return Transaction(vin=[TransactionInput(bytes(32), 0xFFFFFFFF)], vout=[TransactionOutput(1_000_000_000, _OUT)])
def _entry(tx: Transaction, height: int, value: int = 1_000_000_000) -> dict:
return {"tx_hash": tx.txid().hex(), "tx_pos": 0, "height": height, "value": value}
class _Active:
"""The listener's active connection: the address' coins, and raw transactions by txid."""
def __init__(self, entries, raws: dict[str, str] | None = None):
self.entries = entries
self.raws = raws or {}
self.asked_for: list[str] = []
async def listunspent(self, scripthash):
return self.entries
async def get_transaction(self, txid, verbose=False):
self.asked_for.append(txid)
return self.raws[txid]
class _Other:
def __init__(self, entries):
self._entries = entries
async def connect(self):
pass
async def listunspent(self, scripthash):
return self._entries
async def close(self):
pass
async def _user(session_factory, *, deposit_confirmations: int | None = None) -> int:
async with session_factory() as session:
if deposit_confirmations is not None:
session.add(RoundConfig(fee_address="", deposit_confirmations=deposit_confirmations))
user = User(username="alice", password_hash="x", derivation_index=0, address=_ADDRESS)
session.add(user)
await session.commit()
return user.id
def _listener(session_factory, active: _Active, others_entries, *, tip: int) -> ElectrumListener:
others = {host: _Other(others_entries[host]) for host in ("second.example", "third.example")}
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
listener.client = active
listener.tip_height = tip
return listener
async def _balance(session_factory, user_id: int) -> int:
async with session_factory() as session:
return (await session.get(User, user_id)).cached_balance_sats
def _same(entries):
return {"second.example": entries, "third.example": entries}
# --- the thresholds (DEP-03) -----------------------------------------------------------------------
async def test_a_deposit_is_credited_exactly_at_six_confirmations(session_factory):
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100) # confirmations = tip - 100 + 1
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
at_five = _listener(session_factory, active, _same([entry]), tip=104) # 5 confirmations
await at_five.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
assert at_five.pending_deposit_sats(user_id) == 1_000_000_000 # waiting, and shown as such
at_six = _listener(session_factory, active, _same([entry]), tip=105) # 6 confirmations
await at_six.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 1_000_000_000
assert at_six.pending_deposit_sats(user_id) == 0
async def test_deposit_confirmations_is_read_from_the_configuration(session_factory):
user_id = await _user(session_factory, deposit_confirmations=2)
tx = _plain_tx()
entry = _entry(tx, height=100)
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
await _listener(session_factory, active, _same([entry]), tip=100).refresh_user(user_id, "sh") # 1 conf
assert await _balance(session_factory, user_id) == 0
await _listener(session_factory, active, _same([entry]), tip=101).refresh_user(user_id, "sh") # 2 confs
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations(session_factory):
user_id = await _user(session_factory)
tx = _coinbase_tx()
entry = _entry(tx, height=100)
txid = tx.txid().hex()
active = _Active([entry], {txid: tx.serialize().hex()})
# plenty for a deposit (50 confirmations), not for a coinbase
immature = _listener(session_factory, active, _same([entry]), tip=149)
await immature.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
assert immature.pending_deposit_sats(user_id) == 1_000_000_000
assert txid in active.asked_for # it was recognised as a coinbase from the raw transaction
one_short = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 2)
await one_short.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
mature = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 1)
await mature.refresh_user(user_id, "sh") # exactly 120 confirmations
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_a_coinbase_is_not_mistaken_for_a_plain_deposit_when_the_server_cannot_say(session_factory):
"""If the raw transaction cannot be fetched, or hashes to something else than the txid,
the output waits — a server cannot make an immature coinbase look mature by lying."""
user_id = await _user(session_factory)
tx, other = _coinbase_tx(), _plain_tx(9)
entry = _entry(tx, height=100)
missing = _Active([entry], {}) # KeyError on get_transaction
await _listener(session_factory, missing, _same([entry]), tip=160).refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
swapped = _Active([entry], {tx.txid().hex(): other.serialize().hex()}) # a different transaction
listener = _listener(session_factory, swapped, _same([entry]), tip=160)
assert await listener.is_coinbase(tx.txid().hex()) is None
await listener.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
async def test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation(session_factory):
"""The change of a bet, a withdrawal or a payout: the platform built the transaction, so
nobody can double-spend its inputs — including a version replaced by an RBF bump."""
user_id = await _user(session_factory)
current, replaced = _plain_tx(2), _plain_tx(3)
async with session_factory() as session:
row = PendingTransaction(
kind="bet", user_id=user_id, current_txid=current.txid().hex(), fee_rate_sat_vb=1,
raw_tx_hex=current.serialize().hex(), status="pending",
)
session.add(row)
await session.flush()
session.add(PendingTransactionTxid(pending_transaction_id=row.id, txid=replaced.txid().hex(), raw_hex="00"))
await session.commit()
entries = [_entry(current, height=100), _entry(replaced, height=100)]
active = _Active(entries, {})
await _listener(session_factory, active, _same(entries), tip=100).refresh_user(user_id, "sh") # 1 conf
assert active.asked_for == [] # no coinbase lookup either: a platform output is not a deposit
assert await _balance(session_factory, user_id) == 2_000_000_000
async def test_dust_is_never_a_waiting_deposit(session_factory):
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100, value=293)
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 0 and await _balance(session_factory, user_id) == 0
# --- the quorum counts confirmations on our tip (ELC-17, DEP-04) -------------------------------------
async def test_the_other_servers_must_place_the_coin_deep_enough_too(session_factory):
"""Our active server says 6 confirmations; two others put the same coin one block higher
(5 confirmations on our tip): the credit is withheld, not lost."""
user_id = await _user(session_factory)
tx = _plain_tx()
raws = {tx.txid().hex(): tx.serialize().hex()}
ours = _entry(tx, height=100)
shallower = _entry(tx, height=101)
listener = _listener(session_factory, _Active([ours], raws), _same([shallower]), tip=105)
await listener.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
agreeing = _listener(session_factory, _Active([ours], raws), _same([ours]), tip=105)
await agreeing.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_corroborate_utxo_credit_compares_heights_on_our_tip(session_factory):
listener = _listener(session_factory, _Active([]), _same([{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 5}]), tip=105)
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=6) is True
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=7) is False
# without a requirement only "confirmed" matters, as before
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5) is True
# --- classification and the displayed balance (DEP-12 (b), DEP-14) -----------------------------------
async def test_classify_new_deposits_splits_credit_from_waiting():
async def never(_txid):
raise AssertionError("no coinbase lookup expected")
entries = [
{"tx_hash": "01" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # platform, 1 conf
{"tx_hash": "02" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # deposit, 1 conf: waits
{"tx_hash": "03" * 32, "tx_pos": 0, "height": 96, "value": 1_000}, # deposit, 5 confs: waits
{"tx_hash": "04" * 32, "tx_pos": 0, "height": 500, "value": 1_000}, # not on our chain yet: waits
]
policy = DepositPolicy(deposit_confirmations=6, platform_txids={"01" * 32})
credit, waiting = await classify_new_deposits(entries, 100, policy, never)
assert [(e["tx_hash"], required) for e, required in credit] == [("01" * 32, 1)]
assert [e["tx_hash"] for e in waiting] == ["02" * 32, "03" * 32, "04" * 32]
async def test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not(session_factory):
user_id = await _user(session_factory)
async with session_factory() as session:
user = await session.get(User, user_id)
assert await compute_pending_balance(session, user) == (0, False)
shown, has_pending = await compute_pending_balance(session, user, pending_deposit_sats=7_000)
assert (shown, has_pending) == (7_000, True)
assert user.cached_balance_sats == 0 # DEP-13: never spendable until credited
async def test_a_waiting_deposit_that_vanishes_stops_being_shown(session_factory):
"""DEP-14: the set is replaced at every refresh, so a deposit dropped by a reorganisation
is no longer shown."""
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100)
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 1_000_000_000
listener.client = _Active([]) # gone from the chain
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 0
async def test_new_blocks_refresh_the_users_that_have_a_waiting_deposit(session_factory, monkeypatch):
"""No scripthash notification announces that a deposit matured (its history did not
change), so each block refreshes only the users with a waiting deposit, one task at a time."""
import asyncio
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
listener.client = object()
listener._scripthash_to_user = {"sh1": 1, "sh2": 2}
listener._pending_deposits = {1: [{"value": 5}]}
started: list[int] = []
release = asyncio.Event()
async def fake_refresh(user_id, scripthash):
started.append(user_id)
await release.wait()
monkeypatch.setattr(listener, "refresh_user", fake_refresh)
listener._refresh_waiting_deposits()
listener._refresh_waiting_deposits() # a second block while the first refresh is still running
await asyncio.sleep(0)
assert started == [1] # user 2 has nothing waiting; user 1 is not refreshed twice at once
release.set()
await asyncio.sleep(0.01)
listener._refresh_waiting_deposits()
await asyncio.sleep(0)
assert started == [1, 1]
async def test_find_new_credit_candidates_still_lists_every_new_confirmed_output(session_factory):
"""The read half is unchanged: thresholds are applied by classify_new_deposits, between
the read and the write (no session open across the coinbase lookup, B-18)."""
user_id = await _user(session_factory)
entries = [{"tx_hash": "05" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
async with session_factory() as session:
assert await find_new_credit_candidates(session, user_id, entries) == entries
assert (await session.scalars(select(UtxoEvent))).all() == []
assert (await session.scalars(select(AuditLog))).all() == []