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=` (TLS with a pinned certificate, B-84) or `host:port:notls`/`ssl`/`tls` followed by `:pin=`. 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=]" 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 get_block_headers(self, start_height: int, count: int) -> dict: """`count` consecutive headers from `start_height` (B-74, ELC-12), as the server's {"count", "hex", "max"}: `hex` is the 80-byte headers concatenated, and the server may return fewer than asked (never more than its `max`).""" return await self.request("blockchain.block.headers", [start_height, count]) 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"))