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

+16
View File
@@ -19,6 +19,12 @@ class FakeNetwork:
self.asked: list[str] = []
self.fanned_out: list[str] = []
self.proofs_asked: list[tuple[str, int]] = []
# B-82: what `verify_pool_outpoints` answers. Every bet output is present unless listed
# in `pool_absent`; `pool_unavailable` models a quorum that could not be reached.
self.pool_absent: set[tuple[str, int]] = set()
self.pool_unavailable = False
self.verified_scripthashes: list[str] = []
self.verified_outpoints: list[list[tuple[str, int, int]]] = []
async def transaction_known(self, txid: str) -> bool | None:
self.asked.append(txid)
@@ -31,6 +37,16 @@ class FakeNetwork:
self.proofs_asked.append((txid, height))
return self.proven
async def verify_pool_outpoints(self, scripthash, outpoints):
self.verified_scripthashes.append(scripthash)
self.verified_outpoints.append(list(outpoints))
hook = getattr(self, "on_verify", None)
if hook is not None:
await hook()
if self.pool_unavailable:
return None
return {(txid, vout): (txid, vout) not in self.pool_absent for txid, vout, _amount in outpoints}
MISSING_INPUTS = "bad-txns-inputs-missingorspent"
+46
View File
@@ -265,6 +265,8 @@ async def test_config_update_without_changes_logs_nothing(client):
{"rbf_timeout_seconds": 1},
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
{"draw_confirmations": 21},
{"deposit_confirmations": 0}, # B-82: a deposit credited with no confirmation at all
{"deposit_confirmations": 101},
],
)
async def test_config_rejects_unusable_values(client, payload):
@@ -466,3 +468,47 @@ async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client)
assert resp.status_code == 200
# an unrelated edit is never blocked by the payability check
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
async def test_deposit_confirmations_is_configurable_between_1_and_100(client): # B-82 (DAT-62, API-31)
headers = {"X-Admin-Token": "test-admin-token"}
assert (await client.get("/admin/config", headers=headers)).json()["deposit_confirmations"] == 6
for value in (1, 100, 12):
resp = await client.put("/admin/config", headers=headers, json={"deposit_confirmations": value})
assert resp.status_code == 200 and resp.json()["deposit_confirmations"] == value
audit = (await client.get("/admin/audit-log", headers=headers)).json()
changes = [e["payload"]["deposit_confirmations"] for e in audit if e["event_type"] == "config_updated"]
assert {"from": 100, "to": 12} in changes
async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain(client): # B-82 (DAT-68, API-32)
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import base as db_base
from app.db.models import AuditLog, Round
headers = {"X-Admin-Token": "test-admin-token"}
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
await conn.run_sync(db_base.Base.metadata.create_all)
async with db_base.AsyncSessionLocal() as session:
session.add(Round(status="paying_out", payout_blocked_at=datetime.now(timezone.utc)))
session.add(Round(status="closed"))
await session.commit()
async with db_base.AsyncSessionLocal() as session:
from app.rounds.pause import pause_automatically
await pause_automatically(session, "payout_blocked")
await session.commit()
assert (await client.get("/admin/config", headers=headers)).json()["paused_reason"] == "payout_blocked"
listed = (await client.get("/admin/rounds", headers=headers)).json() # API-36: the mark is visible
assert sorted(r["payout_blocked_at"] is not None for r in listed) == [False, True]
resp = await client.post("/admin/resume", headers=headers)
assert resp.status_code == 200 and resp.json()["paused"] is False and resp.json()["paused_reason"] is None
async with db_base.AsyncSessionLocal() as session:
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
assert resumed and '"payout_blocked"' in resumed[0].payload_json
+1
View File
@@ -366,6 +366,7 @@ async def _flagged_user(session_factory) -> int:
def _reinstate_listener(session_factory, others: dict[str, _UnspentServer], active_entries):
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: far past every credit threshold, coinbase maturity included
listener.client = _ActiveClient(active_entries)
return listener
+312
View File
@@ -0,0 +1,312 @@
"""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() == []
+4
View File
@@ -405,6 +405,7 @@ async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(sessio
}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
await listener.refresh_user(user_id, "scripthash")
@@ -431,6 +432,7 @@ async def test_refresh_user_does_not_mark_when_corroboration_fails(session_facto
{"first.example": [], "second.example": still_there, "third.example": still_there}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(_UNRELATED_ENTRY)
await listener.refresh_user(user_id, "scripthash")
@@ -719,6 +721,7 @@ async def test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports(s
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
# Our own connection reports the tracked UTXO plus a phantom one nobody else has.
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
)
@@ -742,6 +745,7 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory)
{"first.example": [], "second.example": _PHANTOM, "third.example": _PHANTOM}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
)
+10
View File
@@ -203,3 +203,13 @@ def test_the_bump_reserve_column_exists_at_head_and_is_nullable(migrated_db): #
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
assert "bump_reserve_sats" in rounds and rounds["bump_reserve_sats"][3] == 0
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
def test_deposit_confirmations_and_payout_blocked_columns_exist_at_head(migrated_db): # B-82 (DAT-62, DAT-68)
with sqlite3.connect(migrated_db) as conn:
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
assert config["deposit_confirmations"][3] == 1 and config["deposit_confirmations"][4] in ("6", "'6'")
assert "payout_blocked_at" in rounds and rounds["payout_blocked_at"][3] == 0
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
assert _index_sql(migrated_db, "ix_users_username_lower") is not None
+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}
+5 -1
View File
@@ -183,7 +183,11 @@ async def _payout(payout_session_factory, pool_address: str, round_id: int = 1):
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)
listener = FakePayoutListener(client)
await RoundScheduler(payout_session_factory, listener)._trigger_payout(round_id)
# B-82: a round with its own address asks the quorum (the listener), not the active
# connection, which is only asked for a round paid from the shared reserve address.
client.scripthashes = client.scripthashes + listener.verified_scripthashes
return client
+32 -12
View File
@@ -120,7 +120,15 @@ async def payout_session_factory(tmp_path, monkeypatch):
hd._account_key = None
async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
def _payout_bet_txid(index: int) -> str:
return f"{index + 0x33:064x}"
async def _seed_paying_out_round(session_factory, round_id: int = 1, bets: list[int] | None = None) -> int:
"""A round in `paying_out` whose confirmed participants — the first one is the winner —
each placed a bet of the given net amount (B-82: the payout spends exactly those bet
outputs, `bet_txid:0`, so the pool is their sum)."""
bets = bets if bets is not None else [_POOL_AMOUNT_SATS]
async with session_factory() as session:
winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS)
session.add(winner)
@@ -130,11 +138,27 @@ async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
Round(
id=round_id,
status="paying_out",
pool_amount_sats=_POOL_AMOUNT_SATS,
pool_amount_sats=sum(bets),
winner_user_id=winner.id,
pool_address=derive_pool_address(round_id), # B-80
)
)
await session.flush()
for index, amount in enumerate(bets):
user = winner
if index:
user = User(
username=f"bettor{index}", password_hash="x", derivation_index=index,
address=derive_pool_address(7000 + index),
)
session.add(user)
await session.flush()
session.add(
RoundParticipant(
round_id=round_id, user_id=user.id, bet_amount_sats=amount, bet_txid=_payout_bet_txid(index),
bet_vout=0, status="confirmed", draw_position=index,
)
)
await session.commit()
return winner.id
@@ -206,18 +230,14 @@ async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_in
so a round past MAX_TX_INPUTS participants could not be paid at all — the build
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
and no new round could ever open behind it. It must now broadcast normally."""
await _seed_paying_out_round(payout_session_factory)
participants = MAX_TX_INPUTS + 1
bet_sats = _POOL_AMOUNT_SATS // participants
entries = [
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
for i in range(participants)
]
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
# on-chain: integer division above leaves a remainder, so top the last one up.
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
client = FakePayoutClient(entries)
bets = [bet_sats] * participants
# The pool's total is the round's recorded pool_amount_sats: integer division above
# leaves a remainder, so top the last one up.
bets[-1] += _POOL_AMOUNT_SATS - bet_sats * participants
await _seed_paying_out_round(payout_session_factory, bets=bets)
client = FakePayoutClient([])
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
+5 -1
View File
@@ -671,7 +671,11 @@ def _payout_scheduler(session_factory, client):
from app.rounds.scheduler import RoundScheduler
from tests.unit.test_scheduler import FakePayoutListener
return RoundScheduler(session_factory, FakePayoutListener(client))
listener = FakePayoutListener(client)
# B-82: the network phase of a payout is the quorum's verification of the round's coins,
# so that is where a test injects "the lock is lost while the network is being used".
listener.on_verify = getattr(client, "_hook_listunspent", None)
return RoundScheduler(session_factory, listener)
class _PayoutClient:
+13
View File
@@ -176,3 +176,16 @@ async def test_me_created_at_is_utc_stamped(client):
assert resp.status_code == 200
created_at = resp.json()["created_at"]
assert created_at.endswith("+00:00") or created_at.endswith("Z")
async def test_me_shows_waiting_deposits_in_the_pending_balance_only(client): # B-82 (DEP-12 (b), DEP-14)
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
me = (await client.get("/users/me", headers=headers)).json()
assert (me["balance_sats"], me["pending_balance_sats"], me["has_pending"]) == (0, 0, False)
listener = client._transport.app.state.electrum_listener
listener._pending_deposits[me["id"]] = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 5, "value": 250_000_000}]
shown = (await client.get("/users/me", headers=headers)).json()
assert shown["balance_sats"] == 0 # not spendable
assert shown["pending_balance_sats"] == 250_000_000 and shown["has_pending"] is True