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

+141 -8
View File
@@ -22,6 +22,7 @@ from app.rounds.draw import (
server_seed_commitment,
)
from app.rounds.events import broadcaster
from app.rounds.pause import pause_automatically
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.tx.versions import record_version
@@ -437,6 +438,8 @@ class RoundScheduler:
either way it's due immediately.
"""
async with self._session_factory() as session:
if await session.scalar(select(Round.payout_blocked_at).where(Round.id == round_id)) is not None:
return # B-82 (DAT-68): held until an operator resumes the lottery
last_failure_at = await session.scalar(
select(AuditLog.created_at)
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
@@ -477,6 +480,11 @@ class RoundScheduler:
logger.warning("round %s payout deferred: database busy", round_id)
return
round_ = await session.get(Round, round_id)
if round_.payout_blocked_at is not None:
# B-82 (DAT-68): held since the winner's own bet left the chain. Nothing is built
# until an operator resumes the lottery, which clears the mark (and the next
# attempt verifies the coins again).
return
already_in_flight = await session.scalar(
select(PendingTransaction).where(
PendingTransaction.round_id == round_id,
@@ -504,6 +512,18 @@ class RoundScheduler:
winner_user_id = round_.winner_user_id
winner = await session.get(User, winner_user_id)
winner_address = winner.address if winner is not None else None
# B-82 (RND-22, RND-39): the bets the payout may spend and must first verify — the
# round's confirmed participants, each with the output that pays the pool.
bet_rows = [
(p.id, p.user_id, p.bet_txid, p.bet_vout, p.bet_amount_sats)
for p in (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
.order_by(RoundParticipant.draw_position)
)
).all()
]
await session.commit() # get_round_config may have created the row
if not fee_address:
@@ -517,8 +537,10 @@ class RoundScheduler:
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
return
winner_sats = winner_share(pool_amount_sats)
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
# A round opened before the per-round pool address (B-80) pays from the shared reserve
# address, whose coins are not the bets of one round: it keeps the old way of choosing
# them. Every other round is verified and paid from its own bets (RND-22, RND-39).
legacy_pool = round_pool_address == derive_pool_address()
# --- Phase 2: build (network read only, no DB write yet) -----------------
try:
@@ -528,12 +550,33 @@ class RoundScheduler:
pool_key = derive_round_pool_key(round_id, round_pool_address)
pool_script_obj = script.p2wpkh(pool_key.to_public())
pool_address = pool_script_obj.address(network=PLM_MAINNET)
entries = await client.listunspent(address_to_scripthash(pool_address))
utxos = [
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
for e in entries
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
]
if not legacy_pool:
# B-82 (RND-39): with the quorum, before anything is built — bets that left the
# chain drop out and the split is computed on what is really there.
verified = await self._verify_round_pool(
round_id, pool_address, winner_user_id, bet_rows, pool_amount_sats
)
if verified is None:
return # held, unverifiable or database busy — already recorded
bet_rows, pool_amount_sats = verified
winner_sats = winner_share(pool_amount_sats)
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
if legacy_pool:
entries = await client.listunspent(address_to_scripthash(pool_address))
utxos = [
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
for e in entries
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
]
else:
# B-82 (RND-22): exactly the verified bet outputs of this round, nothing else
# that happens to sit at the address, and none already claimed by an unresolved
# payout.
utxos = [
Utxo(txid, vout, amount)
for _id, _user_id, txid, vout, amount in bet_rows
if (txid, vout) not in reserved_outpoints
]
built = build_payout_transaction(
signing_key=pool_key,
@@ -634,6 +677,96 @@ class RoundScheduler:
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
async def _verify_round_pool(
self,
round_id: int,
pool_address: str,
winner_user_id: int | None,
bet_rows: list[tuple[int, int, str, int, int]],
pool_amount_sats: int | None,
) -> tuple[list[tuple[int, int, str, int, int]], int] | None:
"""B-82 (RND-39, DAT-67, DAT-68, ELC-27): before a payout is built, check with the quorum
that each confirmed participant's bet output is still at the round's pool address.
Returns the participants that are really there with the pool amount recomputed on them
(the same pair when nothing was missing), or None when this attempt must stop: the
quorum could not be reached (`payout_failed`, `pool verification unavailable`), the
database was busy, or the **winner's own bet is gone** — then the payout is held
(`payout_blocked`, the lottery pauses) and only an operator's resume tries again.
Participants whose bet is gone become `reorged_out` and leave the pool, one
`participant_reorged_out` row each, in one transaction with the rewritten
`pool_amount_sats` (which can only go down). The winner's own row is left `confirmed`
in the held case, precisely so that the verification repeats after the resume."""
verdicts = await self._listener.verify_pool_outpoints(
address_to_scripthash(pool_address), [(txid, vout, amount) for _i, _u, txid, vout, amount in bet_rows]
)
if verdicts is None:
logger.error("round %s payout deferred: the pool coins could not be verified with the quorum", round_id)
await self._log_payout_failure(round_id, winner_user_id, "pool verification unavailable")
return None
gone = [row for row in bet_rows if not verdicts[(row[2], row[3])]]
if not gone:
return bet_rows, pool_amount_sats
winner_gone = next((row for row in gone if row[1] == winner_user_id), None)
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
logger.warning("round %s payout deferred: database busy while excluding reorged-out bets", round_id)
return None
round_ = await session.get(Round, round_id)
excluded = [row for row in gone if row is not winner_gone]
for participant_id, user_id, txid, vout, amount in excluded:
participant = await session.get(RoundParticipant, participant_id)
if participant is None or participant.status != "confirmed":
continue
participant.status = "reorged_out"
await session.flush()
remaining = (
await session.scalars(
select(RoundParticipant).where(
RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed"
)
)
).all()
new_pool = sum(p.bet_amount_sats for p in remaining)
if pool_amount_sats is not None:
new_pool = min(new_pool, pool_amount_sats) # DAT-67: only ever reduced
round_.pool_amount_sats = new_pool
for _participant_id, user_id, txid, vout, amount in excluded:
await write_audit_log(
session,
"participant_reorged_out",
{"txid": txid, "vout": vout, "amount_sats": amount, "pool_amount_sats": new_pool},
user_id=user_id,
round_id=round_id,
)
if winner_gone is not None:
_pid, user_id, txid, vout, amount = winner_gone
round_.payout_blocked_at = datetime.now(timezone.utc)
await write_audit_log(
session,
"payout_blocked",
{"txid": txid, "vout": vout, "amount_sats": amount},
user_id=user_id,
round_id=round_id,
)
logger.error(
"round %s payout held: the winner's bet %s:%s is no longer on the chain; lottery paused",
round_id,
txid,
vout,
)
await pause_automatically(session, "payout_blocked") # same transaction (AUD-44)
await session.commit()
broadcaster.publish()
if winner_gone is not None:
return None
gone_ids = {row[0] for row in gone}
return [row for row in bet_rows if row[0] not in gone_ids], new_pool
async def _log_payout_failure(self, round_id: int, winner_user_id: int | None, reason: str) -> None:
"""Leaves an operator-visible trace in the audit log for a round stuck in
"paying_out" — the logs alone don't show up in /admin. Called from every