# Copyright (c) 2016-2018, Neil Booth # # All rights reserved. # # See the file "LICENCE" for information about the copyright # and warranty status of this software. '''Classes for local RPC server and remote client TCP/SSL servers.''' import asyncio import codecs from dataclasses import dataclass import itertools import math import os import ssl import time import collections from collections import defaultdict import functools from functools import partial from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network from typing import Iterable, Optional, TYPE_CHECKING, Sequence, Union, Any, Tuple, Set, Dict, Mapping from typing import Callable import random import inspect import logging import aiorpcx from aiorpcx import (Event, JSONRPCAutoDetect, JSONRPCConnection, ReplyAndDisconnect, Request, RPCError, RPCSession, Service, handler_invocation, serve_rs, serve_ws, sleep, NewlineFramer, TaskTimeout, timeout_after, run_in_thread, Notification, ignore_after) from aiorpcx.jsonrpc import SingleRequest import electrumx import electrumx.lib.util as util from electrumx.lib.lrucache import LRUCache from electrumx.lib.util import OldTaskGroup, is_hex_str, get_running_loop from electrumx.lib.hash import (HASHX_LEN, Base58Error, Bech32Error, hash_to_hex_str, hex_str_to_hash, sha256, double_sha256) from electrumx.lib.merkle import MerkleCache from electrumx.lib.text import sessions_lines from electrumx.lib.tx import TXOSpendStatus, TxOutpoint from electrumx.server.daemon import DaemonError from electrumx.server.transport import PaddedRSTransport if TYPE_CHECKING: from electrumx.server.db import DB from electrumx.server.env import Env from electrumx.server.block_processor import BlockProcessor from electrumx.server.daemon import Daemon from electrumx.server.mempool import MemPool from electrumx.server.peers import PeerManager from electrumx.server.controller import Controller, Notifications BAD_REQUEST = 1 DAEMON_ERROR = 2 RPC_ERROR_HISTORY_TOO_LONG = 10_001 def scripthash_to_hashX(scripthash: str) -> bytes: try: bin_hash = hex_str_to_hash(scripthash) if len(bin_hash) == 32: return bin_hash[:HASHX_LEN] except (ValueError, TypeError): pass raise RPCError(BAD_REQUEST, f'{scripthash} is not a valid script hash') def spk_to_scripthash(spk: str) -> str: """Converts scriptPubKey to scripthash.""" assert_hex_str(spk) h = sha256(bytes.fromhex(spk)) return h[::-1].hex() def non_negative_integer(value: Any | int) -> int: '''Return param value it is or can be converted to a non-negative integer, otherwise raise an RPCError.''' try: value = int(value) if value >= 0: return value except (ValueError, TypeError): pass raise RPCError(BAD_REQUEST, f'{value} should be a non-negative integer') def assert_boolean(value: Any | bool) -> bool: '''Return param value it is boolean otherwise raise an RPCError.''' if value in (False, True): return value raise RPCError(BAD_REQUEST, f'{value} should be a boolean value') def assert_txid_hum(value: Any | str) -> bytes: '''Raise an RPCError if the value is not a valid hexadecimal txid_hum. If it is valid, return it as 32-byte binary txid_rev. ''' try: raw_hash = hex_str_to_hash(value) if len(raw_hash) == 32: return raw_hash except (ValueError, TypeError): pass raise RPCError(BAD_REQUEST, f'{value} should be a transaction hash') def assert_hex_str(value: Any | str, *, allow_odd_len: bool = False) -> None: if not is_hex_str(value, allow_odd_len=allow_odd_len): raise RPCError(BAD_REQUEST, f'{value} should be a hex string') def assert_list_or_tuple(value: Any) -> None: if not isinstance(value, (list, tuple)): raise RPCError(BAD_REQUEST, f'{value} should be a list') class GracefulDisconnect(Exception): pass class ChainSyncError(RPCError): def __init__(self, *, code=None, message=None): if code is None: code = BAD_REQUEST if message is None: message = "chain-sync-error" RPCError.__init__(self, code=code, message=message) class ReorgWhileProcessingRequest(ChainSyncError): def __init__(self, *, code=None, message=None): if code is None: code = BAD_REQUEST if message is None: message = "reorg while processing request" ChainSyncError.__init__(self, code=code, message=message) def retry_on_chain_sync_error(func=None, *, check_chaintip: bool): """Decorator that retries calling func multiple times if encountering a ChainSyncError. While handling a request for a session, the daemon/db might process a reorg or the chaintip might move. If a protocol handler method considers this important, it raises a ChainSyncError, which this decorator handles by retrying. If 'check_chaintip' is True, we additionally check that the chaintip did not change while awaiting func, and retry if so. """ if func is None: return partial(retry_on_chain_sync_error, check_chaintip=check_chaintip) assert inspect.iscoroutinefunction(func), f"can only wrap async functions. {func=}" @functools.wraps(func) async def handle_chain_sync_error(self: 'ElectrumX', *args, **kw_args): session_mgr = self.session_mgr # note: the last sleep is ~infinite. A timeout from env.request_timeout will trigger instead. sleeps = [0, 0.5, 2.5, 5, 10, 10**6] # seconds t0 = time.monotonic() for attempt, sleep_time in enumerate(sleeps): if sleep_time != 0: # A previous attempt failed. Sleep until the height changes. # (This is a bit racy: we might miss the height changing if it changes before we start # awaiting the event. So we cap the sleep to sleep_time, and then retry anyway.) async with ignore_after(sleep_time): await session_mgr.notified_height_changed.wait() chaintip1 = chaintip2 = None reorgcnt1 = reorgcnt2 = None try: if check_chaintip: chaintip1 = session_mgr.get_block_ref_for_chaintip() reorgcnt1 = session_mgr._reorg_count res = await func(self, *args, **kw_args) if check_chaintip: chaintip2 = session_mgr.get_block_ref_for_chaintip() reorgcnt2 = session_mgr._reorg_count if not (chaintip1 == chaintip2 and reorgcnt1 == reorgcnt2): raise ChainSyncError(message="chaintip moved while processing request") except ChainSyncError as e: self.bump_cost(0.1) # paranoia: maybe client can force ChainSyncError due to logic bug time_elapsed = time.monotonic() - t0 if time_elapsed > 10.0: self.logger.debug( f"got {e!r} while processing request: {func}. retrying ({attempt=}). " f"time taken: {time_elapsed:.4f} sec") continue # try again assert chaintip1 == chaintip2 # success! sending response now. if attempt != 0: time_elapsed = time.monotonic() - t0 self.logger.log( logging.INFO if time_elapsed > 5.0 else logging.DEBUG, f"responding to request after ChainSyncError, ({attempt=}): " f"{func}. time taken: {time_elapsed:.4f} sec") return res # exhausted all attempts. sending error. # note: we should only get here if notified_height_changed at least once while processing the request, # and we got a ChainSyncError both when trying before and when trying after that. # That likely means the chaintip moved at least twice. time_elapsed = time.monotonic() - t0 self.logger.info( f"sending RPCError to client after exhausting all attempts for ChainSyncError. " f"{func}. time taken: {time_elapsed:.4f} sec") raise RPCError( BAD_REQUEST, "chaintip moved too many times, or DB not in sync with daemon, while processing request") return handle_chain_sync_error @dataclass(slots=True, frozen=True, kw_only=True) class BlockRef: height: int hash_rev: bytes @dataclass(slots=True) class SessionGroup: name: str weight: float sessions: set['SessionBase'] retained_cost: float def session_cost(self) -> float: return sum(session.cost for session in self.sessions) def cost(self) -> float: return self.retained_cost + self.session_cost() @dataclass(slots=True) class SessionReferences: # All attributes are sets but groups is a list sessions: set['SessionBase'] groups: Sequence['SessionGroup'] specials: set[str] # Lower-case strings unknown: set[str] class SessionManager: '''Holds global state about all sessions.''' def __init__( self, *, env: 'Env', db: 'DB', block_processor: 'BlockProcessor', daemon: 'Daemon', mempool: 'MemPool', shutdown_event: asyncio.Event, ): env.max_send = max(350000, env.max_send) self.env = env self.db = db self.bp = block_processor self.daemon = daemon self.mempool = mempool from electrumx.server.peers import PeerManager self.peer_mgr = PeerManager(env, db) self.shutdown_event = shutdown_event self.logger = util.class_logger(__name__, self.__class__.__name__) self.servers = {} # type: Dict[Service, asyncio.Server] self.sessions = {} # type: Dict[SessionBase, Iterable[SessionGroup]] self.session_groups = {} # type: Dict[str, SessionGroup] self.txs_sent = 0 # Would use monotonic time, but aiorpcx sessions use Unix time: self.start_time = time.time() self._method_counts = defaultdict(int) self._reorg_count = 0 self._history_cache = LRUCache(maxsize=1000) # type: LRUCache[bytes, Sequence[tuple[bytes, int]] | RPCError] self._txids_cache = LRUCache(maxsize=1000) # type: LRUCache[int, Sequence[bytes]] # Really a MerkleCache cache self._merkle_txid_cache = LRUCache(maxsize=1000) # type: LRUCache[int, MerkleCache] self.estimatefee_cache: LRUCache[ tuple[int, str | None], tuple[bytes | None, float | None, asyncio.Lock] ] = LRUCache(maxsize=1000) self.oc_txo_status_cache = LRUCache(maxsize=1000) # type: LRUCache[TxOutpoint, TXOSpendStatus] self.notified_height = None # type: int | None self.notified_tip = None # type: bytes | None # block_hash_rev self.notified_height_changed = asyncio.Event() self.hsub_results = None # type: dict[str, Any] | None self._task_group = OldTaskGroup() self._sslc = None # type: ssl.SSLContext | None # Event triggered when electrumx is listening for incoming requests. self.server_listening = Event() self.session_event = Event() # Set up the RPC request handlers cmds = ('add_peer daemon_url disconnect getinfo groups log peers ' 'query reorg sessions stop debug_memusage_list_all_objects ' 'debug_memusage_get_random_backref_chain inspect_session'.split()) LocalRPC.request_handlers = {cmd: getattr(self, 'rpc_' + cmd) for cmd in cmds} def _ssl_context(self) -> ssl.SSLContext: if self._sslc is None: self._sslc = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) self._sslc.load_cert_chain(self.env.ssl_certfile, keyfile=self.env.ssl_keyfile) return self._sslc async def _start_servers(self, services: Iterable[Service]) -> None: for service in services: kind = service.protocol.upper() if service.protocol in self.env.SSL_PROTOCOLS: sslc = self._ssl_context() else: sslc = None if service.protocol == 'rpc': # local admin RPC session_class = LocalRPC serve = serve_rs else: # electrum protocol sessions session_class = self.env.coin.SESSIONCLS if service.protocol in ('ws', 'wss'): # FIXME also add padding to msgs in websocket sessions serve = serve_ws else: serve = partial(serve_rs, transport=PaddedRSTransport) # FIXME: pass the service not the kind session_factory = partial( session_class, session_mgr=self, db=self.db, mempool=self.mempool, peer_mgr=self.peer_mgr, kind=kind, ) host = None if service.host == 'all_interfaces' else str(service.host) try: self.servers[service] = await serve(session_factory, host, service.port, ssl=sslc) except OSError as e: # don't suppress CancelledError self.logger.error(f'{kind} server failed to listen on {service.address}: {e}') else: self.logger.info(f'{kind} server listening on {service.address}') async def _start_external_servers(self): '''Start listening on TCP and SSL ports, but only if the respective port was given in the environment. ''' await self._start_servers(service for service in self.env.services if service.protocol != 'rpc') self.server_listening.set() async def _stop_servers(self, services: Iterable[Service]): '''Stop the servers of the given protocols.''' for service in services: self.logger.info(f'closing down server for {service}') self.servers[service].close() def _remove_servers(self, services: Iterable[Service]): '''Remove the servers of the given protocols.''' for service in services: del self.servers[service] async def _manage_servers(self) -> None: paused = False max_sessions = self.env.max_sessions low_watermark = max_sessions * 19 // 20 while True: await self.session_event.wait() self.session_event.clear() if not paused and len(self.sessions) >= max_sessions: self.logger.info(f'maximum sessions {max_sessions:,d} ' f'reached, stopping new connections until ' f'count drops to {low_watermark:,d}') services_to_remove = [service for service in self.servers if service.protocol != 'rpc'] await self._stop_servers(services_to_remove) self._remove_servers(services_to_remove) paused = True # Start listening for incoming connections if paused and # session count has fallen if paused and len(self.sessions) <= low_watermark: self.logger.info('resuming listening for incoming connections') await self._start_external_servers() paused = False async def _log_sessions(self) -> None: '''Periodically log sessions.''' log_interval = self.env.log_sessions if log_interval: while True: await sleep(log_interval) data = self._session_data(for_log=True) for line in sessions_lines(data): self.logger.info(line) self.logger.info(util.json_serialize(self._get_info())) async def _disconnect_sessions( self, sessions: Sequence['SessionBase'], reason: str, *, force_after: float = 1.0, ) -> None: if sessions: session_ids = ', '.join(str(session.session_id) for session in sessions) self.logger.info(f'{reason} session ids {session_ids}') for session in sessions: await self._task_group.spawn(session.close(force_after=force_after)) async def _clear_stale_sessions(self) -> None: '''Cut off sessions that haven't done anything for 10 minutes.''' while True: await sleep(60) stale_cutoff = time.time() - self.env.session_timeout stale_sessions = [session for session in self.sessions if session.last_recv < stale_cutoff] await self._disconnect_sessions(stale_sessions, 'closing stale') del stale_sessions async def _handle_chain_reorgs(self) -> None: '''Clear certain caches on chain reorgs.''' while True: await self.bp.backed_up_event.wait() self.logger.info(f'reorg signalled; clearing txids and merkle caches') self._reorg_count += 1 # note: history_cache is cleared in _notify_sessions # note: txo_status_cache is cleared in _notify_sessions self._txids_cache.clear() self._merkle_txid_cache.clear() async def _recalc_concurrency(self) -> None: '''Periodically recalculate session concurrency.''' session_class = self.env.coin.SESSIONCLS period = 300 while True: await sleep(period) hard_limit = session_class.cost_hard_limit # Reduce retained group cost refund = period * hard_limit / 5000 dead_groups = [] for group in self.session_groups.values(): group.retained_cost = max(0.0, group.retained_cost - refund) if group.retained_cost == 0 and not group.sessions: dead_groups.append(group) # Remove dead groups for group in dead_groups: self.session_groups.pop(group.name) # Recalc concurrency for sessions where cost is changing gradually, and update # cost_decay_per_sec. for session in self.sessions: # Subs have an on-going cost so decay more slowly with more subs session.cost_decay_per_sec = hard_limit / (10000 + 5 * session.sub_count_total()) session.recalc_concurrency() def _get_info(self) -> dict[str, Any]: '''A summary of server state.''' def cache_fmt(cache: LRUCache): return ( f"{cache.num_lookups} lookups, " f"{cache.num_hits} hits, " f"{len(cache)} entries, " f"hitrate {cache.num_hits/(cache.num_lookups or 1):.4f}" ) sessions = self.sessions return { 'caches': { 'estimatefee': cache_fmt(self.estimatefee_cache), 'history': cache_fmt(self._history_cache), 'merkle txid': cache_fmt(self._merkle_txid_cache), 'txids': cache_fmt(self._txids_cache), 'txo status': cache_fmt(self.oc_txo_status_cache), }, 'coin': self.env.coin.__name__, 'daemon': self.daemon.logged_url(), 'daemon height': self.daemon.cached_height(), 'db height': self.db.db_height, 'groups': len(self.session_groups), 'pid': os.getpid(), 'peers': self.peer_mgr.info(), 'request counts': self._method_counts, 'request total': sum(self._method_counts.values()), 'sessions': { 'count': len(sessions), 'count with subs_sh': sum(s.sub_count_scripthashes() > 0 for s in sessions), 'count with subs_txo': sum(s.sub_count_txoutpoints() > 0 for s in sessions), 'count with subs_any': sum(s.sub_count_total() > 0 for s in sessions), 'errors': sum(s.errors for s in sessions), 'logged': len([s for s in sessions if s.log_me]), 'pending requests': sum(s.unanswered_request_count() for s in sessions), 'subs_sh': sum(s.sub_count_scripthashes() for s in sessions), 'subs_txo': sum(s.sub_count_txoutpoints() for s in sessions), }, 'txs sent': self.txs_sent, 'uptime': util.formatted_time(time.time() - self.start_time), 'version': electrumx.version, } def _session_data(self, for_log: bool): '''Returned to the RPC 'sessions' call.''' now = time.time() sessions = sorted(self.sessions, key=lambda s: s.start_time) return [(session.session_id, session.flags(), session.remote_address_string(for_log=for_log), session.client_shortname(), session.protocol_version_string(), session.cost, session.extra_cost(), session.unanswered_request_count(), session.txs_sent, session.sub_count_total(), session.recv_count, session.recv_size, session.send_count, session.send_size, now - session.start_time) for session in sessions] def _group_data(self): '''Returned to the RPC 'groups' call.''' result = [] for name, group in self.session_groups.items(): sessions = group.sessions result.append([name, len(sessions), group.session_cost(), group.retained_cost, sum(s.unanswered_request_count() for s in sessions), sum(s.txs_sent for s in sessions), sum(s.sub_count_total() for s in sessions), sum(s.recv_count for s in sessions), sum(s.recv_size for s in sessions), sum(s.send_count for s in sessions), sum(s.send_size for s in sessions), ]) return result async def _refresh_hsub_results(self, height: int) -> None: '''Refresh the cached header subscription responses to be for height, and record that as notified_height. ''' # Paranoia: a reorg could race and leave db_height lower height = min(height, self.db.db_height) raw_header = await self.raw_header(height) self.hsub_results = {'hex': raw_header.hex(), 'height': height} self.notified_height = height self.notified_tip = self.env.coin.header_hash_rev(raw_header) self.notified_height_changed.set() self.notified_height_changed.clear() self.logger.debug(f"new notified_height: {self.notified_height}") def _session_references(self, items: Iterable[str] | Any, special_strings): '''Return a SessionReferences object.''' if not isinstance(items, list) or not all(isinstance(item, str) for item in items): raise RPCError(BAD_REQUEST, 'expected a list of session IDs') sessions_by_id = {session.session_id: session for session in self.sessions} groups_by_name = self.session_groups sessions = set() groups = set() # Names as groups are not hashable specials = set() unknown = set() for item in items: if item.isdigit(): session = sessions_by_id.get(int(item)) if session: sessions.add(session) else: unknown.add(item) else: lc_item = item.lower() if lc_item in special_strings: specials.add(lc_item) else: if lc_item in groups_by_name: groups.add(lc_item) else: unknown.add(item) groups = [groups_by_name[group] for group in groups] return SessionReferences(sessions, groups, specials, unknown) # --- LocalRPC command handlers async def rpc_add_peer(self, real_name: str) -> str: '''Add a peer. real_name: "electrum.example.com t50001 s50002" for example ''' await self.peer_mgr.add_localRPC_peer(real_name) return f"peer '{real_name}' added" async def rpc_disconnect(self, session_ids: Iterable[str] | Any) -> Sequence[str]: '''Disconnect sesssions. session_ids: array of session IDs ''' refs = self._session_references(session_ids, {'all'}) result = [] if 'all' in refs.specials: sessions = self.sessions result.append('disconnecting all sessions') else: sessions = refs.sessions result.extend(f'disconnecting session {session.session_id}' for session in sessions) for group in refs.groups: result.append(f'disconnecting group {group.name}') sessions.update(group.sessions) result.extend(f'unknown: {item}' for item in refs.unknown) await self._disconnect_sessions(sessions, 'local RPC request to disconnect') return result async def rpc_log(self, session_ids: Iterable[str] | Any) -> Sequence[str]: '''Toggle logging of sesssions. session_ids: array of session or group IDs, or 'all', 'none', 'new' ''' refs = self._session_references(session_ids, {'all', 'none', 'new'}) result = [] def add_result(text, value): result.append(f'logging {text}' if value else f'not logging {text}') if 'all' in refs.specials: for session in self.sessions: session.log_me = True SessionBase.log_new = True result.append('logging all sessions') if 'none' in refs.specials: for session in self.sessions: session.log_me = False SessionBase.log_new = False result.append('logging no sessions') if 'new' in refs.specials: SessionBase.log_new = not SessionBase.log_new add_result('new sessions', SessionBase.log_new) sessions = refs.sessions for session in sessions: session.log_me = not session.log_me add_result(f'session {session.session_id}', session.log_me) for group in refs.groups: for session in group.sessions.difference(sessions): sessions.add(session) session.log_me = not session.log_me add_result(f'session {session.session_id}', session.log_me) result.extend(f'unknown: {item}' for item in refs.unknown) return result async def rpc_daemon_url(self, daemon_url: str): '''Replace the daemon URL.''' daemon_url = daemon_url or self.env.daemon_url try: self.daemon.set_url(daemon_url) except Exception as e: raise RPCError(BAD_REQUEST, f'an error occurred: {e!r}') return f'now using daemon at {self.daemon.logged_url()}' async def rpc_stop(self): '''Shut down the server cleanly.''' self.shutdown_event.set() return 'stopping' async def rpc_getinfo(self): '''Return summary information about the server process.''' return self._get_info() async def rpc_inspect_session(self, session_id: int) -> dict[str, Any]: """Inspect a given currently connected client session.""" assert isinstance(session_id, int), repr(session_id) sessions_by_id = {session.session_id: session for session in self.sessions} if (s := sessions_by_id.get(session_id)) is None: raise RPCError(BAD_REQUEST, f'no session with id={session_id}') if not isinstance(s, ElectrumX): raise RPCError(BAD_REQUEST, f'session with id={session_id} is not a client session, but {type(s)}') return { 'client_name': s.client_longname, 'request counts': s._method_counts, 'request total': sum(s._method_counts.values()), 'subs_sh': s.sub_count_scripthashes(), 'subs_txo': s.sub_count_txoutpoints(), 'errors': s.errors, 'is_logged': s.log_me, 'pending requests': s.unanswered_request_count(), 'txs sent': s.txs_sent, 'conn_time': util.formatted_time(time.time() - s.start_time), } async def rpc_groups(self): '''Return statistics about the session groups.''' return self._group_data() async def rpc_peers(self): '''Return a list of data about server peers.''' return self.peer_mgr.rpc_data() async def rpc_query(self, items, limit): '''Returns data about a script or address.''' coin = self.env.coin db = self.db lines = [] def arg_to_hashX(arg): try: script = bytes.fromhex(arg) lines.append(f'Script: {arg}') return coin.hashX_from_script(script) except ValueError: pass try: hashX = coin.address_to_hashX(arg) lines.append(f'Address: {arg}') return hashX except (Base58Error, Bech32Error): pass return None for arg in items: hashX = arg_to_hashX(arg) if not hashX: continue n = None history = await db.limited_history(hashX, limit=limit) for n, (txid_rev, height) in enumerate(history): lines.append(f'History #{n:,d}: height {height:,d} ' f'txid {hash_to_hex_str(txid_rev)}') if n is None: lines.append('No history found') n = None utxos = await db.all_utxos(hashX) for n, utxo in enumerate(utxos, start=1): lines.append(f'UTXO #{n:,d}: txid ' f'{hash_to_hex_str(utxo.txid_rev)} ' f'tx_pos {utxo.tx_pos:,d} height ' f'{utxo.height:,d} value {utxo.value:,d}') if n == limit: break if n is None: lines.append('No UTXOs found') balance = sum(utxo.value for utxo in utxos) lines.append(f'Balance: {coin.decimal_value(balance):,f} ' f'{coin.SHORTNAME}') return lines async def rpc_sessions(self): '''Return statistics about connected sessions.''' return self._session_data(for_log=False) async def rpc_reorg(self, count: int) -> str: '''Force a reorg of the given number of blocks. count: number of blocks to reorg ''' count = non_negative_integer(count) if not self.bp.force_chain_reorg(count): raise RPCError(BAD_REQUEST, 'still catching up with daemon') return f'scheduled a reorg of {count:,d} blocks' async def rpc_debug_memusage_list_all_objects(self, limit: int) -> str: """Return a string listing the most common types in memory.""" import objgraph # optional dependency import io with io.StringIO() as fd: objgraph.show_most_common_types( limit=limit, shortnames=False, file=fd) return fd.getvalue() async def rpc_debug_memusage_get_random_backref_chain(self, objtype: str) -> str: """Return a dotfile as text containing the backref chain for a randomly selected object of type objtype. Warning: very slow! and it blocks the server. To convert to image: $ dot -Tps filename.dot -o outfile.ps """ import objgraph # optional dependency import random import io with io.StringIO() as fd: await run_in_thread( lambda: objgraph.show_chain( objgraph.find_backref_chain( random.choice(objgraph.by_type(objtype)), objgraph.is_proper_module), output=fd)) return fd.getvalue() # --- External Interface async def serve(self, notifications: 'Notifications', event: asyncio.Event) -> None: '''Start the RPC server if enabled. When the event is triggered, start TCP and SSL servers.''' try: await self._start_servers(service for service in self.env.services if service.protocol == 'rpc') await event.wait() session_class = self.env.coin.SESSIONCLS session_class.cost_soft_limit = self.env.cost_soft_limit session_class.cost_hard_limit = self.env.cost_hard_limit session_class.cost_decay_per_sec = session_class.cost_hard_limit / 10000 session_class.bw_cost_per_byte = 1.0 / self.env.bw_unit_cost session_class.cost_sleep = self.env.request_sleep / 1000 session_class.initial_concurrent = self.env.initial_concurrent session_class.processing_timeout = self.env.request_timeout self.logger.info(f'max session count: {self.env.max_sessions:,d}') self.logger.info(f'session timeout: {self.env.session_timeout:,d} seconds') self.logger.info(f'session cost hard limit {self.env.cost_hard_limit:,d}') self.logger.info(f'session cost soft limit {self.env.cost_soft_limit:,d}') self.logger.info(f'bandwidth unit cost {self.env.bw_unit_cost:,d}') self.logger.info(f'request sleep {self.env.request_sleep:,d}ms') self.logger.info(f'request timeout {self.env.request_timeout:,d}s') self.logger.info(f'initial concurrent {self.env.initial_concurrent:,d}') self.logger.info(f'max send (response) size {self.env.max_send:,d} bytes') self.logger.info(f'max recv (request) size {self.env.max_recv:,d} bytes') if self.env.drop_client is not None: self.logger.info( f'drop clients matching: {self.env.drop_client.pattern}' ) for service in self.env.report_services: self.logger.info(f'advertising service {service}') # Start notifications; initialize hsub_results await notifications.start(self.db.db_height, self._notify_sessions) await self._start_external_servers() # Peer discovery should start after the external servers # because we connect to ourself async with self._task_group as group: await group.spawn(self.peer_mgr.discover_peers()) await group.spawn(self._clear_stale_sessions()) await group.spawn(self._handle_chain_reorgs()) await group.spawn(self._recalc_concurrency()) await group.spawn(self._log_sessions()) await group.spawn(self._manage_servers()) finally: # Stop listening on servers, so no new sessions can be created self.logger.info(f'stop listening on servers, so no new sessions can be created') await self._stop_servers(self.servers.keys()) # Then close sessions. # note: only best-effort. sessions that are still doing early-handshake are not yet put # into the sessions dict. self.logger.info(f'closing {len(self.sessions):,d} active sessions') async with OldTaskGroup() as group: for session in list(self.sessions): await group.spawn(session.close(force_after=1)) # Finally, wait for servers to be cleaned up and remove servers self.logger.info(f"waiting for all server's resources to close") try: async with timeout_after(3): async with OldTaskGroup() as group: for server in self.servers.values(): await group.spawn(server.wait_closed()) except TaskTimeout: self.logger.warning('timed out waiting for server resources to close') servers_to_remove = list(self.servers.keys()) self._remove_servers(servers_to_remove) self.logger.info(f"all servers closed") def extra_cost(self, session: 'SessionBase') -> float: # Note there is no guarantee that session is still in self.sessions. Example traceback: # notify_sessions->notify->address_status->bump_cost->recalc_concurrency->extra_cost # during which there are many places the sesssion could be removed groups = self.sessions.get(session) if groups is None: return 0 return sum((group.cost() - session.cost) * group.weight for group in groups) def get_block_ref_for_chaintip(self) -> BlockRef: """Returns a BlockRef for the current chaintip of the DB. Raises ChainSyncError if the mempool is not up-to-date with the DB. """ # daemon_height = self.daemon.cached_height() bp_height = self.bp.height bp_tip = self.bp.tip db_height = self.db.db_height db_tip = self.db.db_tip notif_height = self.notified_height notif_tip = self.notified_tip # example propagation: daemon_height --(10s)-> bp_height --(0.1s)-> db_height --(1s)-> notified_height # note: we only care about internal consistency: we allow daemon_height to differ. This also # significantly reduces the size of the critical time window. # We try to give the illusion of a consistent monolithic information-source to the client. # Most protocol method handlers only use e-x's db and mempool to resolve requests, # the few that also query the daemon need to implement their own logic to make sure # the state they present is consistent with e-x's current db state. if not (bp_height == db_height == notif_height): raise ChainSyncError(message="DB/mempool still catching up with each other") if not (bp_tip == db_tip == notif_tip): raise ChainSyncError(message="DB/mempool still catching up with each other") assert db_height >= 0 assert db_tip is not None return BlockRef( height=db_height, hash_rev=db_tip, ) async def _merkle_branch( self, height: int, txids_rev: Sequence[bytes], tx_pos: int, ) -> tuple[Sequence[str], float]: tx_count = len(txids_rev) cost = tx_count if tx_count >= 200: self._merkle_txid_cache.num_lookups += 1 merkle_cache = self._merkle_txid_cache.get(height) if merkle_cache: self._merkle_txid_cache.num_hits += 1 cost = 10 * math.sqrt(tx_count) else: async def tx_hashes_func(start, count): return txids_rev[start: start + count] merkle_cache = MerkleCache(self.db.merkle, tx_hashes_func) self._merkle_txid_cache[height] = merkle_cache await merkle_cache.initialize(len(txids_rev)) branch, _root = await merkle_cache.branch_and_root(tx_count, tx_pos) else: branch, _root = self.db.merkle.branch_and_root(txids_rev, tx_pos) branch = [hash_to_hex_str(hash) for hash in branch] return branch, cost / 2500 async def merkle_branch_for_txid( self, *, txid_rev: bytes, height: int, ) -> Tuple[Sequence[str], int, bytes, float]: '''Return (branch, tx_pos, block_header, cost).''' block_header = await self.raw_header(height) txids_rev, txids_cost = await self.txids_rev_at_blockheight(height) try: tx_pos = txids_rev.index(txid_rev) except ValueError: raise RPCError(BAD_REQUEST, f'tx {hash_to_hex_str(txid_rev)} not in block at height {height:,d}') branch, merkle_cost = await self._merkle_branch(height, txids_rev, tx_pos) if block_header != await self.raw_header(height): raise ReorgWhileProcessingRequest() return branch, tx_pos, block_header, txids_cost + merkle_cost async def merkle_branch_for_tx_pos(self, height: int, tx_pos: int) -> tuple[Sequence[str], str, float]: '''Return a triple (branch, txid_hum, cost).''' txids_rev, txids_cost = await self.txids_rev_at_blockheight(height) try: txid_rev = txids_rev[tx_pos] except IndexError: raise RPCError(BAD_REQUEST, f'no tx at position {tx_pos:,d} in block at height {height:,d}') branch, merkle_cost = await self._merkle_branch(height, txids_rev, tx_pos) txid_hum = hash_to_hex_str(txid_rev) cost = txids_cost + merkle_cost return branch, txid_hum, cost async def txids_rev_at_blockheight(self, height: int) -> tuple[Sequence[bytes], float]: '''Returns a pair (txids_rev, cost). txids_rev is an ordered list of binary hashes, cost is an estimated cost of getting the hashes; cheaper if in-cache. Raises RPCError. ''' self._txids_cache.num_lookups += 1 txids_rev = self._txids_cache.get(height) if txids_rev: self._txids_cache.num_hits += 1 return txids_rev, 0.1 # Ensure the txids_rev are fresh before placing in the cache while True: reorg_count = self._reorg_count try: txids_rev = await self.db.txids_rev_at_blockheight(height) except self.db.DBError as e: raise RPCError(BAD_REQUEST, f'db error: {e!r}') if reorg_count == self._reorg_count: break self._txids_cache[height] = txids_rev return txids_rev, 0.25 + len(txids_rev) * 0.0001 def session_count(self): '''The number of connections that we've sent something to.''' return len(self.sessions) async def daemon_request(self, method: str, *args): '''Catch a DaemonError and convert it to an RPCError.''' try: return await getattr(self.daemon, method)(*args) except DaemonError as e: raise RPCError(DAEMON_ERROR, f'daemon error: {e!r}') from None async def raw_header(self, height: int) -> bytes: '''Return the binary header at the given height.''' try: return await self.db.raw_header(height) except IndexError: raise RPCError(BAD_REQUEST, f'height {height:,d} ' 'out of range') from None async def broadcast_transaction(self, raw_tx: str) -> str: txid_hum = await self.daemon.broadcast_transaction(raw_tx) self.txs_sent += 1 # bump counter for Manager return txid_hum async def broadcast_package(self, tx_package: Sequence[str]) -> dict: result = await self.daemon.broadcast_package(tx_package) self.txs_sent += len(tx_package) # bump counter for Manager return result async def limited_history(self, hashX: bytes) -> tuple[Sequence[tuple[bytes, int]], float]: '''Returns a pair (history, cost). History is a sorted list of (txid_rev, height) tuples, or an RPCError.''' # History DoS limit. Each element of history is about 99 bytes when encoded # as JSON. max_byte_size_of_history_list = self.env.max_send - 100 # deduct a bit for overhead limit = max_byte_size_of_history_list // 99 assert limit > 0 cost = 0.1 self._history_cache.num_lookups += 1 try: # TODO include chaintip in cache entry, validate it hasn't changed? # The cache is invalidated when notified_height changes. result = self._history_cache[hashX] self._history_cache.num_hits += 1 except KeyError: result = await self.db.limited_history(hashX, limit=limit) cost += 0.1 + len(result) * 0.001 if len(result) >= limit: result = RPCError(RPC_ERROR_HISTORY_TOO_LONG, f'history too large', cost=cost) self._history_cache[hashX] = result assert result is not None if isinstance(result, Exception): raise result return result, cost async def _notify_sessions( self, *, touched_hashxs: Set[bytes], touched_outpoints: Set[TxOutpoint], height: int, ) -> None: '''Notify sessions about height changes and touched addresses.''' height_changed = height != self.notified_height if height_changed: await self._refresh_hsub_results(height) # Invalidate our history cache for touched hashXs for hashX in set(self._history_cache).intersection(touched_hashxs): del self._history_cache[hashX] # Invalidate our txo-status cache for touched outpoints for txo in set(self.oc_txo_status_cache).intersection(touched_outpoints): del self.oc_txo_status_cache[txo] for session in self.sessions: if session.taskgroup.joined: continue # session already being closed, skip it # we run this in session.taskgroup, so raising will result in disconnecting just that session: coro = session.notify( touched_hashxs=touched_hashxs, touched_outpoints=touched_outpoints, height_changed=height_changed, ) try: await session.taskgroup.spawn(coro) except RuntimeError: if session.taskgroup.joined: pass # race: task group terminated just after we checked it before spawning else: raise def _ip_addr_group_name(self, session) -> Optional[str]: host = session.remote_address().host if isinstance(host, (IPv4Address, IPv6Address)): if host.is_private: # exempt private addresses return None if isinstance(host, IPv4Address): subnet_size = self.env.session_group_by_subnet_ipv4 subnet = IPv4Network(host).supernet(prefixlen_diff=32 - subnet_size) return str(subnet) elif isinstance(host, IPv6Address): subnet_size = self.env.session_group_by_subnet_ipv6 subnet = IPv6Network(host).supernet(prefixlen_diff=128 - subnet_size) return str(subnet) return 'unknown_addr' def _session_group(self, name: Optional[str], weight: float) -> Optional[SessionGroup]: if name is None: return None group = self.session_groups.get(name) if not group: group = SessionGroup(name, weight, set(), 0) self.session_groups[name] = group return group def add_session(self, session: 'SessionBase') -> None: self.session_event.set() # Return the session groups groups = ( self._session_group(self._ip_addr_group_name(session), 1.0), ) groups = tuple(group for group in groups if group is not None) self.sessions[session] = groups for group in groups: group.sessions.add(session) def remove_session(self, session: 'SessionBase') -> None: '''Remove a session from our sessions list if there.''' self.session_event.set() groups = self.sessions.pop(session) for group in groups: group.retained_cost += session.cost group.sessions.remove(session) class RPCSessionWithTaskGroup(RPCSession): def __init__(self, *args, manager_taskgroup: OldTaskGroup, **kwargs): RPCSession.__init__(self, *args, **kwargs) self._manager_taskgroup = manager_taskgroup self.taskgroup = OldTaskGroup() asyncio.get_event_loop().create_task( self._start_main_loop()) async def _start_main_loop(self) -> None: if self._manager_taskgroup.joined: # this can happen during shutdown self.logger.warning( f"manager_taskgroup already terminated. closing session during its init.") await self.close(force_after=1.0) return try: await self._manager_taskgroup.spawn(self.main_loop()) except RuntimeError: if self._manager_taskgroup.joined: pass # race: task group terminated just after we checked it before spawning else: raise async def main_loop(self) -> None: """Manages taskgroup tied to this session. The session and the taskgroup share a lifecycle, either dying will kill the other. This method must not raise, to avoid killing the manager_taskgroup. """ self.logger.debug("starting taskgroup.") try: async with self.taskgroup as group: await group.spawn(asyncio.Event().wait) # run forever (until cancel) except GracefulDisconnect as e: pass except Exception as e: self.logger.exception("taskgroup died.") finally: try: await self.close(force_after=1.0) except Exception: self.logger.exception("unexpected exception while closing session") self.logger.debug("taskgroup stopped.") async def connection_lost(self): """Handle client disconnection.""" await self.taskgroup.cancel_remaining() await super().connection_lost() class SessionBase(RPCSessionWithTaskGroup): '''Base class of ElectrumX JSON sessions. Each session runs its tasks in asynchronous parallelism with other sessions. ''' MAX_CHUNK_SIZE = 2016 session_counter = itertools.count() log_new = False request_handlers: Dict[str, Callable] notification_handlers: Dict[str, Callable] def __init__( self, transport, *, session_mgr: 'SessionManager', db: 'DB', mempool: 'MemPool', peer_mgr: 'PeerManager', kind: str, ): connection = JSONRPCConnection(JSONRPCAutoDetect) super().__init__( transport, manager_taskgroup=session_mgr._task_group, connection=connection, ) self.session_mgr = session_mgr self.db = db self.mempool = mempool self.peer_mgr = peer_mgr self.kind = kind # 'RPC', 'TCP' etc. self.env = session_mgr.env self.coin = self.env.coin self.client_longname = 'unknown' self.sv_seen = False # has seen 'server.version' message? self.sv_negotiated = asyncio.Event() # done negotiating protocol version self.anon_logs = self.env.anon_logs self.txs_sent = 0 self.log_me = SessionBase.log_new self.session_id = None # type: int self.daemon_request = self.session_mgr.daemon_request self.session_id = next(self.session_counter) context = {'conn_id': f'{self.session_id}'} logger = util.class_logger(__name__, self.__class__.__name__) self.logger = util.ConnectionLogger(logger, context) self.logger.info(f'{self.kind} {self.remote_address_string()}, ' f'{self.session_mgr.session_count():,d} total') self._method_counts = defaultdict(int) self.session_mgr.add_session(self) self.recalc_concurrency() # must be called after session_mgr.add_session def client_shortname(self): return self.client_longname[:17] async def notify( self, *, touched_hashxs: Set[bytes], touched_outpoints: Set[TxOutpoint], height_changed: bool, ) -> None: pass def default_framer(self): return NewlineFramer(max_size=self.env.max_recv) def remote_address_string(self, *, for_log: bool = True) -> str: '''Returns the peer's IP address and port as a human-readable string, respecting anon logs if the output is for a log.''' if for_log and self.anon_logs: return 'xx.xx.xx.xx:xx' return str(self.remote_address()) def flags(self) -> str: '''Status flags.''' status = self.kind[0] if self.is_closing(): status += 'C' if self.log_me: status += 'L' status += str(self._incoming_concurrency.max_concurrent) return status async def connection_lost(self): '''Handle client disconnection.''' await super().connection_lost() self.session_mgr.remove_session(self) msg = '' if self._incoming_concurrency.max_concurrent < self.initial_concurrent * 0.8: msg += ' whilst throttled' if self.send_size >= 1_000_000: msg += f'. Sent {self.send_size:,d} bytes in {self.send_count:,d} messages' if msg: msg = 'disconnected' + msg self.logger.info(msg) def sub_count_scripthashes(self) -> int: return 0 def sub_count_txoutpoints(self) -> int: return 0 def sub_count_total(self) -> int: return self.sub_count_scripthashes() + self.sub_count_txoutpoints() async def handle_request(self, request: SingleRequest): '''Handle an incoming request.''' handler = None if isinstance(request, Request): handler = self.request_handlers.get(request.method) elif isinstance(request, Notification): handler = self.notification_handlers.get(request.method) method = 'invalid method' if handler is None else request.method # Version negotiation must happen before any other messages. if not self.sv_seen and method != 'server.version': self.logger.info(f'closing session: server.version must be first msg. got: {method}') await self._do_crash_old_electrum_client() raise ReplyAndDisconnect(RPCError( BAD_REQUEST, f'use server.version to identify client')) # Wait for version negotiation to finish before processing other messages. if method != 'server.version' and not self.sv_negotiated.is_set(): await self.sv_negotiated.wait() self._method_counts[method] += 1 self.session_mgr._method_counts[method] += 1 coro = handler_invocation(handler, request)() return await coro def protocol_version_string(self) -> str: raise NotImplementedError() async def maybe_crash_old_client( self, ptuple: Optional[tuple[int, ...]], crash_client_ver: Optional[tuple[int, ...]], ) -> None: if crash_client_ver: client_ver = util.protocol_tuple(self.client_longname) is_old_protocol = ptuple is None or ptuple <= (1, 2) is_old_client = client_ver != (0,) and client_ver <= crash_client_ver if is_old_protocol and is_old_client: await self._do_crash_old_electrum_client() async def _do_crash_old_electrum_client(self): self.logger.info(f'attempting to crash old client with version {self.client_shortname()}') # this can crash electrum client 2.6 <= v < 3.1.2 await self.send_notification('blockchain.relayfee', ()) # this can crash electrum client (v < 2.8.2) UNION (3.0.0 <= v < 3.3.0) await self.send_notification('blockchain.estimatefee', ()) class ElectrumX(SessionBase): '''A TCP server that handles incoming Electrum connections.''' PROTOCOL_MIN = (1, 4) # consider bumping Coin.MIN_REQUIRED_DAEMON_VERSION too when releasing a new protocol version PROTOCOL_MAX = (1, 7, 0) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.subscribe_headers = False self.connection.max_response_size = self.env.max_send self.hashX_subs = {} # type: Dict[bytes, str] # hashX -> scripthash self.txoutpoint_subs = set() # type: Set[TxOutpoint] # (txid_rev, txout_idx) self.mempool_hashX_statuses = {} # type: Dict[bytes, str] self.mempool_txoutpoint_statuses = {} # type: Dict[TxOutpoint, TXOSpendStatus] self.set_request_handlers(self.PROTOCOL_MIN) self.is_peer = False self.cost = 5.0 # Connection cost @classmethod def protocol_min_max_strings(cls) -> tuple[str, str]: return tuple( util.version_string(ver) for ver in (cls.PROTOCOL_MIN, cls.PROTOCOL_MAX)) @classmethod def server_features(cls, env: 'Env') -> dict[str, Any]: '''Return the server features dictionary.''' hosts_dict = {} for service in env.report_services: port_dict = hosts_dict.setdefault(str(service.host), {}) if service.protocol not in port_dict: port_dict[f'{service.protocol}_port'] = service.port min_str, max_str = cls.protocol_min_max_strings() return { 'hosts': hosts_dict, 'pruning': None, 'server_version': electrumx.version, 'protocol_min': min_str, 'protocol_max': max_str, 'genesis_hash': env.coin.GENESIS_HASH, 'hash_function': 'sha256', # FIXME should only be present for proto < 1.7 'services': [str(service) for service in env.report_services], } async def phandle_server_features_async(self) -> dict[str, Any]: self.bump_cost(0.2) features = self.server_features(self.env) if self.protocol_tuple >= (1, 7): features.pop('hash_function', None) return features @classmethod def server_version_args(cls): '''The arguments to a server.version RPC call to a peer.''' return [electrumx.version, cls.protocol_min_max_strings()] def protocol_version_string(self): return util.version_string(self.protocol_tuple) def extra_cost(self) -> float: return self.session_mgr.extra_cost(self) def on_disconnect_due_to_excessive_session_cost(self): remote_addr = self.remote_address() ip_addr = remote_addr.host if remote_addr else None groups = self.session_mgr.sessions[self] group_names = [group.name for group in groups] self.logger.info(f"closing session over res usage. ip: {ip_addr}. groups: {group_names}") def sub_count_scripthashes(self): return len(self.hashX_subs) def sub_count_txoutpoints(self): return len(self.txoutpoint_subs) def unsubscribe_hashX(self, hashX: bytes) -> Optional[str]: self.mempool_hashX_statuses.pop(hashX, None) return self.hashX_subs.pop(hashX, None) async def notify( self, *, touched_hashxs: Set[bytes], touched_outpoints: Set[TxOutpoint], height_changed: bool, ): """Send notifications. If we raise, we will disconnect from just this session. (websockets raise exceptions for unclear reasons?) """ try: async with timeout_after(30): await self._notify_inner( touched_hashxs=touched_hashxs, touched_outpoints=touched_outpoints, height_changed=height_changed, ) except TaskTimeout as e: self.logger.warning( f"timeout notifying client, closing... " f"sub_count: sh={self.sub_count_scripthashes()}, txo={self.sub_count_txoutpoints()}." ) raise GracefulDisconnect from e except RPCError as e: self.logger.warning( f"RPCError while notifying client, closing... " f"sub_count: sh={self.sub_count_scripthashes()}, txo={self.sub_count_txoutpoints()}. " f"RPCError: {e}" ) raise GracefulDisconnect from e async def _notify_inner( self, *, touched_hashxs: Set[bytes], touched_outpoints: Set[TxOutpoint], height_changed: bool, ) -> None: '''Notify the client about changes to touched addresses (from mempool updates or new blocks) and height. ''' cnt_sent = 0 # block headers if height_changed and self.subscribe_headers: args = (await self.subscribe_headers_result(), ) await self.send_notification('blockchain.headers.subscribe', args) cnt_sent += 1 # hashXs num_hashx_notifs_sent = 0 touched_hashxs = touched_hashxs.intersection(self.hashX_subs) if touched_hashxs or (height_changed and self.mempool_hashX_statuses): changed = {} # type: dict[str, Optional[str]] for hashX in touched_hashxs: scripthash = self.hashX_subs.get(hashX) if scripthash: status = await self.subscription_address_status(hashX) changed[scripthash] = status # Check mempool hashXs - the status is a function of the confirmed state of # other transactions. (this is to detect if height changed from -1 to 0) mempool_hashX_statuses = self.mempool_hashX_statuses.copy() for hashX, old_status in mempool_hashX_statuses.items(): scripthash = self.hashX_subs.get(hashX) if scripthash: status = await self.subscription_address_status(hashX) if status != old_status: changed[scripthash] = status if self.protocol_tuple >= (1, 7): method = 'blockchain.scriptpubkey.subscribe' else: method = 'blockchain.scripthash.subscribe' for scripthash, status in changed.items(): await self.send_notification(method, (scripthash, status)) cnt_sent += 1 num_hashx_notifs_sent = len(changed) # tx outpoints num_txo_notifs_sent = 0 touched_outpoints = touched_outpoints.intersection(self.txoutpoint_subs) if touched_outpoints or (height_changed and self.mempool_txoutpoint_statuses): method = 'blockchain.outpoint.subscribe' txo_to_status = {} # type: dict[TxOutpoint, TXOSpendStatus] for prevout in touched_outpoints: # TODO taskgroup? txo_to_status[prevout] = await self.txoutpoint_status_for_notif(*prevout) # can raise RPCError # Check mempool TXOs - the status is a function of the confirmed state of # other transactions. (this is to detect if height changed from -1 to 0) mempool_txoutpoint_statuses = self.mempool_txoutpoint_statuses.copy() for prevout, old_status in mempool_txoutpoint_statuses.items(): # TODO taskgroup? status = await self.txoutpoint_status_for_notif(*prevout) # can raise RPCError if status != old_status: txo_to_status[prevout] = status for txid_rev, txout_idx in touched_outpoints: spend_status = txo_to_status[(txid_rev, txout_idx)] spend_status_dict = self._convert_txospendstatus_to_protocol_dict(spend_status) tx_hash_hex = hash_to_hex_str(txid_rev) await self.send_notification(method, (tx_hash_hex, txout_idx, spend_status_dict)) cnt_sent += 1 num_txo_notifs_sent = len(touched_outpoints) # log (number of useful notifications we sent) if num_hashx_notifs_sent + num_txo_notifs_sent > 0: es1 = '' if num_hashx_notifs_sent == 1 else 'es' s2 = '' if num_txo_notifs_sent == 1 else 's' self.logger.info(f'notified of {num_hashx_notifs_sent:,d} address{es1} and ' f'{num_txo_notifs_sent:,d} outpoint{s2}') # maybe send some noise if self.protocol_tuple >= (1, 7): if height_changed: # on block if cnt_sent < 2: await self.send_ping_notification_to_client(data_len=128) # similar len to bc.spk.sub while random.random() < 0.1: await self.send_ping_notification_to_client(data_len=128) else: # on mempool once_per_10_minutes = self.mempool.refresh_secs / 600 if random.random() < once_per_10_minutes: await self.send_ping_notification_to_client(data_len=128) async def subscribe_headers_result(self): '''The result of a header subscription or notification.''' return self.session_mgr.hsub_results async def phandle_headers_subscribe(self): '''Subscribe to get raw headers of new blocks.''' self.subscribe_headers = True self.bump_cost(0.25) return await self.subscribe_headers_result() async def phandle_add_peer(self, features: dict[str, Any] | Any): '''Add a peer (but only if the peer resolves to the source).''' self.is_peer = True self.bump_cost(100.0) return await self.peer_mgr.on_add_peer(features, self.remote_address()) async def phandle_peers_subscribe(self): '''Return the server peers as a list of (ip, host, details) tuples.''' self.bump_cost(1.0) return self.peer_mgr.on_peers_subscribe(self.is_tor()) async def address_status(self, hashX: bytes) -> Optional[str]: '''Returns an address status. Status is a hex string, but must be None if there is no history. Can raise RPCError. Side-effect: updates client-last-seen status, used by notifications. ''' # Note both confirmed history and mempool history are ordered # For mempool, height is -1 if it has unconfirmed inputs, otherwise 0 db_history, cost = await self.session_mgr.limited_history(hashX) mempool = await self.mempool.transaction_summaries(hashX) status = ''.join(f'{hash_to_hex_str(tx_hash)}:' f'{height:d}:' for tx_hash, height in db_history) status += ''.join(f'{hash_to_hex_str(tx.txid_rev)}:' f'{-tx.has_unconfirmed_inputs:d}:' for tx in mempool) # Add status hashing cost self.bump_cost(cost + 0.1 + len(status) * 0.00002) if status: status = sha256(status.encode()).hex() else: status = None # update status last sent to client if mempool: self.mempool_hashX_statuses[hashX] = status else: self.mempool_hashX_statuses.pop(hashX, None) return status async def subscription_address_status(self, hashX: bytes) -> Optional[str]: '''As for address_status, but if it can't be calculated the subscription is discarded.''' try: return await self.address_status(hashX) except RPCError: self.unsubscribe_hashX(hashX) return None async def _calc_oc_txo_status(self, funder_txid_rev: bytes, txout_idx: int) -> 'TXOSpendStatus': """For an outpoint, returns its spend-status (ignoring mempool events). Uses the daemon to find the spender_txhash, requiring "txospenderindex=1". However, mempool events are ignored, as it would be difficult to distinguish block height 0 vs -1 using only the daemon. Instead, our own mempool data (as opposed to the daemon's) can be used separately to enrich the return value. Can raise RPCError. """ funder_txid_hum = hash_to_hex_str(funder_txid_rev) # 1. call the daemon "getrawtransaction" to see if prevtx exists/is_mined self.bump_cost(1) try: funder_item = await self.session_mgr.daemon.getrawtransaction(funder_txid_hum, verbose=True) # verbose=int(1) except DaemonError as e: error, = e.args ecode = error['code'] if ecode == -5: # "No such mempool or blockchain transaction." return TXOSpendStatus(funder_height=None) # utxo never existed self.logger.debug(f"getrawtransaction errored. {funder_txid_hum=}. {error=}") raise RPCError(DAEMON_ERROR, f'daemon error: {error!r}') from None assert funder_item.get("txid") == funder_txid_hum, f"{funder_item.get('txid')=} != {funder_txid_hum=}" funder_bhash = funder_item.get("blockhash") funder_bheight = None # type: Optional[int] if funder_bhash is not None: # If the daemon's height is higher than our db.db_height, or the daemon *just* reorged, # the db might fail to map this block_hash to a height: that's good for internal consistency. # If our db has not caught up to a change yet, that change should be hidden from clients. funder_bheight = self.db.get_blockheight_from_blockhash(funder_bhash) if funder_bheight is None: # if in mempool, will defer to mempool.spender_for_txo return TXOSpendStatus(funder_height=None) # utxo never existed (in chain) assert isinstance(funder_bheight, int) # ok, funding tx exists, does the requested output index also exist in this tx? vouts = funder_item.get("vout") or [] if len(vouts) <= txout_idx: return TXOSpendStatus(funder_height=None) # txout_idx was out-of-bounds # by now we know the funding TXO existed in the chain. Let's see if it was spent. # 2. call the daemon "gettxspendingprevout" self.bump_cost(1) try: spender_item = await self.session_mgr.daemon.gettxspendingprevout(funder_txid_hum, txout_idx) except DaemonError as e: error, = e.args self.logger.debug(f"gettxspendingprevout errored. txo={funder_txid_hum}:{txout_idx}. {error=}") raise RPCError(DAEMON_ERROR, f'daemon error: {error!r}') from None assert spender_item.get("txid") == funder_txid_hum, f"{spender_item.get('txid')=} != {funder_txid_hum=}" # paranoia: check if funder tx got reorged while we were awaiting the daemon RPC if self.db.get_blockheight_from_blockhash(funder_bhash) is None: raise ReorgWhileProcessingRequest() spender_bhash = spender_item.get("blockhash") spender_bheight = None if spender_bhash is not None: spender_bheight = self.db.get_blockheight_from_blockhash(spender_bhash) if spender_bheight is None: # if in mempool, will defer to mempool.spender_for_txo return TXOSpendStatus(funder_height=funder_bheight) # utxo funded but unspent (in-chain) spender_txid = spender_item.get("spendingtxid") assert spender_txid is not None # we already have a height! # utxo funded, and spent (in-chain) return TXOSpendStatus( funder_height=funder_bheight, spender_txid_rev=hex_str_to_hash(spender_txid), spender_height=spender_bheight, ) def _convert_txospendstatus_to_protocol_dict(self, spend_status: 'TXOSpendStatus') -> dict[str, Any]: # convert to json dict the client expects d = {} if spend_status.funder_height is not None: d['funder_height'] = spend_status.funder_height if spend_status.spender_txid_rev is not None: assert spend_status.spender_height is not None d['spender_txhash'] = hash_to_hex_str(spend_status.spender_txid_rev) d['spender_height'] = spend_status.spender_height return d async def _calc_txoutpoint_status(self, prev_txid_rev: bytes, txout_idx: int) -> 'TXOSpendStatus': """Can raise RPCError""" self.bump_cost(0.1) prevout = (prev_txid_rev, txout_idx) # first, consider only on-chain mined events, and check cache first (to avoid daemon RPC calls) self.session_mgr.oc_txo_status_cache.num_lookups += 1 try: oc_status = self.session_mgr.oc_txo_status_cache[prevout] self.session_mgr.oc_txo_status_cache.num_hits += 1 except KeyError: oc_status = await self._calc_oc_txo_status(prev_txid_rev, txout_idx) # "on-chain" status self.session_mgr.oc_txo_status_cache[prevout] = oc_status assert oc_status is not None # let's see if we also need to consider the mempool if oc_status.spender_height is not None: # TXO was created, was mined, was spent, and spend was mined. assert oc_status.funder_height > 0 assert oc_status.spender_height > 0 assert oc_status.spender_txid_rev is not None ret = oc_status else: # mempool is still relevant self.bump_cost(0.1) mp_status = await self.mempool.spender_for_txo(prev_txid_rev, txout_idx) ret = TXOSpendStatus( funder_height=mp_status.funder_height if mp_status.funder_height is not None else oc_status.funder_height, spender_txid_rev=mp_status.spender_txid_rev if mp_status.spender_txid_rev is not None else oc_status.spender_txid_rev, spender_height=mp_status.spender_height if mp_status.spender_height is not None else oc_status.spender_height, ) return ret async def txoutpoint_status_for_notif(self, prev_txid_rev: bytes, txout_idx: int) -> 'TXOSpendStatus': """Can raise RPCError Side-effect: updates client-last-seen status, used by notifications. """ status = await self._calc_txoutpoint_status(prev_txid_rev=prev_txid_rev, txout_idx=txout_idx) # update status last sent to client prevout = (prev_txid_rev, txout_idx) fh = status.funder_height sh = status.spender_height if ((fh is not None and fh <= 0) or (sh is not None and sh <= 0)): self.mempool_txoutpoint_statuses[prevout] = status else: self.mempool_txoutpoint_statuses.pop(prevout, None) return status async def hashX_listunspent(self, hashX: bytes) -> Sequence[dict[str, Any]]: '''Return the list of UTXOs of a script hash, including mempool effects.''' utxos = await self.db.all_utxos(hashX) utxos = sorted(utxos) utxos.extend(await self.mempool.unordered_UTXOs(hashX)) self.bump_cost(1.0 + len(utxos) / 50) spends = await self.mempool.potential_spends(hashX) return [{'tx_hash': hash_to_hex_str(utxo.txid_rev), 'tx_pos': utxo.tx_pos, 'height': utxo.height, 'value': utxo.value} for utxo in utxos if (utxo.txid_rev, utxo.tx_pos) not in spends] async def hashX_subscribe(self, hashX: bytes, scripthash: str) -> Optional[str]: # Store the subscription only after address_status succeeds result = await self.address_status(hashX) self.hashX_subs[hashX] = scripthash return result async def get_balance(self, hashX: bytes) -> dict[str, Any]: utxos = await self.db.all_utxos(hashX) confirmed = sum(utxo.value for utxo in utxos) unconfirmed = await self.mempool.balance_delta(hashX) self.bump_cost(1.0 + len(utxos) / 50) return {'confirmed': confirmed, 'unconfirmed': unconfirmed} @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scripthash_get_balance(self, scripthash: str | Any) -> dict[str, Any]: '''Return the confirmed and unconfirmed balance of a scripthash.''' hashX = scripthash_to_hashX(scripthash) return await self.get_balance(hashX) async def unconfirmed_history(self, hashX: bytes) -> list[dict[str, Any]]: # Note both confirmed history and mempool history are ordered # height is -1 if it has unconfirmed inputs, otherwise 0 result = [{'tx_hash': hash_to_hex_str(tx.txid_rev), 'height': -tx.has_unconfirmed_inputs, 'fee': tx.fee} for tx in await self.mempool.transaction_summaries(hashX)] self.bump_cost(0.25 + len(result) / 50) return result async def confirmed_and_unconfirmed_history(self, hashX: bytes) -> list[dict[str, Any]]: # Note both confirmed history and mempool history are ordered history, cost = await self.session_mgr.limited_history(hashX) self.bump_cost(cost) conf = [{'tx_hash': hash_to_hex_str(txid_rev), 'height': height} for txid_rev, height in history] unconf = await self.unconfirmed_history(hashX) # note: the same tx could appear both in conf and unconf, if it was *just* mined. # - more complicated example: tx1 could have been just mined, while # our not-caught-up-mempool still contains a conflicting tx2. # we should avoid sending an inconsistent response... # _history_cache makes this race unlikely: it could only happen # if we *also* get a cache-miss (but the cache is only cleared when notified_height is updated). # Conceptually the same issue affects other methods too, e.g. get_balance: # in general the DB and the mempool might be out-of-sync with each other. # This issue is eliminated if the caller is wrapped in @retry_on_chain_sync_error(check_chaintip=True). return conf + unconf @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scripthash_get_history(self, scripthash: str | Any) -> list[dict[str, Any]]: '''Return the confirmed and unconfirmed history of a scripthash.''' hashX = scripthash_to_hashX(scripthash) return await self.confirmed_and_unconfirmed_history(hashX) @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scripthash_get_mempool(self, scripthash: str | Any) -> list[dict[str, Any]]: '''Return the mempool transactions touching a scripthash.''' hashX = scripthash_to_hashX(scripthash) return await self.unconfirmed_history(hashX) @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scripthash_listunspent(self, scripthash: str | Any) -> Sequence[dict[str, Any]]: '''Return the list of UTXOs of a scripthash.''' hashX = scripthash_to_hashX(scripthash) return await self.hashX_listunspent(hashX) @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scripthash_subscribe(self, scripthash: str | Any) -> Optional[str]: '''Subscribe to a script hash. scripthash: the SHA256 hash of the script to subscribe to''' hashX = scripthash_to_hashX(scripthash) return await self.hashX_subscribe(hashX, scripthash) async def phandle_scripthash_unsubscribe(self, scripthash: str | Any): '''Unsubscribe from a script hash.''' self.bump_cost(0.1) hashX = scripthash_to_hashX(scripthash) return self.unsubscribe_hashX(hashX) is not None @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scriptpubkey_get_balance(self, spk: str | Any) -> dict[str, Any]: scripthash = spk_to_scripthash(spk) hashX = scripthash_to_hashX(scripthash) d = await self.get_balance(hashX) return d @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scriptpubkey_get_history(self, spk: str) -> dict[str, Any]: scripthash = spk_to_scripthash(spk) hashX = scripthash_to_hashX(scripthash) d = dict() d["history"] = await self.confirmed_and_unconfirmed_history(hashX) return d @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scriptpubkey_get_mempool(self, spk: str) -> dict[str, Any]: scripthash = spk_to_scripthash(spk) hashX = scripthash_to_hashX(scripthash) d = dict() d["history"] = await self.unconfirmed_history(hashX) return d @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scriptpubkey_listunspent(self, spk: str) -> dict[str, Any]: scripthash = spk_to_scripthash(spk) hashX = scripthash_to_hashX(scripthash) d = dict() d["utxos"] = await self.hashX_listunspent(hashX) return d @retry_on_chain_sync_error(check_chaintip=True) async def phandle_scriptpubkey_subscribe(self, spk: str) -> Optional[str]: scripthash = spk_to_scripthash(spk) return await self.phandle_scripthash_subscribe(scripthash) def phandle_scriptpubkey_unsubscribe(self, spk: str) -> collections.abc.Awaitable[bool]: scripthash = spk_to_scripthash(spk) return self.phandle_scripthash_unsubscribe(scripthash) @retry_on_chain_sync_error(check_chaintip=True) async def phandle_txoutpoint_get_status(self, tx_hash: str | Any, txout_idx: int | Any, spk_hint: str | Any) -> dict[str, Any]: '''Return the status of an outpoint, without subscribing. spk_hint: scriptPubKey corresponding to the outpoint. Might be used by other servers, but we don't need and hence ignore it. ''' txid_rev = assert_txid_hum(tx_hash) txout_idx = non_negative_integer(txout_idx) assert_hex_str(spk_hint) # calc status (but do not side-effect client-last-seen status) spend_status = await self._calc_txoutpoint_status(txid_rev, txout_idx) db_chaintip = self.session_mgr.get_block_ref_for_chaintip() if max(spend_status.funder_height or 0, spend_status.spender_height or 0) > db_chaintip.height: raise ChainSyncError(message="DB is lagging behind daemon") d = self._convert_txospendstatus_to_protocol_dict(spend_status) return d @retry_on_chain_sync_error(check_chaintip=True) async def phandle_txoutpoint_subscribe(self, tx_hash: str | Any, txout_idx: int | Any, spk_hint: str | Any) -> dict[str, Any]: '''Subscribe to an outpoint. spk_hint: scriptPubKey corresponding to the outpoint. Might be used by other servers, but we don't need and hence ignore it. ''' if not self.env.coin.DAEMON_HAS_TXO_SPENDER_INDEX: # the status of a subscribed outpoint is re-checked with the daemon at every block: without # support the subscription would be accepted now and then close the session raise RPCError(BAD_REQUEST, 'outpoint subscriptions are not supported: the daemon cannot look up ' 'the spender of an outpoint') txid_rev = assert_txid_hum(tx_hash) txout_idx = non_negative_integer(txout_idx) assert_hex_str(spk_hint) # calc status, update client-last-seen status. # if we can't calc the status, as e.g. the daemon errors, don't add the subscription spend_status = await self.txoutpoint_status_for_notif(txid_rev, txout_idx) db_chaintip = self.session_mgr.get_block_ref_for_chaintip() if max(spend_status.funder_height or 0, spend_status.spender_height or 0) > db_chaintip.height: raise ChainSyncError(message="DB is lagging behind daemon") # sub to outpoint self.txoutpoint_subs.add((txid_rev, txout_idx)) d = self._convert_txospendstatus_to_protocol_dict(spend_status) return d async def phandle_txoutpoint_unsubscribe(self, tx_hash: str | Any, txout_idx: int | Any) -> bool: '''Unsubscribe from an outpoint.''' txid_rev = assert_txid_hum(tx_hash) txout_idx = non_negative_integer(txout_idx) self.bump_cost(0.1) prevout = (txid_rev, txout_idx) was_subscribed = prevout in self.txoutpoint_subs self.txoutpoint_subs.discard(prevout) self.mempool_txoutpoint_statuses.pop(prevout, None) return was_subscribed async def _merkle_proof(self, cp_height: int, height: int) -> dict[str, Any]: max_height = self.db.db_height if not height <= cp_height <= max_height: raise RPCError(BAD_REQUEST, f'require header height {height:,d} <= ' f'cp_height {cp_height:,d} <= ' f'chain height {max_height:,d}') branch, root = await self.db.header_branch_and_root(cp_height + 1, height) return { 'branch': [hash_to_hex_str(elt) for elt in branch], 'root': hash_to_hex_str(root), } async def phandle_block_header(self, height: int, cp_height: int = 0) -> dict[str, Any]: '''Return a raw block header as a hexadecimal string, or as a dictionary with a merkle proof.''' height = non_negative_integer(height) cp_height = non_negative_integer(cp_height) raw_header_hex = (await self.session_mgr.raw_header(height)).hex() self.bump_cost(1.25 - (cp_height == 0)) if cp_height == 0: return raw_header_hex result = {'header': raw_header_hex} result.update(await self._merkle_proof(cp_height, height)) return result async def phandle_block_headers(self, start_height: int, count: int, cp_height: int = 0) -> dict[str, Any]: '''Return count concatenated block headers as hex for the main chain; starting at start_height. start_height and count must be non-negative integers. At most MAX_CHUNK_SIZE headers will be returned. ''' if self.protocol_tuple >= (1, 6): return await self.block_headers_array(start_height, count, cp_height) start_height = non_negative_integer(start_height) count = non_negative_integer(count) cp_height = non_negative_integer(cp_height) cost = count / 50 max_size = self.MAX_CHUNK_SIZE count = min(count, max_size) headers, count = await self.db.read_headers(start_height, count) result = {'hex': headers.hex(), 'count': count, 'max': max_size} if count and cp_height: cost += 1.0 last_height = start_height + count - 1 result.update(await self._merkle_proof(cp_height, last_height)) self.bump_cost(cost) return result async def block_headers_array(self, start_height: int, count: int, cp_height: int = 0) -> dict[str, Any]: '''Return block headers in an array for the main chain; starting at start_height. start_height and count must be non-negative integers. At most MAX_CHUNK_SIZE headers will be returned. ''' start_height = non_negative_integer(start_height) count = non_negative_integer(count) cp_height = non_negative_integer(cp_height) cost = count / 50 max_size = self.MAX_CHUNK_SIZE count = min(count, max_size) headers, count = await self.db.read_headers(start_height, count) result = {'count': count, 'max': max_size, 'headers': []} if count and cp_height: cost += 1.0 last_height = start_height + count - 1 result.update(await self._merkle_proof(cp_height, last_height)) cursor = 0 height = 0 while cursor < len(headers): next_cursor = self.db.header_offset(height + 1) header = headers[cursor:next_cursor] result['headers'].append(header.hex()) cursor = next_cursor height += 1 self.bump_cost(cost) return result def is_tor(self) -> bool: '''Try to detect if the connection is to a tor hidden service we are running.''' proxy_address = self.peer_mgr.proxy_address() if not proxy_address: return False remote_addr = self.remote_address() if not remote_addr: return False return remote_addr.host == proxy_address.host async def replaced_banner(self, banner: str) -> str: network_info = await self.daemon_request('getnetworkinfo') ni_version = network_info['version'] # e.g. 290100 (for /Satoshi:29.1.0/) major = ni_version // 10_000 minor = (ni_version % 10_000) // 100 revision = ni_version % 100 daemon_version = f'{major:d}.{minor:d}.{revision:d}' for pair in [ ('$SERVER_VERSION', electrumx.version_short), ('$SERVER_SUBVERSION', electrumx.version), ('$DAEMON_VERSION', daemon_version), ('$DAEMON_SUBVERSION', network_info['subversion']), ('$DONATION_ADDRESS', self.env.donation_address), ]: banner = banner.replace(*pair) return banner async def phandle_donation_address(self) -> str: '''Return the donation address as a string, empty if there is none.''' self.bump_cost(0.1) return self.env.donation_address async def phandle_banner(self) -> str: '''Return the server banner text.''' banner = f'You are connected to an {electrumx.version} server.' self.bump_cost(0.5) if self.is_tor(): banner_file = self.env.tor_banner_file else: banner_file = self.env.banner_file if banner_file: try: with codecs.open(banner_file, 'r', 'utf-8') as f: banner = f.read() except (OSError, UnicodeDecodeError) as e: self.logger.error(f'reading banner file {banner_file}: {e!r}') else: banner = await self.replaced_banner(banner) return banner async def phandle_relayfee(self): """The minimum fee required for a transaction to be relayed on by the daemon to the network. Doesn't guarantee mempool acceptance.""" self.bump_cost(1.0) return await self.daemon_request('relayfee') async def phandle_mempool_info(self) -> dict[str, float]: """ mempool.get_info, introduced in protocol 1.6. returns: { "mempoolminfee": PLM/kvB, "minrelaytxfee": PLM/kvB, "incrementalrelayfee": PLM/kvB, } """ self.bump_cost(1.0) return await self.daemon_request('mempool_info') async def phandle_mempool_recent(self) -> list[dict[str, Any]]: """ mempool.recent, introduced in protocol 1.6.1. Return a list of the last 10 transactions to enter the mempool. """ self.bump_cost(1.0) recent_txs = await self.mempool.get_recently_added_txs(count=10) return [{ "txid": hash_to_hex_str(tx.txid_rev), "fee": tx.fee, "vsize": tx.vsize, } for tx in recent_txs] async def phandle_estimatefee(self, number: int | Any, mode=None): '''The estimated transaction fee per kilobyte to be paid for a transaction to be included within a certain number of blocks. number: the number of blocks mode: CONSERVATIVE or ECONOMICAL estimation mode ''' number = non_negative_integer(number) # use whitelist for mode, otherwise it would be easy to force a cache miss: mode = mode.upper() if isinstance(mode, str) else None if mode not in self.coin.ESTIMATEFEE_MODES: raise RPCError(BAD_REQUEST, f'unknown estimatefee mode: {mode}') self.bump_cost(0.1) number = self.coin.bucket_estimatefee_block_target(number) cache = self.session_mgr.estimatefee_cache cache_item = cache.get((number, mode)) cache.num_lookups += 1 if cache_item is not None: blockhash, feerate, lock = cache_item if blockhash and blockhash == self.session_mgr.bp.tip: cache.num_hits += 1 return feerate else: # create lock now, store it, and only then await on it lock = asyncio.Lock() cache[(number, mode)] = (None, None, lock) async with lock: cache_item = cache.get((number, mode)) if cache_item is not None: blockhash, feerate, lock = cache_item if blockhash == self.session_mgr.bp.tip: cache.num_hits += 1 return feerate self.bump_cost(2.0) # cache miss incurs extra cost blockhash = self.session_mgr.bp.tip if mode: feerate = await self.daemon_request('estimatefee', number, mode) else: feerate = await self.daemon_request('estimatefee', number) assert feerate is not None assert blockhash is not None cache[(number, mode)] = (blockhash, feerate, lock) return feerate async def phandle_ping(self, pong_len=0, data=""): '''Serves as a connection keep-alive mechanism and for the client to confirm the server is still responding. It can also be used to obfuscate traffic patterns. ''' self.bump_cost(0.1) if self.protocol_tuple < (1, 7): return None assert_hex_str(data, allow_odd_len=True) pong_len = non_negative_integer(pong_len) if pong_len > self.env.max_send: raise RPCError(BAD_REQUEST, f'pong_len value too high') pong_data = pong_len * "0" ret = {"data": pong_data} return ret async def phandle_on_ping_notification(self, data=""): self.bump_cost(0.1) # note: the bw cost for receiving 'data' has already been incurred assert_hex_str(data, allow_odd_len=True) # nothing to do. # note: we could probabilistically send back a ping notif to the client, as noise, # but we don't. Leave such logic to the client: if they wanted a response, # they would have sent "server.ping" as a request instead of a notification. async def send_ping_notification_to_client(self, data_len: int) -> None: assert isinstance(data_len, int) and data_len >= 0, repr(data_len) data = "0" * data_len await self.send_notification("server.ping", (data,)) async def phandle_server_version( self, client_name='', protocol_version=None, *extra_args, **extra_kwargs, ): '''Returns the server version as a string. client_name: a string identifying the client protocol_version: the protocol version spoken by the client note: extraneous unknown args for 'server.version' MUST be tolerated and ignored by the server, to allow for future extensions. ''' self.bump_cost(0.5) if self.sv_seen: raise RPCError(BAD_REQUEST, f'server.version already sent') self.sv_seen = True if client_name: client_name = str(client_name) if self.env.drop_client is not None and \ self.env.drop_client.match(client_name): raise ReplyAndDisconnect(RPCError( BAD_REQUEST, f'unsupported client: {client_name}')) self.client_longname = client_name[:100] # Find the highest common protocol version. Disconnect if # that protocol version in unsupported. ptuple, client_min = util.protocol_version( protocol_version, self.PROTOCOL_MIN, self.PROTOCOL_MAX) await self.maybe_crash_old_client(ptuple, self.env.coin.CRASH_CLIENT_VER) if ptuple is None: if client_min > self.PROTOCOL_MIN: self.logger.info(f'client requested future protocol version ' f'{util.version_string(client_min)} ' f'- is your software out of date?') raise ReplyAndDisconnect(RPCError( BAD_REQUEST, f'unsupported protocol version: {protocol_version}')) self.set_request_handlers(ptuple) self.sv_negotiated.set() return electrumx.version, self.protocol_version_string() async def phandle_transaction_broadcast(self, raw_tx: str | Any) -> str: '''Broadcast a raw transaction to the network. raw_tx: the raw transaction as a hexadecimal string''' assert_hex_str(raw_tx) self.bump_cost(0.25 + len(raw_tx) / 5000) # This returns errors as JSON RPC errors, as is natural try: txid_hum = await self.session_mgr.broadcast_transaction(raw_tx) except DaemonError as e: error, = e.args message = error['message'] self.logger.info(f'error sending transaction: {message}') raise RPCError(BAD_REQUEST, 'the transaction was rejected by ' f'network rules.\n\n{message}\n[{raw_tx}]') else: self.txs_sent += 1 client_ver = util.protocol_tuple(self.client_longname) if client_ver != (0, ): msg = self.coin.warn_old_client_on_tx_broadcast(client_ver) if msg: self.logger.info(f'sent tx: {txid_hum}. and warned user to upgrade their ' f'client from {self.client_shortname()}') return msg self.logger.info(f'sent tx: {txid_hum}') return txid_hum async def phandle_package_broadcast(self, raw_txs: Sequence[str] | Any, verbose: bool = False) -> dict[str, Any]: """Broadcast a package of raw transactions to the network (submitpackage). The package must consist of a child with its parents, and none of the parents may depend on one another. raw_txs: a list of raw transactions as hexadecimal strings""" assert_list_or_tuple(raw_txs) for raw_tx in raw_txs: assert_hex_str(raw_tx) self.bump_cost(0.25 + sum(len(tx) / 5000 for tx in raw_txs)) try: daemon_result = await self.session_mgr.broadcast_package(raw_txs) except DaemonError as e: error, = e.args message = error['message'] self.logger.info(f"error submitting package: {message}") if error.get('code') == -32601: # method not found raise RPCError(BAD_REQUEST, 'the daemon does not support package submission (submitpackage)') raise RPCError( BAD_REQUEST, f'the tx package was rejected by network rules.\n\n{message}.', ) self.txs_sent += len(raw_txs) self.logger.info(f'broadcasted package: {len(raw_txs)=}') if verbose: return daemon_result response: dict[str, Union[bool, list]] = { 'success': daemon_result['package_msg'] == 'success', } errors = [] for tx in daemon_result.get('tx-results', {}).values(): if tx.get('error'): error_msg = { 'txid': tx.get('txid'), 'error': tx['error'] } errors.append(error_msg) if errors: response['errors'] = errors return response async def phandle_transaction_testmempoolaccept(self, raw_txs: Sequence[str]) -> Sequence[dict]: """Returns result of mempool acceptance tests indicating if txs would be accepted by mempool. raw_txs: a list of raw transactions as hexadecimal strings """ assert_list_or_tuple(raw_txs) for raw_tx in raw_txs: assert_hex_str(raw_tx) self.bump_cost(0.25 + sum(len(tx) / 5000 for tx in raw_txs)) daemon_result = await self.daemon_request("testmempoolaccept", raw_txs) response: list[dict] = [] for orig_item in daemon_result: # one item for each tx new_item = {"txid": orig_item["txid"]} if "wtxid" in orig_item: # not reported by older daemons (Palladium Core) new_item["wtxid"] = orig_item["wtxid"] # optional: "allowed" field if orig_item.get("allowed") in (True, False): new_item["allowed"] = orig_item["allowed"] # optional: "reason" field reason_str = ( orig_item.get("package-error") or orig_item.get("reject-details") or orig_item.get("reject-reason") or None) if reason_str is not None: new_item["reason"] = reason_str response.append(new_item) return response async def phandle_transaction_get(self, tx_hash: str | Any, verbose=False): '''Return the serialized raw transaction given its hash tx_hash: the transaction hash as a hexadecimal string verbose: passed on to the daemon ''' assert_txid_hum(tx_hash) if verbose not in (True, False): raise RPCError(BAD_REQUEST, '"verbose" must be a boolean') self.bump_cost(1.0) return await self.daemon_request('getrawtransaction', tx_hash, verbose) @retry_on_chain_sync_error(check_chaintip=False) async def phandle_transaction_merkle(self, tx_hash: str | Any, height: int | Any) -> dict[str, Any]: '''Return the merkle branch to a confirmed transaction given its hash and height. tx_hash: the transaction hash as a hexadecimal string height: the height of the block it is in ''' txid_rev = assert_txid_hum(tx_hash) height = non_negative_integer(height) branch, tx_pos, block_header, cost = await self.session_mgr.merkle_branch_for_txid( txid_rev=txid_rev, height=height) self.bump_cost(cost) blockhash_hum = hash_to_hex_str(self.coin.header_hash_rev(block_header)) return { "block_height": height, # "block_hash": blockhash_hum, "merkle": branch, "pos": tx_pos, } async def phandle_transaction_id_from_pos(self, height, tx_pos, merkle=False): '''Return the txid and optionally a merkle proof, given a block height and position in the block. ''' tx_pos = non_negative_integer(tx_pos) height = non_negative_integer(height) if merkle not in (True, False): raise RPCError(BAD_REQUEST, '"merkle" must be a boolean') if merkle: branch, txid_hum, cost = await self.session_mgr.merkle_branch_for_tx_pos( height, tx_pos) self.bump_cost(cost) return {"tx_hash": txid_hum, "merkle": branch} else: txids_rev, cost = await self.session_mgr.txids_rev_at_blockheight(height) try: txid_rev = txids_rev[tx_pos] except IndexError: raise RPCError(BAD_REQUEST, f'no tx at position {tx_pos:,d} in block at height {height:,d}') self.bump_cost(cost) txid_hum = hash_to_hex_str(txid_rev) return txid_hum async def phandle_compact_fee_histogram(self) -> Sequence[tuple[float, int]]: self.bump_cost(1.0) return await self.mempool.compact_fee_histogram() def set_request_handlers(self, ptuple): self.protocol_tuple = ptuple handlers = { 'blockchain.block.header': self.phandle_block_header, 'blockchain.block.headers': self.phandle_block_headers, 'blockchain.estimatefee': self.phandle_estimatefee, 'blockchain.headers.subscribe': self.phandle_headers_subscribe, 'blockchain.transaction.broadcast': self.phandle_transaction_broadcast, 'blockchain.transaction.get': self.phandle_transaction_get, 'blockchain.transaction.get_merkle': self.phandle_transaction_merkle, 'blockchain.transaction.id_from_pos': self.phandle_transaction_id_from_pos, 'mempool.get_fee_histogram': self.phandle_compact_fee_histogram, 'server.add_peer': self.phandle_add_peer, 'server.banner': self.phandle_banner, 'server.donation_address': self.phandle_donation_address, 'server.features': self.phandle_server_features_async, 'server.peers.subscribe': self.phandle_peers_subscribe, 'server.ping': self.phandle_ping, 'server.version': self.phandle_server_version, } notif_handlers = {} if ptuple < (1, 7): handlers['blockchain.scripthash.get_balance'] = self.phandle_scripthash_get_balance handlers['blockchain.scripthash.get_history'] = self.phandle_scripthash_get_history handlers['blockchain.scripthash.get_mempool'] = self.phandle_scripthash_get_mempool handlers['blockchain.scripthash.listunspent'] = self.phandle_scripthash_listunspent handlers['blockchain.scripthash.subscribe'] = self.phandle_scripthash_subscribe if (1, 4, 2) <= ptuple < (1, 7): handlers['blockchain.scripthash.unsubscribe'] = self.phandle_scripthash_unsubscribe if ptuple >= (1, 6): handlers['blockchain.transaction.broadcast_package'] = self.phandle_package_broadcast handlers['mempool.get_info'] = self.phandle_mempool_info else: handlers['blockchain.relayfee'] = self.phandle_relayfee # removed in 1.6 if ptuple >= (1, 7): handlers['blockchain.transaction.testmempoolaccept'] = self.phandle_transaction_testmempoolaccept handlers['blockchain.outpoint.subscribe'] = self.phandle_txoutpoint_subscribe handlers['blockchain.outpoint.get_status'] = self.phandle_txoutpoint_get_status handlers['blockchain.outpoint.unsubscribe'] = self.phandle_txoutpoint_unsubscribe handlers['blockchain.scriptpubkey.get_balance'] = self.phandle_scriptpubkey_get_balance handlers['blockchain.scriptpubkey.get_history'] = self.phandle_scriptpubkey_get_history handlers['blockchain.scriptpubkey.get_mempool'] = self.phandle_scriptpubkey_get_mempool handlers['blockchain.scriptpubkey.listunspent'] = self.phandle_scriptpubkey_listunspent handlers['blockchain.scriptpubkey.subscribe'] = self.phandle_scriptpubkey_subscribe handlers['blockchain.scriptpubkey.unsubscribe'] = self.phandle_scriptpubkey_unsubscribe handlers['mempool.recent'] = self.phandle_mempool_recent notif_handlers['server.ping'] = self.phandle_on_ping_notification self.request_handlers = handlers self.notification_handlers = notif_handlers class LocalRPC(SessionBase): '''A local TCP RPC server session.''' processing_timeout = 10**9 # disable timeouts def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.sv_seen = True assert get_running_loop() is not None, "must be running on asyncio thread" self.sv_negotiated.set() self.client_longname = 'RPC' self.connection.max_response_size = 0 # note: self.request_handlers are set on the class, in SessionManager.__init__ self.notification_handlers = {} def protocol_version_string(self): return 'RPC'