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:
1 parent
daa85cf4f0
commit
54dbffb552
42 files changed
+1360
-270
No files matched your search
+141
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user