The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
665 lines
34 KiB
Python
665 lines
34 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from embit import script
|
|
from embit.transaction import Transaction
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import OperationalError
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from app.audit.log import write_audit_log
|
|
from app.db.base import begin_immediate
|
|
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User
|
|
from app.electrum.listener import ElectrumListener
|
|
from app.electrum.scripthash import address_to_scripthash
|
|
from app.rounds.config import get_round_config
|
|
from app.rounds.draw import (
|
|
draw_index,
|
|
draw_seed,
|
|
new_server_seed,
|
|
participants_hash,
|
|
server_seed_commitment,
|
|
)
|
|
from app.rounds.events import broadcaster
|
|
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
|
|
from app.wallet.hd import derive_round_pool_key
|
|
from app.wallet.keystore import decrypt_round_seed, encrypt_round_seed
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TICK_INTERVAL_SECONDS = 5
|
|
|
|
# B-26: how long to wait after a payout failure before automatically retrying it.
|
|
# Long enough that a persistently-broken payout (misconfigured fee_address,
|
|
# insufficient pool UTXOs) doesn't re-attempt — and re-write a payout_failed audit
|
|
# entry — every _TICK_INTERVAL_SECONDS; short enough that a transient failure
|
|
# (a dropped Electrum connection, a momentarily-empty pool) self-heals quickly.
|
|
_PAYOUT_RETRY_INTERVAL_SECONDS = 60
|
|
|
|
# B-36: _wait_for_next_block has no timeout of its own — a round can legitimately
|
|
# wait several PLM blocks (120s each) for its draw entropy, and re-waits on a
|
|
# corroboration failure. These only make an already-long wait *observable*, they
|
|
# never cut it short.
|
|
_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60
|
|
_DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
|
|
|
|
|
|
class RoundScheduler:
|
|
"""Background task implementing the round lifecycle (docs/ciclo-di-vita-del-round.md):
|
|
closes the round on its timer (once any in-flight bets have confirmed), draws a
|
|
winner from the next confirmed block, and broadcasts the payout. The next round
|
|
only opens once this one is fully closed (rounds/service.get_active_round)."""
|
|
|
|
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
|
|
self._session_factory = session_factory
|
|
self._listener = listener
|
|
|
|
async def run(self) -> None:
|
|
while True:
|
|
try:
|
|
await self._tick()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("round scheduler tick failed")
|
|
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
|
|
|
async def _tick(self) -> None:
|
|
if self._listener.client is None:
|
|
return
|
|
|
|
async with self._session_factory() as session:
|
|
# B-88: every state transition below starts with BEGIN IMMEDIATE (DAT-74).
|
|
# A busy database raises out of the tick, which run() logs and retries at
|
|
# the next one — nothing has been written yet.
|
|
await begin_immediate(session)
|
|
round_ = await open_new_round_if_needed(session)
|
|
await session.commit()
|
|
if round_ is None:
|
|
return # still in the cooldown window after the last round closed
|
|
round_id, status = round_.id, round_.status
|
|
# B-61: this round's own snapshotted deadline, not one recomputed from
|
|
# whatever the config says now — an operator lowering the duration
|
|
# mid-round used to close the round on the spot.
|
|
deadline = round_deadline(round_)
|
|
|
|
if status == "paying_out":
|
|
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
|
|
# any failure after that (no Electrum client, insufficient pool UTXOs, a
|
|
# rejected broadcast) or a process restart while paying_out left the round
|
|
# wedged here forever. Every tick now re-checks and retries, throttled by
|
|
# _retry_payout_if_due so a persistent failure doesn't retry on every tick.
|
|
await self._retry_payout_if_due(round_id)
|
|
return
|
|
|
|
if status not in ("open", "closing"):
|
|
return # "drawing" — progress happens inside the in-flight _close_and_draw call
|
|
|
|
if status == "open":
|
|
if datetime.now(timezone.utc) < deadline:
|
|
return
|
|
|
|
async with self._session_factory() as session:
|
|
await begin_immediate(session) # B-88
|
|
round_ = await session.get(Round, round_id)
|
|
# No new bets from here on, regardless of how long the pending-bet
|
|
# wait below takes — flip to "closing" immediately so it's observable
|
|
# via /rounds/current (e.g. "round closed, waiting for jackpot
|
|
# confirmation") instead of silently staying "open" past the deadline.
|
|
round_.status = "closing"
|
|
round_.closed_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
broadcaster.publish()
|
|
|
|
async with self._session_factory() as session:
|
|
# "building" counts as in-flight too: it's a bet mid-broadcast (see
|
|
# bets/service.py's two-phase write). A bet that never confirms is
|
|
# eventually removed by app/tx/reconcile.py, which is what stops this
|
|
# wait from being unbounded.
|
|
pending_count = await session.scalar(
|
|
select(func.count())
|
|
.select_from(RoundParticipant)
|
|
.where(
|
|
RoundParticipant.round_id == round_id,
|
|
RoundParticipant.status.in_(("building", "broadcast")),
|
|
)
|
|
)
|
|
if pending_count:
|
|
return # wait for in-flight bets to confirm before closing; stays "closing"
|
|
|
|
await self._close_and_draw(round_id)
|
|
|
|
async def _close_and_draw(self, round_id: int) -> None:
|
|
"""Close a `closing` round once nothing is in flight: either close it (no confirmed
|
|
participant) or commit to the draw — fix the participant list, its fingerprint and the
|
|
draw height, all in one transaction, and enter `drawing` (RND-13, RND-14, RND-33,
|
|
DAT-65) — then run the draw itself (`_complete_draw`)."""
|
|
async with self._session_factory() as session:
|
|
await begin_immediate(session) # B-88
|
|
round_ = await session.get(Round, round_id)
|
|
|
|
# B-53: re-check for in-flight bets in the *same* session that snapshots
|
|
# the participants. _tick's check ran in a session of its own, so a bet
|
|
# committing its "building" row in between was counted by neither: the
|
|
# round drew and paid out without it, while its sats still landed in the
|
|
# pool. place_bet's compare-and-set on the round row is what makes that
|
|
# window unreachable; this is the cheap second lock on the same door, and
|
|
# it fails safe — the round stays "closing" and the next tick retries.
|
|
pending_count = await session.scalar(
|
|
select(func.count())
|
|
.select_from(RoundParticipant)
|
|
.where(
|
|
RoundParticipant.round_id == round_id,
|
|
RoundParticipant.status.in_(("building", "broadcast")),
|
|
)
|
|
)
|
|
if pending_count:
|
|
logger.info(
|
|
"round %s: %s bet(s) still in flight at close time, waiting", round_id, pending_count
|
|
)
|
|
return
|
|
|
|
# B-73 (RND-14, DAT-66): the draw order is the bet txid, ascending — a value the
|
|
# public can read off the chain, with no ties (two bets never share a txid). The
|
|
# server-only `broadcast_at` it replaces could not be checked by anyone.
|
|
participants = (
|
|
await session.scalars(
|
|
select(RoundParticipant)
|
|
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
|
|
.order_by(RoundParticipant.bet_txid)
|
|
)
|
|
).all()
|
|
|
|
if not participants:
|
|
round_.status = "closed"
|
|
# DAT-64: nothing was drawn, so the secret has nothing left to protect.
|
|
if round_.server_seed_enc and not round_.server_seed:
|
|
round_.server_seed = decrypt_round_seed(round_.server_seed_enc)
|
|
await write_audit_log(session, "round_closed", {"participants": 0}, round_id=round_id)
|
|
await session.commit()
|
|
broadcaster.publish()
|
|
logger.info("round %s closed with no participants", round_id)
|
|
return
|
|
|
|
# B-73 (RND-19, RND-33): draw_height is "the height we knew at closing, plus one", so
|
|
# an unknown height (0) cannot be a base — the round stays "closing" and this is
|
|
# tried again at the next step, with one audit row per round.
|
|
tip_at_close = self._listener.tip_height
|
|
if tip_at_close <= 0:
|
|
already_written = await session.scalar(
|
|
select(func.count())
|
|
.select_from(AuditLog)
|
|
.where(AuditLog.event_type == "draw_baseline_tip_unknown", AuditLog.round_id == round_id)
|
|
)
|
|
if not already_written:
|
|
logger.warning("round %s: chain tip unknown at closing time, not entering drawing yet", round_id)
|
|
await write_audit_log(session, "draw_baseline_tip_unknown", {}, round_id=round_id)
|
|
await session.commit()
|
|
return
|
|
|
|
users = {
|
|
user.id: user
|
|
for user in (
|
|
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
|
|
).all()
|
|
}
|
|
if not round_.server_seed_enc:
|
|
# A round opened before B-73 has no commitment. Give it a secret now: it still
|
|
# cannot be known to anyone who could steer the block, because the list and the
|
|
# height are fixed below before that block exists — only the early publication
|
|
# of the commitment is missing for this one round.
|
|
seed = new_server_seed()
|
|
round_.server_seed_hash = server_seed_commitment(seed)
|
|
round_.server_seed_enc = encrypt_round_seed(seed)
|
|
for position, participant in enumerate(participants):
|
|
participant.draw_position = position
|
|
round_.participants_hash = participants_hash(
|
|
[(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
|
|
)
|
|
round_.draw_height = tip_at_close + 1
|
|
round_.pool_amount_sats = sum(p.bet_amount_sats for p in participants)
|
|
round_.status = "drawing"
|
|
round_.drawing_started_at = datetime.now(timezone.utc)
|
|
await write_audit_log(
|
|
session,
|
|
"draw_committed",
|
|
{
|
|
"participants_hash": round_.participants_hash,
|
|
"draw_height": round_.draw_height,
|
|
"participants": len(participants),
|
|
"server_seed_hash": round_.server_seed_hash,
|
|
},
|
|
round_id=round_id,
|
|
)
|
|
await session.commit()
|
|
broadcaster.publish()
|
|
|
|
await self._complete_draw(round_id)
|
|
|
|
async def _complete_draw(self, round_id: int) -> None:
|
|
"""The draw of a round in `drawing`, from nothing but what the database holds (RND-36,
|
|
DAT-65): wait for the block at `draw_height` to be confirmed and agreed on by the
|
|
quorum, compute the winner, store it and reveal the secret in one transaction, then
|
|
start the payout. Idempotent — a round no longer in `drawing`, or with a winner
|
|
already stored, is left alone."""
|
|
async with self._session_factory() as session:
|
|
round_ = await session.get(Round, round_id)
|
|
if round_ is None or round_.status != "drawing" or round_.winner_user_id is not None:
|
|
return
|
|
draw_height = round_.draw_height
|
|
draw_confirmations = round_.draw_confirmations
|
|
committed_hash = round_.participants_hash
|
|
server_seed_enc = round_.server_seed_enc
|
|
server_seed_hash = round_.server_seed_hash
|
|
waiting_since = round_.drawing_started_at.replace(tzinfo=timezone.utc)
|
|
|
|
block_hash = await self._wait_for_draw_block(round_id, draw_height, draw_confirmations, waiting_since)
|
|
|
|
server_seed = decrypt_round_seed(server_seed_enc)
|
|
if server_seed_commitment(server_seed) != server_seed_hash:
|
|
raise RuntimeError(f"round {round_id}: the stored secret does not match its published commitment")
|
|
|
|
async with self._session_factory() as session:
|
|
await begin_immediate(session) # B-88
|
|
round_ = await session.get(Round, round_id)
|
|
if round_.status != "drawing" or round_.winner_user_id is not None:
|
|
return # drawn meanwhile (a second pass over the same round)
|
|
participants = (
|
|
await session.scalars(
|
|
select(RoundParticipant)
|
|
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
|
|
.order_by(RoundParticipant.draw_position)
|
|
)
|
|
).all()
|
|
users = {
|
|
user.id: user
|
|
for user in (
|
|
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
|
|
).all()
|
|
}
|
|
ordered_rows = [(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
|
|
if participants_hash(ordered_rows) != committed_hash:
|
|
raise RuntimeError(f"round {round_id}: the draw list no longer matches its published fingerprint")
|
|
seed = draw_seed(server_seed, block_hash, committed_hash)
|
|
winner = participants[draw_index(seed, len(participants))]
|
|
winner_address = users[winner.user_id].address
|
|
pool_amount = sum(p.bet_amount_sats for p in participants if p.status == "confirmed")
|
|
|
|
round_.draw_block_height = draw_height
|
|
round_.draw_block_hash = block_hash
|
|
round_.seed_int = str(seed)
|
|
round_.winner_user_id = winner.user_id
|
|
round_.pool_amount_sats = pool_amount
|
|
# The reveal (RND-18, DAT-64): the plaintext secret becomes visible in the same
|
|
# transaction that stores the winner, never before.
|
|
round_.server_seed = server_seed
|
|
round_.status = "paying_out"
|
|
await write_audit_log(
|
|
session,
|
|
"winner_drawn",
|
|
{
|
|
"winner_address": winner_address,
|
|
"pool_amount_sats": pool_amount,
|
|
"block_height": draw_height,
|
|
"block_hash": block_hash,
|
|
"participants": len(participants),
|
|
"draw_height": draw_height,
|
|
"participants_hash": committed_hash,
|
|
"server_seed": server_seed,
|
|
"seed": f"{seed:064x}",
|
|
},
|
|
user_id=winner.user_id,
|
|
round_id=round_id,
|
|
)
|
|
await session.commit()
|
|
broadcaster.publish()
|
|
|
|
logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount)
|
|
await self._trigger_payout(round_id)
|
|
|
|
async def _wait_for_draw_block(
|
|
self, round_id: int, draw_height: int, confirmations: int, waiting_since: datetime
|
|
) -> str:
|
|
"""The hash of the block at `draw_height`, once it is safe to seed the draw with it
|
|
(RND-15, RND-16, RND-20; B-73): the chain must have `confirmations` blocks from that
|
|
height (a tip of at least `draw_height + confirmations - 1`), and then the quorum of
|
|
servers must agree on the header *at that exact height* — never on the tip, never on
|
|
a later block. If they do not agree the same height is asked again at the next step
|
|
and `draw_header_corroboration_failed` is written once per distinct hash the active
|
|
server reported, so a disagreement that lasts does not write a row every 5 seconds.
|
|
Whoever produces or relays blocks therefore cannot choose which block seeds the draw:
|
|
withholding or delaying one only delays the draw.
|
|
|
|
The wait has no timeout — the entropy genuinely depends on a future block — but it is
|
|
observable (B-36): progress is logged every _DRAW_PROGRESS_LOG_INTERVAL_SECONDS, and a
|
|
`draw_stalled` row is written when for _DRAW_STALL_THRESHOLD_SECONDS the chain has not
|
|
advanced while the round waits for confirmations, or the quorum has been failing to
|
|
agree — and repeated every threshold for as long as that lasts. The clock restarts
|
|
whenever the tip advances during the confirmation wait, so a chain that is merely
|
|
producing its three blocks at the normal pace never counts as stalled.
|
|
"""
|
|
required_tip = draw_height + confirmations - 1
|
|
now = datetime.now(timezone.utc)
|
|
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
|
stall_since = now
|
|
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
|
last_tip = self._listener.tip_height
|
|
agreeing = False # True once the confirmations are there and only the quorum is awaited
|
|
reported_hashes: set[str | None] = set()
|
|
|
|
while True:
|
|
now = datetime.now(timezone.utc)
|
|
tip = self._listener.tip_height
|
|
if tip >= required_tip:
|
|
if not agreeing:
|
|
agreeing = True
|
|
stall_since = now
|
|
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
|
block_hash, reported = await self._listener.agreed_block_hash(draw_height)
|
|
if block_hash is not None:
|
|
return block_hash
|
|
if reported not in reported_hashes:
|
|
reported_hashes.add(reported)
|
|
logger.error(
|
|
"round %s: block %s header (active server: %s) is not agreed by the other Electrum "
|
|
"servers; asking again for the same height",
|
|
round_id,
|
|
draw_height,
|
|
reported,
|
|
)
|
|
async with self._session_factory() as session:
|
|
await write_audit_log(
|
|
session,
|
|
"draw_header_corroboration_failed",
|
|
{"height": draw_height, "reported_hash": reported},
|
|
round_id=round_id,
|
|
)
|
|
await session.commit()
|
|
elif tip != last_tip:
|
|
last_tip = tip
|
|
stall_since = now
|
|
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
|
|
|
if now >= next_progress_log_at:
|
|
logger.info(
|
|
"round %s: still waiting for block %s (%s confirmation(s) needed, chain at %s, %.0fs since drawing started)",
|
|
round_id,
|
|
draw_height,
|
|
confirmations,
|
|
tip,
|
|
(now - waiting_since).total_seconds(),
|
|
)
|
|
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
|
if now >= next_stall_audit_at:
|
|
async with self._session_factory() as session:
|
|
await write_audit_log(
|
|
session,
|
|
"draw_stalled",
|
|
{
|
|
"draw_height": draw_height,
|
|
"current_tip_height": tip,
|
|
"elapsed_seconds": int((now - stall_since).total_seconds()),
|
|
},
|
|
round_id=round_id,
|
|
)
|
|
await session.commit()
|
|
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
|
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
|
|
|
async def _retry_payout_if_due(self, round_id: int) -> None:
|
|
"""B-26: whether a "paying_out" round is due for another payout attempt.
|
|
|
|
Throttled by the most recent payout_failed audit entry for this round
|
|
(written by _log_payout_failure on every early return in _trigger_payout,
|
|
including ones that used to fail silently) rather than by any new DB state,
|
|
since a failed attempt doesn't necessarily leave a PendingTransaction behind
|
|
(a build failure like a missing fee_address never gets that far). No entry
|
|
yet means this round hasn't failed before — either it's a fresh "paying_out"
|
|
(the very first call already happened from _close_and_draw and hasn't had a
|
|
chance to fail yet) or the process restarted before ever recording one —
|
|
either way it's due immediately.
|
|
"""
|
|
async with self._session_factory() as session:
|
|
last_failure_at = await session.scalar(
|
|
select(AuditLog.created_at)
|
|
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
|
|
.order_by(AuditLog.id.desc())
|
|
.limit(1)
|
|
)
|
|
if last_failure_at is not None:
|
|
last_failure_at = last_failure_at.replace(tzinfo=timezone.utc)
|
|
if datetime.now(timezone.utc) < last_failure_at + timedelta(seconds=_PAYOUT_RETRY_INTERVAL_SECONDS):
|
|
return # too soon — avoid hammering a persistently-broken payout
|
|
await self._trigger_payout(round_id)
|
|
|
|
async def _trigger_payout(self, round_id: int) -> None:
|
|
"""Four phases, so no DB session is held across a network call (B-18): read
|
|
what's needed, build the tx, persist the intent, then broadcast.
|
|
|
|
The persist happens *before* the broadcast (B-25) — the same two-phase shape
|
|
as place_bet/request_withdrawal (B-08): a crash between building the payout
|
|
and recording it used to leave money on-chain with zero trace in the DB (no
|
|
payout_txid, no PendingTransaction), so a manual retry would have paid the
|
|
winner a second time. Now the worst case is a "building" PendingTransaction
|
|
the reconciler (app/tx/reconcile.py) can resolve either way by asking the
|
|
chain whether the tx exists, exactly like it already does for bets and
|
|
withdrawals.
|
|
"""
|
|
client = self._listener.client
|
|
if client is None:
|
|
logger.error("round %s payout deferred: not connected", round_id)
|
|
await self._log_payout_failure(round_id, None, "electrum client not connected")
|
|
return
|
|
|
|
# --- Phase 1: read (session closed before any network I/O) ---------------
|
|
async with self._session_factory() as session:
|
|
try:
|
|
await begin_immediate(session) # B-88
|
|
except OperationalError:
|
|
# Nothing built or sent yet: try again at the next tick.
|
|
logger.warning("round %s payout deferred: database busy", round_id)
|
|
return
|
|
round_ = await session.get(Round, round_id)
|
|
already_in_flight = await session.scalar(
|
|
select(PendingTransaction).where(
|
|
PendingTransaction.round_id == round_id,
|
|
PendingTransaction.kind == "payout",
|
|
PendingTransaction.status.in_(("building", "pending")),
|
|
)
|
|
)
|
|
if already_in_flight is not None:
|
|
# A payout for this round is already building or broadcast — this
|
|
# must not build a second one, or a retry (manual, or a future
|
|
# automatic one) would pay the winner twice. Confirmation/
|
|
# reconciliation already owns resolving that row.
|
|
logger.info(
|
|
"round %s payout already in flight (pending_transaction %s), skipping",
|
|
round_id,
|
|
already_in_flight.id,
|
|
)
|
|
return
|
|
reserved_outpoints = await _reserved_payout_outpoints(session)
|
|
config = await get_round_config(session)
|
|
fee_address = config.fee_address
|
|
fee_rate = config.fee_rate_sat_vb
|
|
pool_amount_sats = round_.pool_amount_sats
|
|
round_pool_address = round_.pool_address # B-80 ("" fails in derive_round_pool_key below)
|
|
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
|
|
await session.commit() # get_round_config may have created the row
|
|
|
|
if not fee_address:
|
|
logger.error(
|
|
"round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id
|
|
)
|
|
await self._log_payout_failure(round_id, winner_user_id, "no fee_address configured")
|
|
return
|
|
if winner_address is None:
|
|
logger.error("round %s payout blocked: winner user %s not found", round_id, winner_user_id)
|
|
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
|
|
|
|
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
|
try:
|
|
# B-80 (RND-22): the round's own key and address — only the coins at this
|
|
# round's pool address are ever read, so no other round's money (and no
|
|
# stray coin sent to another address) can end up among the inputs.
|
|
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
|
|
]
|
|
|
|
built = build_payout_transaction(
|
|
signing_key=pool_key,
|
|
from_script=pool_script_obj,
|
|
utxos=utxos,
|
|
winner_address=winner_address,
|
|
winner_share_sats=winner_sats,
|
|
fee_address=fee_address,
|
|
commission_sats=commission_share,
|
|
change_address=pool_address,
|
|
fee_rate_sat_vb=fee_rate,
|
|
)
|
|
except InsufficientFundsError as exc:
|
|
# Includes the "too_many_inputs" case: the pool holds enough, but spread over
|
|
# more UTXOs than one transaction may spend, so /admin has to say which. Since
|
|
# B-52 that means MAX_PAYOUT_TX_INPUTS, and participants are capped below it at
|
|
# bet time (bets/service.py), so reaching it now takes pool change accumulated
|
|
# over many rounds rather than one busy round — an operator consolidation job,
|
|
# not a dead end for the bets of the round in progress.
|
|
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
|
|
logger.exception("round %s payout failed: %s", round_id, reason)
|
|
await self._log_payout_failure(round_id, winner_user_id, reason)
|
|
return
|
|
except Exception:
|
|
# Anything else — a malformed fee_address (EmbitError) or similar. This
|
|
# used to escape all the way to run()'s catch-all, which logged it
|
|
# without recording anything, leaving no trace of *why* the round was
|
|
# stuck (B-05). _retry_payout_if_due (B-26) is what turns this recorded
|
|
# failure into an automatic retry instead of a dead end.
|
|
logger.exception("round %s payout build failed", round_id)
|
|
await self._log_payout_failure(round_id, winner_user_id, "payout build failed")
|
|
return
|
|
|
|
# --- Phase 3: persist the intent, *then* broadcast (B-25) -----------------
|
|
async with self._session_factory() as session:
|
|
try:
|
|
await begin_immediate(session) # B-88
|
|
except OperationalError:
|
|
# Not written, not broadcast: the payout is simply rebuilt on the retry.
|
|
logger.warning("round %s payout deferred before broadcast: database busy", round_id)
|
|
await self._log_payout_failure(round_id, winner_user_id, "database busy")
|
|
return
|
|
round_ = await session.get(Round, round_id)
|
|
round_.winner_amount_sats = built.winner_sats
|
|
round_.fee_amount_sats = built.commission_sats
|
|
round_.payout_txid = built.txid
|
|
pending = PendingTransaction(
|
|
kind="payout",
|
|
round_id=round_id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=fee_rate,
|
|
raw_tx_hex=built.raw_hex,
|
|
# "building" until the broadcast succeeds, exactly like place_bet's
|
|
# two phases — see the reconciler, which gives this a short grace
|
|
# period before asking the chain whether it made it out after all.
|
|
status="building",
|
|
)
|
|
session.add(pending)
|
|
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version
|
|
await session.commit()
|
|
pending_id = pending.id
|
|
|
|
# --- Phase 4: broadcast, then promote the pending row --------------------
|
|
# B-76 (TX-05): the same three outcomes as a bet's broadcast. Accepted — the
|
|
# server took it, or refused with "already known" / a server of the network
|
|
# holds it — promotes the row; a refusal and an uncertain outcome both leave it
|
|
# "building" with a payout_failed entry, since which of the two it really was
|
|
# is the reconciler's call, made against the chain (TX-16, TX-17).
|
|
verdict = await broadcast_with_verdict(client, self._listener, built.raw_hex, built.txid)
|
|
if verdict.outcome is not BroadcastOutcome.ACCEPTED:
|
|
logger.error("round %s payout broadcast not accepted (%s): %s", round_id, verdict.outcome.value, verdict.reason)
|
|
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
|
|
return
|
|
|
|
async with self._session_factory() as session:
|
|
try:
|
|
await begin_immediate(session) # B-88
|
|
except OperationalError:
|
|
# The tx is on the network; the row stays "building" and the
|
|
# reconciler finds it on the chain and promotes it (TX-28).
|
|
logger.error(
|
|
"round %s payout %s broadcast but the database is busy: left 'building'",
|
|
round_id,
|
|
built.txid,
|
|
)
|
|
return
|
|
pending = await session.get(PendingTransaction, pending_id)
|
|
pending.status = "pending"
|
|
await write_audit_log(
|
|
session,
|
|
"payout_sent",
|
|
{"txid": built.txid, "winner_sats": built.winner_sats, "commission_sats": built.commission_sats},
|
|
user_id=winner_user_id,
|
|
round_id=round_id,
|
|
)
|
|
await session.commit()
|
|
|
|
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
|
|
|
|
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
|
|
early return in _trigger_payout (B-26), not just the generic exception
|
|
branch as before, so _retry_payout_if_due always has an entry to throttle
|
|
against and /admin always shows *why* a round is stuck rather than just
|
|
that it is."""
|
|
try:
|
|
async with self._session_factory() as session:
|
|
await write_audit_log(
|
|
session,
|
|
"payout_failed",
|
|
{"round_id": round_id, "reason": reason},
|
|
user_id=winner_user_id,
|
|
round_id=round_id,
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
logger.exception("could not record the payout failure of round %s", round_id)
|
|
|
|
|
|
async def _reserved_payout_outpoints(session: AsyncSession) -> set[tuple[str, int]]:
|
|
"""Pool UTXOs already claimed by a payout that hasn't resolved yet — this
|
|
round's own in-flight payout (guarded against separately in _trigger_payout) or
|
|
a stale one from an earlier round the reconciler hasn't abandoned yet (B-25).
|
|
These must be excluded from selection, or a retry would double-spend the same
|
|
coins into two payouts before the reconciler gets a chance to release them."""
|
|
rows = (
|
|
await session.scalars(
|
|
select(PendingTransaction).where(
|
|
PendingTransaction.kind == "payout",
|
|
PendingTransaction.status.in_(("building", "pending")),
|
|
)
|
|
)
|
|
).all()
|
|
reserved: set[tuple[str, int]] = set()
|
|
for row in rows:
|
|
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
|
|
for vin in tx.vin:
|
|
reserved.add((vin.txid.hex(), vin.vout))
|
|
return reserved
|