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:
2026-07-20 23:52:20 +02:00
co-authored by Claude Sonnet 5
parent bae48c46dc
commit df72367f02
76 changed files with 3506 additions and 1 deletions
View File
+114
View File
@@ -0,0 +1,114 @@
import asyncio
import itertools
import json
import ssl
class ElectrumError(Exception):
pass
class ElectrumClient:
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
Push notifications (blockchain.headers.subscribe, blockchain.scripthash.subscribe)
arrive under the *same* method name as the subscribe call, multiplexed for every
scripthash subscribed — callers read `notifications(method)` and, for scripthash
pushes, dispatch on `params[0]` (the scripthash) themselves.
"""
def __init__(self, host: str, port: int, use_ssl: bool = True):
self.host = host
self.port = port
self.use_ssl = use_ssl
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._id_counter = itertools.count(1)
self._pending: dict[int, asyncio.Future] = {}
self._subscriptions: dict[str, asyncio.Queue] = {}
self._read_task: asyncio.Task | None = None
async def connect(self) -> None:
# Electrum servers commonly present self-signed certs; the protocol's trust
# model is server consensus, not TLS PKI, so we only use SSL for transport
# encryption and don't verify the certificate chain/hostname.
ssl_context = None
if self.use_ssl:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self._reader, self._writer = await asyncio.open_connection(self.host, self.port, ssl=ssl_context)
self._read_task = asyncio.create_task(self._read_loop())
await self.request("server.version", ["plm-lottery", "1.4"])
async def close(self) -> None:
if self._read_task is not None:
self._read_task.cancel()
if self._writer is not None:
self._writer.close()
try:
await asyncio.wait_for(self._writer.wait_closed(), timeout=2)
except (ssl.SSLError, TimeoutError, asyncio.TimeoutError):
pass # some Electrum servers don't send a clean TLS close_notify
async def request(self, method: str, params: list | None = None) -> object:
if self._writer is None:
raise ElectrumError("not connected")
request_id = next(self._id_counter)
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[request_id] = future
payload = json.dumps({"id": request_id, "method": method, "params": params or []}) + "\n"
self._writer.write(payload.encode())
await self._writer.drain()
return await future
def notifications(self, method: str) -> asyncio.Queue:
return self._subscriptions.setdefault(method, asyncio.Queue())
async def subscribe_headers(self) -> dict:
self.notifications("blockchain.headers.subscribe")
return await self.request("blockchain.headers.subscribe")
async def subscribe_scripthash(self, scripthash: str) -> str | None:
self.notifications("blockchain.scripthash.subscribe")
return await self.request("blockchain.scripthash.subscribe", [scripthash])
async def listunspent(self, scripthash: str) -> list[dict]:
return await self.request("blockchain.scripthash.listunspent", [scripthash])
async def broadcast(self, raw_tx_hex: str) -> str:
return await self.request("blockchain.transaction.broadcast", [raw_tx_hex])
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
return await self.request("blockchain.transaction.get", [txid, verbose])
async def _read_loop(self) -> None:
assert self._reader is not None
try:
while True:
line = await self._reader.readline()
if not line:
break
message = json.loads(line)
self._dispatch(message)
finally:
error = ElectrumError("connection closed")
for future in self._pending.values():
if not future.done():
future.set_exception(error)
self._pending.clear()
def _dispatch(self, message: dict) -> None:
message_id = message.get("id")
if message_id is not None and message_id in self._pending:
future = self._pending.pop(message_id)
if future.done():
return
if message.get("error"):
future.set_exception(ElectrumError(message["error"]))
else:
future.set_result(message.get("result"))
elif "method" in message:
queue = self._subscriptions.get(message["method"])
if queue is not None:
queue.put_nowait(message.get("params"))
+111
View File
@@ -0,0 +1,111 @@
import asyncio
import logging
from collections.abc import Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
logger = logging.getLogger(__name__)
class ElectrumListener:
"""Long-lived background task: keeps one Electrum connection open, subscribes
every user's address (plus any address added later via add_address), and
credits confirmed deposits as scripthash-change notifications arrive.
Reconnects with backoff on any failure; a fresh connection re-subscribes to
every user pulled straight from the DB, so no in-memory subscription state is
ever a stale source of truth.
"""
def __init__(self, client_factory: Callable[[], ElectrumClient], session_factory: async_sessionmaker):
self._client_factory = client_factory
self._session_factory = session_factory
self._scripthash_to_user: dict[str, int] = {}
self.tip_height: int = 0
self.tip_header_hex: str | None = None
self.client: ElectrumClient | None = None
def address_for_new_user(self, user_id: int, address: str) -> None:
"""Called right after a user registers so their deposit address starts
being watched immediately, without waiting for the next reconnect cycle."""
scripthash = address_to_scripthash(address)
self._scripthash_to_user[scripthash] = user_id
if self.client is not None:
asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id))
async def run(self) -> None:
backoff = 1
while True:
try:
await self._run_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Electrum listener error, reconnecting in %ss", backoff)
self.client = None
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
continue
backoff = 1
async def _run_once(self) -> None:
client = self._client_factory()
await client.connect()
self.client = client
header = await client.subscribe_headers()
self.tip_height = header["height"]
self.tip_header_hex = header.get("hex")
await self._subscribe_all_users()
headers_queue = client.notifications("blockchain.headers.subscribe")
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
try:
await asyncio.gather(
self._consume_headers(headers_queue),
self._consume_scripthash(scripthash_queue),
)
finally:
await client.close()
async def _subscribe_all_users(self) -> None:
async with self._session_factory() as session:
users = (await session.scalars(select(User))).all()
for user in users:
scripthash = address_to_scripthash(user.address)
self._scripthash_to_user[scripthash] = user.id
await self._subscribe_and_refresh(scripthash, user.id)
async def _subscribe_and_refresh(self, scripthash: str, user_id: int) -> None:
assert self.client is not None
await self.client.subscribe_scripthash(scripthash)
await self._refresh_user(user_id, scripthash)
async def _consume_headers(self, queue: asyncio.Queue) -> None:
while True:
params = await queue.get()
for header in params:
self.tip_height = header["height"]
self.tip_header_hex = header.get("hex")
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
while True:
scripthash, _status = await queue.get()
user_id = self._scripthash_to_user.get(scripthash)
if user_id is not None:
await self._refresh_user(user_id, scripthash)
async def _refresh_user(self, user_id: int, scripthash: str) -> None:
assert self.client is not None
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
if credited:
logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id)
+15
View File
@@ -0,0 +1,15 @@
import hashlib
from embit.script import Script
def address_to_scripthash(address: str) -> str:
"""Electrum protocol scripthash: sha256(scriptPubKey), byte-reversed, hex.
Uses `.data` (the raw scriptPubKey bytes), not `.serialize()` — the latter
prefixes a compact-size length byte meant for embedding the script as pushdata
elsewhere (e.g. a P2SH redeemScript), which is not part of the actual on-chain
output script and produces a wrong (unmatchable) scripthash if used here.
"""
script_pubkey = Script.from_address(address).data
return hashlib.sha256(script_pubkey).digest()[::-1].hex()