Round opening (B-09). open_new_round_if_needed now handles the IntegrityError from ix_rounds_single_active (previous commit) by rolling back and using the winner's round. Deviation from the plan in BUGS.md, which proposed making the scheduler the only writer: that would mean the first bet after a cooldown couldn't open a round, so both callers stay and a bounded retry was added instead — a conflict where nothing is active yet just means the winner hadn't committed, and a bet must not fail on that timing. get_active_round also logs loudly if it ever sees more than one active round rather than silently picking the newest. The jackpot (B-11). It was participant_count * the *current* bet_amount_sats, which overstated the pool (each stored bet is already net of that bet's network fee) and silently rewrote the advertised jackpot of a round in progress whenever an operator edited the bet amount. It now sums the participants' stored bet_amount_sats. The remaining imprecision — the payout tx's own fee, deducted from the winner's share and unknowable until the payout is built — is documented in the code rather than promised away, since the comment there claimed exactness. Payout (B-05, B-18). _trigger_payout is split into read / build+broadcast / persist, so no DB session is held across a network call (on SQLite that meant holding the write lock for two unbounded round-trips). That restructuring is also what makes the error handling placeable: it now catches Exception around the chain work and writes a payout_failed audit entry, where a malformed fee_address used to raise EmbitError all the way to the scheduler's catch-all, leaving the round stuck in paying_out with nothing recorded about why. Automatic payout retry remains an open gap. The scheduler also counts "building" participants as in-flight when deciding whether a round may close, matching the two-phase bet write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
12 KiB
Python
270 lines
12 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from embit import script
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
|
|
from app.audit.log import write_audit_log
|
|
from app.db.models import 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_winner, header_hex_to_block_hash
|
|
from app.rounds.events import broadcaster
|
|
from app.rounds.service import open_new_round_if_needed
|
|
from app.wallet.hd import derive_pool_key
|
|
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
|
|
|
|
|
|
class RoundScheduler:
|
|
"""Background task implementing flowchart.mmd's DRAW subgraph: 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:
|
|
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, opened_at = round_.id, round_.status, round_.opened_at
|
|
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
|
|
|
|
if status not in ("open", "closing"):
|
|
return # already drawing/paying_out; progress happens elsewhere
|
|
|
|
if status == "open":
|
|
opened_at = opened_at.replace(tzinfo=timezone.utc)
|
|
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds):
|
|
return
|
|
|
|
async with self._session_factory() as session:
|
|
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:
|
|
async with self._session_factory() as session:
|
|
round_ = await session.get(Round, round_id)
|
|
|
|
participants = (
|
|
await session.scalars(
|
|
select(RoundParticipant)
|
|
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
|
|
.order_by(RoundParticipant.broadcast_at)
|
|
)
|
|
).all()
|
|
|
|
if not participants:
|
|
round_.status = "closed"
|
|
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
|
|
|
|
pool_amount = sum(p.bet_amount_sats for p in participants)
|
|
addresses: list[str] = []
|
|
user_by_address: dict[str, int] = {}
|
|
for p in participants:
|
|
user = await session.get(User, p.user_id)
|
|
addresses.append(user.address)
|
|
user_by_address[user.address] = user.id
|
|
|
|
round_.status = "drawing"
|
|
await session.commit()
|
|
broadcaster.publish()
|
|
|
|
tip_at_close = self._listener.tip_height
|
|
block_height, block_hash = await self._wait_for_next_block(tip_at_close)
|
|
winner_address = draw_winner(addresses, block_hash)
|
|
|
|
async with self._session_factory() as session:
|
|
round_ = await session.get(Round, round_id)
|
|
round_.draw_block_height = block_height
|
|
round_.draw_block_hash = block_hash
|
|
round_.seed_int = str(int(block_hash, 16))
|
|
round_.winner_user_id = user_by_address[winner_address]
|
|
round_.pool_amount_sats = pool_amount
|
|
round_.status = "paying_out"
|
|
await write_audit_log(
|
|
session,
|
|
"winner_drawn",
|
|
{
|
|
"winner_address": winner_address,
|
|
"pool_amount_sats": pool_amount,
|
|
"block_height": block_height,
|
|
"block_hash": block_hash,
|
|
"participants": len(addresses),
|
|
},
|
|
user_id=user_by_address[winner_address],
|
|
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_next_block(self, tip_at_close: int) -> tuple[int, str]:
|
|
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
|
|
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
|
return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex)
|
|
|
|
async def _trigger_payout(self, round_id: int) -> None:
|
|
"""Three phases, so no DB session is held across a network call (B-18): read
|
|
what's needed, do the chain work, then persist the outcome."""
|
|
client = self._listener.client
|
|
if client is None:
|
|
logger.error("round %s payout deferred: not connected", round_id)
|
|
return
|
|
|
|
# --- Phase 1: read (session closed before any network I/O) ---------------
|
|
async with self._session_factory() as session:
|
|
round_ = await session.get(Round, round_id)
|
|
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
|
|
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
|
|
)
|
|
return
|
|
if winner_address is None:
|
|
logger.error("round %s payout blocked: winner user %s not found", round_id, winner_user_id)
|
|
return
|
|
|
|
winner_share = pool_amount_sats * 70 // 100
|
|
commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees
|
|
|
|
# --- Phase 2: build and broadcast ----------------------------------------
|
|
try:
|
|
pool_key = derive_pool_key()
|
|
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]
|
|
|
|
built = build_payout_transaction(
|
|
signing_key=pool_key,
|
|
from_script=pool_script_obj,
|
|
utxos=utxos,
|
|
winner_address=winner_address,
|
|
winner_share_sats=winner_share,
|
|
fee_address=fee_address,
|
|
commission_sats=commission_share,
|
|
change_address=pool_address,
|
|
fee_rate_sat_vb=fee_rate,
|
|
)
|
|
await client.broadcast(built.raw_hex)
|
|
except InsufficientFundsError:
|
|
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
|
|
return
|
|
except Exception:
|
|
# Anything else — a malformed fee_address (EmbitError), a rejected
|
|
# broadcast, a dead connection. 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). The round stays in
|
|
# "paying_out" either way: automatic payout retry is still an open gap.
|
|
logger.exception("round %s payout failed", round_id)
|
|
await self._log_payout_failure(round_id, winner_user_id)
|
|
return
|
|
|
|
# --- Phase 3: persist -----------------------------------------------------
|
|
async with self._session_factory() as session:
|
|
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
|
|
session.add(
|
|
PendingTransaction(
|
|
kind="payout",
|
|
round_id=round_id,
|
|
current_txid=built.txid,
|
|
fee_rate_sat_vb=fee_rate,
|
|
raw_tx_hex=built.raw_hex,
|
|
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) -> 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."""
|
|
try:
|
|
async with self._session_factory() as session:
|
|
await write_audit_log(
|
|
session,
|
|
"payout_failed",
|
|
{"round_id": round_id},
|
|
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)
|