import asyncio import logging from collections.abc import Awaitable, Callable from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.db.models import PendingTransaction from app.electrum.client import ElectrumClient from app.rounds.events import broadcaster logger = logging.getLogger(__name__) _POLL_INTERVAL_SECONDS = 10 ConfirmationHandler = Callable[[AsyncSession, PendingTransaction], Awaitable[None]] _handlers: dict[str, ConfirmationHandler] = {} def register_handler(kind: str, handler: ConfirmationHandler) -> None: """Domain modules (bets, rounds, withdrawals) register here so this generic poller can notify them when one of their outgoing txs gets its 1st confirmation, without this module importing them directly.""" _handlers[kind] = handler async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int: async with session_factory() as session: pending = ( await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending")) ).all() pending_ids = [p.id for p in pending] confirmed = 0 for pending_id, txid, kind in [(p.id, p.current_txid, p.kind) for p in pending]: tx = await client.get_transaction(txid, verbose=True) if not tx or tx.get("confirmations", 0) < 1: continue async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) if row is None or row.status != "pending": continue row.status = "confirmed" handler = _handlers.get(kind) if handler is not None: await handler(session, row) await session.commit() broadcaster.publish() # a bet/withdrawal/payout just confirmed — balance and/or round state changed confirmed += 1 return confirmed class ConfirmationPoller: def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]): self._session_factory = session_factory self._get_client = get_client async def run(self) -> None: while True: client = self._get_client() if client is not None: try: await poll_once(self._session_factory, client) except asyncio.CancelledError: raise except Exception: logger.exception("confirmation poll failed") await asyncio.sleep(_POLL_INTERVAL_SECONDS)