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

+9 -1
View File
@@ -3,7 +3,7 @@ import secrets
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
@@ -47,6 +47,7 @@ _CONFIG_FIELDS = (
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
"deposit_confirmations", # B-82 (DAT-62)
"draw_confirmations", # B-73 (DAT-62)
)
@@ -59,6 +60,7 @@ class RoundConfigResponse(BaseModel):
fee_rate_sat_vb: int
rbf_timeout_seconds: int
draw_animation_seconds: int
deposit_confirmations: int # B-82 (DAT-62)
draw_confirmations: int # B-73 (DAT-62)
paused: bool
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
@@ -81,6 +83,7 @@ class RoundConfigUpdate(BaseModel):
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
@field_validator("fee_address")
@@ -186,6 +189,9 @@ async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundC
lifted = config.paused_reason if config.paused else None
config.paused = False
config.paused_reason = None
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
await session.commit()
return _config_response(config)
@@ -287,6 +293,7 @@ class AdminRoundResponse(BaseModel):
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
draw_height: int | None # B-73
bump_reserve_sats: int | None # B-79 (API-36)
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
@@ -318,6 +325,7 @@ async def list_rounds(
server_seed_hash=r.server_seed_hash,
draw_height=r.draw_height,
bump_reserve_sats=r.bump_reserve_sats,
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
)
for r in rounds
]
+7 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,10 +31,15 @@ class MeResponse(BaseModel):
@router.get("/me", response_model=MeResponse)
async def me(
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MeResponse:
pending_balance_sats, has_pending = await compute_pending_balance(session, user)
# B-82 (DEP-12 (b)): deposits confirmed but short of their threshold, kept in memory by
# the listener (absent in an app without one, e.g. a test of this route alone).
listener = getattr(request.app.state, "electrum_listener", None)
waiting_sats = listener.pending_deposit_sats(user.id) if listener is not None else 0
pending_balance_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
return MeResponse(
id=user.id,
username=user.username,
+6
View File
@@ -114,6 +114,9 @@ class Round(Base):
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
# B-82 (DAT-68): set when the verification before the payout finds the *winner's* bet gone
# from the chain; while it is set no payout is built. Cleared by POST /admin/resume.
payout_blocked_at: Mapped[datetime | None] = mapped_column(default=None)
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
@@ -184,6 +187,9 @@ class RoundConfig(Base):
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
# applies from the next round.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
# B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited
# as a deposit (platform transactions keep 1, coinbase outputs 120).
deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6")
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
# Maintenance switch: when true, the round currently in progress still runs to
+85 -1
View File
@@ -1,17 +1,33 @@
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import UtxoEvent
from app.db.models import PendingTransaction, PendingTransactionTxid, RoundConfig, UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
from app.wallet.psbt_builder import DUST_LIMIT_SATS
logger = logging.getLogger(__name__)
# B-82 (DEP-03): confirmations a coinbase output needs before it can be spent, PLM's
# COINBASE_MATURITY (palladiumcore/src/consensus/consensus.h). A coinbase paying a user's
# address is credited no earlier than this, whatever deposit_confirmations says.
COINBASE_MATURITY = 120
# Confirmations an output created by a transaction of the platform itself — the change of
# a bet or a withdrawal, a payout — needs: the platform built it, nobody else could have
# double-spent its inputs (DEP-03).
PLATFORM_TRANSACTION_CONFIRMATIONS = 1
# Default of round_config.deposit_confirmations, used only when the config row does not
# exist yet (get_round_config creates it with the same value).
DEFAULT_DEPOSIT_CONFIRMATIONS = 6
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
"""The subset of `entries` that would actually credit something: confirmed
@@ -41,6 +57,74 @@ async def find_new_credit_candidates(session: AsyncSession, user_id: int, entrie
]
@dataclass
class DepositPolicy:
"""What decides how many confirmations an incoming output needs (DEP-03), read from the
database before any network call: the configured deposit threshold and which of the
transactions involved are the platform's own."""
deposit_confirmations: int = DEFAULT_DEPOSIT_CONFIRMATIONS
platform_txids: set[str] = field(default_factory=set)
async def read_deposit_policy(session: AsyncSession, txids: set[str]) -> DepositPolicy:
"""The deposit threshold and, among `txids`, the ones created by a platform transaction:
any txid of a `pending_transactions` row, replaced RBF versions included (B-77)."""
configured = await session.scalar(select(RoundConfig.deposit_confirmations))
platform: set[str] = set()
if txids:
platform |= set(
(await session.scalars(select(PendingTransactionTxid.txid).where(PendingTransactionTxid.txid.in_(txids))))
)
platform |= set(
(await session.scalars(select(PendingTransaction.current_txid).where(PendingTransaction.current_txid.in_(txids))))
)
return DepositPolicy(
deposit_confirmations=configured if configured else DEFAULT_DEPOSIT_CONFIRMATIONS, platform_txids=platform
)
async def classify_new_deposits(
new_entries: list[dict],
tip_height: int,
policy: DepositPolicy,
is_coinbase: Callable[[str], Awaitable[bool | None]],
) -> tuple[list[tuple[dict, int]], list[dict]]:
"""Split the new, confirmed, non-dust entries of `find_new_credit_candidates` into the
ones that may now be credited — as `(entry, required_confirmations)`, the number the
quorum is then asked to confirm (ELC-17) — and the ones still **waiting** for
confirmations (B-82, DEP-03, DEP-14).
Confirmations are `tip_height - height + 1`. The threshold is 1 for an output of a
platform transaction, 120 (`COINBASE_MATURITY`) for a coinbase output, and
`deposit_confirmations` for anything else; a coinbase is only looked for once the plain
threshold is met, since below it the output waits anyway. `is_coinbase` answers from the
raw transaction (hash-checked by the caller) and may return None when it cannot tell, in
which case the output waits and is asked again at the next refresh. No database session
is involved: this runs between the read and the write phases, where the network is used.
"""
candidates: list[tuple[dict, int]] = []
waiting: list[dict] = []
for entry in new_entries:
confirmations = tip_height - entry["height"] + 1
if entry["tx_hash"] in policy.platform_txids:
required = PLATFORM_TRANSACTION_CONFIRMATIONS
else:
required = policy.deposit_confirmations
if required <= confirmations < COINBASE_MATURITY:
# Plain threshold met, maturity not: only now is it worth knowing whether
# this is a coinbase output (which would still be immature).
coinbase = await is_coinbase(entry["tx_hash"])
if coinbase is None or coinbase:
waiting.append(entry)
continue
if confirmations < required:
waiting.append(entry)
continue
candidates.append((entry, required))
return candidates, waiting
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
`listunspent` response (idempotent on txid+vout), refresh the user's cached
+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
)
]
+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
+2
View File
@@ -85,6 +85,8 @@
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
<label for="admin-deposit-confirmations">Conferme per accreditare un deposito (1–100; le uscite coinbase ne richiedono comunque 120)</label>
<input id="admin-deposit-confirmations" inputmode="numeric" placeholder="es. 6">
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
+2
View File
@@ -151,6 +151,7 @@ async function adminLoadConfig() {
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
document.getElementById('admin-deposit-confirmations').value = data.deposit_confirmations;
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
renderMaintenanceState(data.paused);
@@ -211,6 +212,7 @@ async function adminSave() {
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
deposit_confirmations: parseInt(document.getElementById('admin-deposit-confirmations').value, 10),
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
};
+7 -7
View File
@@ -87,7 +87,7 @@ const TRANSLATIONS = {
'result.lose': 'Not a win this time.',
'deposit.balanceTitle': 'Internal balance',
'deposit.balanceHint': 'Updated after 1 network confirmation',
'deposit.balanceHint': 'Updated once the network has confirmed your deposit (a few blocks)',
'deposit.refreshBtn': 'Refresh',
'deposit.refreshAria': 'Refresh balance',
'deposit.addressTitle': 'Deposit address',
@@ -258,7 +258,7 @@ const TRANSLATIONS = {
'result.lose': 'Non hai vinto questa volta.',
'deposit.balanceTitle': 'Saldo interno',
'deposit.balanceHint': 'Aggiornato dopo 1 conferma sulla rete',
'deposit.balanceHint': 'Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)',
'deposit.refreshBtn': 'Aggiorna',
'deposit.refreshAria': 'Aggiorna saldo',
'deposit.addressTitle': 'Indirizzo di deposito',
@@ -426,7 +426,7 @@ const TRANSLATIONS = {
'result.lose': 'Esta vez no has ganado.',
'deposit.balanceTitle': 'Saldo interno',
'deposit.balanceHint': 'Actualizado tras 1 confirmación en la red',
'deposit.balanceHint': 'Se actualiza cuando la red confirma tu depósito (unos bloques)',
'deposit.refreshBtn': 'Actualizar',
'deposit.refreshAria': 'Actualizar saldo',
'deposit.addressTitle': 'Dirección de depósito',
@@ -594,7 +594,7 @@ const TRANSLATIONS = {
'result.lose': "Pas de gain cette fois-ci.",
'deposit.balanceTitle': 'Solde interne',
'deposit.balanceHint': 'Mis à jour après 1 confirmation sur le réseau',
'deposit.balanceHint': 'Mis à jour une fois le dépôt confirmé par le réseau (quelques blocs)',
'deposit.refreshBtn': 'Actualiser',
'deposit.refreshAria': 'Actualiser le solde',
'deposit.addressTitle': 'Adresse de dépôt',
@@ -762,7 +762,7 @@ const TRANSLATIONS = {
'result.lose': 'Diesmal kein Gewinn.',
'deposit.balanceTitle': 'Internes Guthaben',
'deposit.balanceHint': 'Aktualisiert nach 1 Netzwerkbestätigung',
'deposit.balanceHint': 'Aktualisiert, sobald das Netzwerk die Einzahlung bestätigt hat (einige Blöcke)',
'deposit.refreshBtn': 'Aktualisieren',
'deposit.refreshAria': 'Guthaben aktualisieren',
'deposit.addressTitle': 'Einzahlungsadresse',
@@ -930,7 +930,7 @@ const TRANSLATIONS = {
'result.lose': 'На этот раз без выигрыша.',
'deposit.balanceTitle': 'Внутренний баланс',
'deposit.balanceHint': 'Обновляется после 1 подтверждения в сети',
'deposit.balanceHint': 'Обновляется, когда сеть подтвердит депозит (несколько блоков)',
'deposit.refreshBtn': 'Обновить',
'deposit.refreshAria': 'Обновить баланс',
'deposit.addressTitle': 'Адрес для депозита',
@@ -1098,7 +1098,7 @@ const TRANSLATIONS = {
'result.lose': '这次没有中奖。',
'deposit.balanceTitle': '内部余额',
'deposit.balanceHint': '在网络确认 1 次后更新',
'deposit.balanceHint': '网络确认存款后更新(需要数个区块)',
'deposit.refreshBtn': '刷新',
'deposit.refreshAria': '刷新余额',
'deposit.addressTitle': '存款地址',
+1 -1
View File
@@ -176,7 +176,7 @@
<div class="dash-panel active" id="panel-deposit">
<div class="card">
<h2 data-i18n="deposit.balanceTitle">Saldo interno</h2>
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato dopo 1 conferma sulla rete</p>
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)</p>
<div class="row-between">
<div><span class="balance-value mono" id="dash-balance">—</span> <span class="balance-unit">PLM</span></div>
<button class="secondary" onclick="refreshMe()" id="refresh-btn" data-i18n-aria-label="deposit.refreshAria" aria-label="Aggiorna saldo">
+9 -2
View File
@@ -18,7 +18,9 @@ async def recompute_balance(session: AsyncSession, user_id: int) -> int:
return user.cached_balance_sats
async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[int, bool]:
async def compute_pending_balance(
session: AsyncSession, user: User, pending_deposit_sats: int = 0
) -> tuple[int, bool]:
"""Balance including the user's own change still in flight.
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
@@ -47,6 +49,11 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
the same sats are already inside cached_balance_sats — double-counting the
change unless excluded below.
B-82 (DEP-12 (b), DEP-14): `pending_deposit_sats` is the total of the user's deposits
that are confirmed but still short of their threshold (the listener keeps them, in
memory); they are added to what is shown, and make `has_pending` true, exactly like
change in flight — without ever being spendable.
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
caller whether this differs from the confirmed-only balance at all.
"""
@@ -79,4 +86,4 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
pending_change_sats += out.value
return user.cached_balance_sats + pending_change_sats, bool(pending)
return user.cached_balance_sats + pending_change_sats + pending_deposit_sats, bool(pending) or pending_deposit_sats > 0
+4 -1
View File
@@ -108,7 +108,10 @@ async def request_withdrawal(
# "insufficient". Distinguish "you don't have the money" from "your money
# hasn't confirmed yet" so the error doesn't contradict what the user is
# looking at on screen.
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user)
# B-82 (DEP-13): the displayed balance includes deposits still short of their
# threshold, so a withdrawal they would cover gets "wait for the confirmation".
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
if has_pending and pending_inclusive_sats >= amount_sats:
raise WithdrawalError(
"balance_pending_confirmation",