Implement full MVP: auth, HD wallet, Electrum client, deposits, bets, round/draw engine, payout, withdrawals, RBF, admin+audit
All 10 build-order stages complete and unit-tested (49 tests). Verified live on mainnet: registration/address derivation, deposit crediting, a real 10 PLM bet (broadcast + confirmed + change credited). A full round close->draw->payout cycle was triggered live and was in progress at commit time. Withdrawal and RBF bump are unit-tested but not yet exercised against a live broadcast. Known gaps (scheduler doesn't resume mid-flight rounds after restart, payout has no retry, no deployment setup, etc.) are documented in CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
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
|
||||
|
||||
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()
|
||||
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)
|
||||
Reference in New Issue
Block a user