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

+145 -7
View File
@@ -12,11 +12,13 @@ from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import User
from app.deposits.service import (
classify_new_deposits,
credit_confirmed_utxos,
find_new_credit_candidates,
find_reinstate_candidates,
find_utxos_missing_from,
mark_utxos_spent_externally,
read_deposit_policy,
reinstate_reappeared_utxos,
)
from app.config import MIN_QUORUM_RESPONSES
@@ -120,6 +122,15 @@ class ElectrumListener:
# B-86: when the last header was accepted (monotonic seconds), None until one is.
self._last_header_at: float | None = None
self.client: ElectrumClient | None = None
# B-82 (DEP-14): per user, the deposits seen at the last refresh that are confirmed
# but still short of their threshold — what the displayed balance shows as pending.
# Memory only: it moves no money and is rebuilt by the first refresh after a restart.
self._pending_deposits: dict[int, list[dict]] = {}
# txid -> is it a coinbase (DEP-03). A transaction never changes, so this is safe to
# keep; bounded so a long-lived process does not grow it without limit.
self._coinbase_cache: dict[str, bool] = {}
# Users with a refresh already running because of a new block (see _consume_headers).
self._block_refreshes: set[int] = set()
@property
def current_endpoint(self) -> ElectrumEndpoint | None:
@@ -819,7 +830,9 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
async def corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
async def corroborate_utxo_credit(
self, scripthash: str, txid: str, vout: int, value: int, required_confirmations: int | None = None
) -> bool:
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
way. A candidate external spend could not reduce a balance without a
quorum, but `value` and `height` for a *credit* were taken from the single
@@ -841,8 +854,11 @@ class ElectrumListener:
only credited once the quorum agrees.
"""
# B-82 (ELC-17): the other server must also place the coin deep enough — at a height
# that gives it `required_confirmations` counted on *our* tip, not on its own.
max_height = None if required_confirmations is None else self.tip_height - required_confirmations + 1
return await self._corroborate_unspent_confirmed(
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}"
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}", max_height=max_height
)
async def corroborate_utxo_reinstatement(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
@@ -856,10 +872,11 @@ class ElectrumListener:
)
async def _corroborate_unspent_confirmed(
self, scripthash: str, txid: str, vout: int, value: int, description: str
self, scripthash: str, txid: str, vout: int, value: int, description: str, max_height: int | None = None
) -> bool:
"""Do the other servers list this outpoint as unspent, for the same amount, and
confirmed (height > 0)? Shared by credits (B-59) and reinstatements (B-83)."""
confirmed (height > 0, and no higher than `max_height` when one is given)? Shared by
credits (B-59, B-82) and reinstatements (B-83)."""
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
client = self._client_factory(endpoint)
@@ -873,6 +890,7 @@ class ElectrumListener:
and e.get("tx_pos") == vout
and e.get("value") == value
and (e.get("height") or 0) > 0
and (max_height is None or (e.get("height") or 0) <= max_height)
for e in entries
)
except Exception:
@@ -882,6 +900,59 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda agrees: agrees, description)
async def verify_pool_outpoints(
self, scripthash: str, outpoints: list[tuple[str, int, int]]
) -> dict[tuple[str, int], bool] | None:
"""Which of a round's bet outputs (`(txid, vout, amount)`) are really there, at the
round's pool address, according to the *other* servers (B-82, ELC-27, RND-39).
Every other server lists the address' unspent coins; each answer says, per outpoint,
*present* (listed, same amount, confirmed), *absent* (not listed, or listed with
another amount) or neither (listed but unconfirmed — a block that left the chain
may well come back, which is no reason to throw a bet out). An outpoint is present
or absent when a strict majority of the answers say so. The result maps every
outpoint to True (present) or False (absent); it is None — nothing concluded — when
fewer than the quorum minimum (ELC-14) of servers answered or an outpoint has no
majority either way, so a participant is only ever excluded on the quorum's word and
the caller simply tries again later. With ALLOW_NO_QUORUM=true and no other server
configured the active server answers instead (development only)."""
async def _ask(client: ElectrumClient) -> dict[tuple[str, int], tuple[int, int]] | None:
entries = await client.listunspent(scripthash)
return {(e["tx_hash"], e["tx_pos"]): (e["value"], e.get("height") or 0) for e in entries}
answers = [a for a in await self._ask_other_servers(_ask) if a is not None]
if not self._other_endpoints() and self._allow_no_quorum:
answers = [a for a in [await self._ask_active_server(_ask)] if a is not None]
elif len(answers) < max(self._minimum_other_answers(), 1):
logger.warning(
"could not verify the pool coins at %s: %s answer(s) from the other servers, %s needed",
scripthash,
len(answers),
max(self._minimum_other_answers(), 1),
)
return None
if not answers:
return None
result: dict[tuple[str, int], bool] = {}
for txid, vout, amount in outpoints:
present = absent = 0
for answer in answers:
listed = answer.get((txid, vout))
if listed is None or listed[0] != amount:
absent += 1
elif listed[1] > 0:
present += 1
if present * 2 > len(answers):
result[(txid, vout)] = True
elif absent * 2 > len(answers):
result[(txid, vout)] = False
else:
logger.warning("the servers do not agree whether %s:%s is still on the chain", txid, vout)
return None
return result
# --- Confirmations proven by merkle branch (B-83, TX-22, ELC-26) ---------------------
async def _header_at(self, height: int) -> str | None:
@@ -942,6 +1013,63 @@ class ElectrumListener:
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
# refetch instead of waiting for their next poll.
broadcaster.publish()
self._refresh_waiting_deposits()
def _refresh_waiting_deposits(self) -> None:
"""A deposit that is waiting for confirmations (DEP-14) does not change its address'
status when the next block arrives — the history is the same, only the tip moved — so
no scripthash notification will ever announce that it has matured. Refresh the users
that have one at each new block, one task per user at a time, instead of leaving it to
the 300s sweep (B-82, DEP-09)."""
if self.client is None:
return
scripthash_of = {user_id: scripthash for scripthash, user_id in self._scripthash_to_user.items()}
for user_id, waiting in list(self._pending_deposits.items()):
scripthash = scripthash_of.get(user_id)
if not waiting or scripthash is None or user_id in self._block_refreshes:
continue
self._block_refreshes.add(user_id)
task = asyncio.create_task(self._refresh_after_block(user_id, scripthash))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
async def _refresh_after_block(self, user_id: int, scripthash: str) -> None:
try:
await self.refresh_user(user_id, scripthash)
except Exception:
logger.exception("refreshing user_id=%s after a new block failed", user_id)
finally:
self._block_refreshes.discard(user_id)
def pending_deposit_sats(self, user_id: int) -> int:
"""Total of the user's deposits that are confirmed but short of their threshold
(DEP-12 (b), DEP-14); 0 when there are none or nothing has been refreshed yet."""
return sum(entry["value"] for entry in self._pending_deposits.get(user_id, []))
async def is_coinbase(self, txid: str) -> bool | None:
"""Is `txid` a coinbase transaction (DEP-03)? Read from the raw transaction, which is
accepted only if it hashes to `txid` — so the server cannot misdescribe it — and
whose single input has the null outpoint. None when it could not be fetched or did
not match; the caller then simply asks again at the next refresh."""
cached = self._coinbase_cache.get(txid)
if cached is not None:
return cached
if self.client is None:
return None
try:
raw = await self.client.get_transaction(txid, verbose=False)
tx = Transaction.parse(bytes.fromhex(raw))
except Exception:
logger.warning("could not read transaction %s to tell whether it is a coinbase", txid)
return None
if tx.txid().hex() != txid:
logger.warning("transaction %s: the server returned one that hashes to %s", txid, tx.txid().hex())
return None
result = len(tx.vin) == 1 and tx.vin[0].txid == bytes(32) and tx.vin[0].vout == 0xFFFFFFFF
if len(self._coinbase_cache) >= 10_000:
self._coinbase_cache.clear()
self._coinbase_cache[txid] = result
return result
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
while True:
@@ -960,7 +1088,8 @@ class ElectrumListener:
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
new_entries = await find_new_credit_candidates(session, user_id, entries)
policy = await read_deposit_policy(session, {entry["tx_hash"] for entry in new_entries})
# B-83 (DEP-02): candidates only — nothing is written before the quorum agrees.
reinstate_candidates = await find_reinstate_candidates(session, user_id, entries)
candidates = [
@@ -968,11 +1097,20 @@ class ElectrumListener:
for row in await find_utxos_missing_from(session, user_id, entries)
]
# B-82 (DEP-03, DEP-14): which of the new outputs have reached their threshold; the
# others are remembered as pending deposits (memory only) and shown as such.
credit_candidates, waiting_deposits = await classify_new_deposits(
new_entries, self.tip_height, policy, self.is_coinbase
)
self._pending_deposits[user_id] = waiting_deposits
if not waiting_deposits:
self._pending_deposits.pop(user_id, None)
corroborated_credits = [
entry
for entry in credit_candidates
for entry, required in credit_candidates
if await self.corroborate_utxo_credit(
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"], required_confirmations=required
)
]