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>
381 lines
17 KiB
Python
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"))
|