GENESIS_ACTIVATION and is_unspendable_genesis (always legacy for Palladium), script matching helpers and ScriptPubKey.TO_*_OPS, Deserializer._read_be_uint16, the unused Coin.CHUNK_SIZE and ENCODE_CHECK, the name-index fallback of the local RPC 'query' command, and stale comments about other coins. Block processing and the db format are unchanged: a fresh sync gives the same history as before. STATIC_BLOCK_HEADERS and the dynamic header code in db.py are left, being generic functionality that would need wider changes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2433 lines
105 KiB
Python
2433 lines
105 KiB
Python
# 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, 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:
|
|
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 bitcoind 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 daemon (bitcoind) 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 bitcoind'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 bitcoind "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 bitcoind "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 bitcoind 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 bitcoind 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.
|
|
'''
|
|
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. bitcoind 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
|
|
bitcoin 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": BTC/kvB,
|
|
"minrelaytxfee": BTC/kvB,
|
|
"incrementalrelayfee": BTC/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}")
|
|
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"],
|
|
"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'
|