Files
plm-lottery/app/electrum/client.py
T
davideandClaude Sonnet 5.5 2b6ddb019b Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
place_bet and request_withdrawal released everything on any exception from the
broadcast, including the 15s timeout and a connection dropped after the write. The
tx may have propagated: a bet's sats landed in the pool with no participant, a
withdrawal left while its row said failed and its coins were spendable again.

- client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply
  carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain
  ElectrumError.
- tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused /
  uncertain. A refusal is verified first: ElectrumListener.transaction_known asks
  blockchain.transaction.get of the active server and every other one (10s each) —
  any server returning the tx means accepted; the active server plus
  MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps
  the reduced rule); too few answers is uncertain. "already known" wording is only a
  shortcut that skips the check. With no network to ask, a refusal is uncertain.
- place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal
  releases; uncertain leaves the rows building for the reconciler, publishes SSE and
  POST /bets and POST /withdrawals answer 202 with status building; "already known"
  promotes a payout (payout_sent) like any accepted broadcast.
- An accepted transaction is fanned out to every other server
  (ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF
  replacement in bump_fee is left for B-77, which rewrites that function.

Tests changed because the spec changed: the existing "refused broadcast" tests raised
a bare RuntimeError (now an uncertain outcome) and are made explicit refusals
(rpc_error) with a network that denies the tx; direct route calls pass a Response;
FakePayoutListener gains the network surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:22:20 +02:00

381 lines
17 KiB
Python

import asyncio
import hashlib
import itertools
import json
import logging
import re
import ssl
from typing import NamedTuple
logger = logging.getLogger(__name__)
class ElectrumEndpoint(NamedTuple):
"""One server to connect to. The listener rotates over a list of these so a
single unreachable or misbehaving server doesn't take the platform down —
every consumer of PLM chain data goes through this one connection.
`pin` (B-84, ELC-03) is the SHA-256 of the server's DER certificate, in lower-case
hex; when set, a connection presenting any other certificate fails like an
unreachable server. Only meaningful with TLS."""
host: str
port: int
use_ssl: bool = True
pin: str | None = None
def __str__(self) -> str:
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}{' (pinned)' if self.pin else ''}"
_PIN_PATTERN = re.compile(r"[0-9a-f]{64}")
def normalize_pin(raw: str, entry: str) -> str:
"""A certificate pin as lower-case hex, or ValueError: 64 hexadecimal digits."""
pin = raw.strip().lower()
if not _PIN_PATTERN.fullmatch(pin):
raise ValueError(f"invalid certificate pin in Electrum server {entry!r}: expected 64 hex digits (sha256)")
return pin
def parse_endpoints(
primary_host: str,
primary_port: int,
primary_use_ssl: bool,
fallback_spec: str,
primary_pin: str = "",
) -> list[ElectrumEndpoint]:
"""Build the connection rotation: the primary server first, then whatever
ELECTRUM_FALLBACK_SERVERS lists.
`fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal
case), `host:port:notls`, `host:port:pin=<sha256>` (TLS with a pinned certificate,
B-84) or `host:port:notls`/`ssl`/`tls` followed by `:pin=<sha256>`. A pin on a
plaintext entry is refused: there is no certificate to pin. `primary_pin` is the
same pin for the primary (ELECTRUM_CERT_PIN). Malformed entries raise ValueError
rather than being skipped: a typo in a fallback server is something to fix at
startup, not to discover during an outage, when the fallback is what's needed.
Duplicates are dropped, keeping first position.
"""
syntax = "expected host:port[:notls][:pin=<sha256>]"
pin = normalize_pin(primary_pin, "ELECTRUM_CERT_PIN") if primary_pin.strip() else None
if pin and not primary_use_ssl:
raise ValueError("ELECTRUM_CERT_PIN needs TLS: ELECTRUM_USE_SSL is off")
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl, pin)]
for raw in fallback_spec.split(","):
entry = raw.strip()
if not entry:
continue
parts = entry.split(":")
if not 2 <= len(parts) <= 4:
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
host, port = parts[0].strip(), parts[1].strip()
if not host or not port.isdigit():
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
use_ssl = True
pin = None
mode_seen = False
for extra in parts[2:]:
extra = extra.strip()
if extra.lower().startswith("pin="):
if pin is not None:
raise ValueError(f"invalid Electrum server {entry!r}: more than one pin")
pin = normalize_pin(extra[4:], entry)
continue
flag = extra.lower()
if mode_seen or flag not in ("ssl", "tls", "notls", "plain"):
raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}")
mode_seen = True
use_ssl = flag in ("ssl", "tls")
if pin and not use_ssl:
raise ValueError(f"invalid Electrum server {entry!r}: a pin needs TLS")
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl, pin))
deduped: list[ElectrumEndpoint] = []
seen: set[tuple] = set()
for endpoint in endpoints:
key = (endpoint.host, endpoint.port, endpoint.use_ssl)
if key not in seen:
seen.add(key)
deduped.append(endpoint)
return deduped
# Every request is bounded: without this, a half-open socket (the peer vanished
# without a FIN, or the read loop died — see _read_loop) leaves `await future`
# hanging forever, and with it whatever was awaiting the call. That used to be
# unbounded, which meant a POST /bets could hang while holding the per-user lock
# and the confirmation poller could stop polling permanently.
_REQUEST_TIMEOUT_SECONDS = 15
# Longest reply line the client accepts (B-75, ELC-20). asyncio's default StreamReader
# limit is 64 KiB, and a longer line used to raise out of the read loop and close the
# only connection: the pool address's get_history passed that after ~800 transactions
# (and a few hundred dust outputs sent to any public address do it on purpose), after
# which every confirmation poll dropped the connection and payouts never confirmed.
_MAX_LINE_BYTES = 16 * 1024 * 1024
_READ_CHUNK_BYTES = 64 * 1024
# How much of each end of a discarded line is kept to find the reply's `id` in, so the
# request it answered can fail at once instead of waiting out the 15s timeout.
_ID_SNIFF_BYTES = 256
_ID_PATTERN = re.compile(rb'"id"\s*:\s*(\d+)')
class ElectrumError(Exception):
pass
class ElectrumRpcError(ElectrumError):
"""The server *answered* with a JSON-RPC `error` object (B-76, TX-04). It is the
only failure that says anything about the request itself: a timeout, a dropped
connection or a write error (plain ElectrumError) leaves it unknown whether the
server ever acted on it. `args[0]` is the error object as the server sent it."""
@property
def message(self) -> str:
detail = self.args[0] if self.args else ""
if isinstance(detail, dict):
return str(detail.get("message", detail))
return str(detail)
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.
A dead connection is observable rather than silent: `wait_closed()` resolves as
soon as the read loop terminates for any reason, which is what lets
ElectrumListener notice the drop and reconnect instead of waiting forever on
notification queues nobody will ever fill again.
"""
def __init__(self, host: str, port: int, use_ssl: bool = True, pin: str | None = None):
self.host = host
self.port = port
self.use_ssl = use_ssl
self.pin = pin.lower() if pin else None
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
self._closed = asyncio.Event()
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. What can be
# asked instead is that the certificate be *the* one the operator pinned
# (B-84, ELC-03): without a pin, whoever sits on the path to a server takes
# its place, and only the quorum stands between that and the draw.
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, limit=_MAX_LINE_BYTES
)
if self.pin:
try:
self._check_pin()
except ElectrumError:
self._writer.close()
raise
self._read_task = asyncio.create_task(self._read_loop())
await self.request("server.version", ["plm-lottery", "1.4"])
def _check_pin(self) -> None:
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
ssl_object = self._writer.get_extra_info("ssl_object") if self._writer is not None else None
der = ssl_object.getpeercert(binary_form=True) if ssl_object is not None else None
if not der:
raise ElectrumError(f"{self.host}:{self.port} presented no certificate to check against the pin")
if hashlib.sha256(der).hexdigest() != self.pin:
raise ElectrumError(f"{self.host}:{self.port} certificate does not match the configured pin")
async def close(self) -> None:
self._closed.set()
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, ConnectionError):
pass # some Electrum servers don't send a clean TLS close_notify
async def wait_closed(self) -> None:
"""Resolves once this connection is gone — read loop finished (peer closed,
protocol error, TLS failure) or close() was called. ElectrumListener races
this against its notification consumers so a drop triggers a reconnect."""
await self._closed.wait()
async def request(self, method: str, params: list | None = None) -> object:
if self._writer is None or self._closed.is_set():
raise ElectrumError("not connected")
request_id = next(self._id_counter)
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[request_id] = future
payload = json.dumps({"id": request_id, "method": method, "params": params or []}) + "\n"
try:
self._writer.write(payload.encode())
await self._writer.drain()
except (ConnectionError, ssl.SSLError, OSError) as exc:
self._pending.pop(request_id, None)
self._closed.set()
raise ElectrumError(f"write failed: {exc}") from exc
try:
return await asyncio.wait_for(future, timeout=_REQUEST_TIMEOUT_SECONDS)
except (TimeoutError, asyncio.TimeoutError) as exc:
self._pending.pop(request_id, None)
# A server that owes us a reply and never sends one is indistinguishable
# from a dead socket, and retrying on the same connection would keep
# hitting it — tear it down so the listener reconnects.
self._closed.set()
raise ElectrumError(f"{method} timed out after {_REQUEST_TIMEOUT_SECONDS}s") from exc
async def ping(self) -> None:
"""Keepalive: Electrum servers drop idle connections (commonly after ~10
minutes), which on a quiet instance would otherwise be the normal way this
connection dies. Called periodically by ElectrumListener."""
await self.request("server.ping")
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 get_history(self, scripthash: str) -> list[dict]:
"""Every transaction touching `scripthash`, each as {"tx_hash", "height"} —
height > 0 means confirmed at that height, height <= 0 means still in the
mempool. Used instead of blockchain.transaction.get's verbose=True mode
for confirmation/existence checks (B-41): several Electrum server
implementations and versions reject the verbose flag outright ("verbose
transactions are currently unsupported"), while get_history is a plain,
universally-supported method every server must implement.
"""
return await self.request("blockchain.scripthash.get_history", [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 get_merkle(self, txid: str, height: int) -> dict:
"""The merkle branch proving `txid` is in the block at `height` (B-83, ELC-26)."""
return await self.request("blockchain.transaction.get_merkle", [txid, height])
async def get_block_header(self, height: int) -> str:
return await self.request("blockchain.block.header", [height])
async def _read_loop(self) -> None:
"""Frame the stream into lines by hand rather than with StreamReader.readline:
readline raises on a line over the limit, and that exception used to end this
loop and with it the connection. An oversized line is now dropped up to its
newline (B-75, ELC-20) and the connection stays up."""
assert self._reader is not None
buffer = bytearray()
discarding = False
head = b""
tail = b""
try:
while True:
chunk = await self._reader.read(_READ_CHUNK_BYTES)
if not chunk:
break
while chunk:
newline = chunk.find(b"\n")
piece = chunk if newline < 0 else chunk[:newline]
chunk = b"" if newline < 0 else chunk[newline + 1 :]
if discarding:
tail = (tail + piece)[-_ID_SNIFF_BYTES:]
else:
buffer += piece
if len(buffer) > _MAX_LINE_BYTES:
discarding = True
head = bytes(buffer[:_ID_SNIFF_BYTES])
tail = bytes(buffer[-_ID_SNIFF_BYTES:])
buffer.clear()
if newline >= 0:
if discarding:
self._fail_unreadable_reply(head + b" " + tail, "reply exceeds the line limit")
discarding = False
else:
self._handle_line(bytes(buffer))
buffer.clear()
if buffer and not discarding:
self._handle_line(bytes(buffer)) # a final line the server never terminated
finally:
# Whatever ended this loop — clean EOF, TLS failure — the
# connection is unusable from here on. Setting this is what makes the
# death observable to wait_closed(), and so to the listener's reconnect
# logic; without it the listener waited on notification queues nobody
# would ever fill again, forever (B-01).
self._closed.set()
error = ElectrumError("connection closed")
for future in self._pending.values():
if not future.done():
future.set_exception(error)
self._pending.clear()
def _handle_line(self, line: bytes) -> None:
if not line.strip():
return
try:
message = json.loads(line)
except ValueError:
# A reply that is not JSON is a problem of that one request, not of the
# connection (B-75).
self._fail_unreadable_reply(line[:_ID_SNIFF_BYTES] + b" " + line[-_ID_SNIFF_BYTES:], "reply is not valid JSON")
return
if not isinstance(message, dict):
logger.warning("ignoring an Electrum message that is not a JSON object")
return
self._dispatch(message)
def _fail_unreadable_reply(self, sample: bytes, reason: str) -> None:
"""Fail the request an unusable reply belongs to, when its `id` can be read
from `sample` (the first and last bytes of the line); otherwise that request
fails by its own timeout (ELC-04)."""
logger.warning("dropping an unusable Electrum reply: %s", reason)
for match in _ID_PATTERN.finditer(sample):
future = self._pending.pop(int(match.group(1)), None)
if future is not None:
if not future.done():
future.set_exception(ElectrumError(reason))
return
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(ElectrumRpcError(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"))