Initial commit: snapshot of spesmilo/electrumx 2.0.0
Unmodified source tree of upstream commit 280cb339aa158e099eb936033eecd15719770651
("Merge pull request #389 from SomberNight/202607_release_2_0",
https://github.com/spesmilo/electrumx, release 2.0.0, 3 July 2026).
All Palladium-specific changes are in the commits that follow.
This commit is contained in:
commit
384d907c65
271 files changed
+27905
No files matched your search
Whitespace-only changes.
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,199 @@
|
||||
# Copyright (c) 2016-2018, Neil Booth
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
from asyncio import Event
|
||||
from typing import Set, Dict, Tuple
|
||||
|
||||
from aiorpcx import _version as aiorpcx_version
|
||||
|
||||
import electrumx
|
||||
from electrumx.lib.server_base import ServerBase
|
||||
from electrumx.lib.util import version_string, OldTaskGroup
|
||||
from electrumx.lib.tx import TxOutpoint
|
||||
from electrumx.server.db import DB
|
||||
from electrumx.server.mempool import MemPool, MemPoolAPI
|
||||
from electrumx.server.session import SessionManager
|
||||
|
||||
|
||||
class Notifications:
|
||||
# hashX notifications come from two sources: new blocks and
|
||||
# mempool refreshes.
|
||||
#
|
||||
# A user with a pending transaction is notified after the block it
|
||||
# gets in is processed. Block processing can take an extended
|
||||
# time, and the prefetcher might poll the daemon after the mempool
|
||||
# code in any case. In such cases the transaction will not be in
|
||||
# the mempool after the mempool refresh. We want to avoid
|
||||
# notifying clients twice - for the mempool refresh and when the
|
||||
# block is done. This object handles that logic by deferring
|
||||
# notifications appropriately.
|
||||
|
||||
def __init__(self):
|
||||
self._touched_hashxs_mp = {} # type: Dict[int, Set[bytes]]
|
||||
self._touched_hashxs_bp = {} # type: Dict[int, Set[bytes]]
|
||||
self._touched_outpoints_mp = {} # type: Dict[int, Set[TxOutpoint]]
|
||||
self._touched_outpoints_bp = {} # type: Dict[int, Set[TxOutpoint]]
|
||||
self._highest_block = -1
|
||||
|
||||
async def _maybe_notify(self):
|
||||
th_mp, th_bp = self._touched_hashxs_mp, self._touched_hashxs_bp
|
||||
# figure out block height
|
||||
common_heights = set(th_mp).intersection(th_bp)
|
||||
if common_heights:
|
||||
height = max(common_heights)
|
||||
elif th_mp and max(th_mp) == self._highest_block:
|
||||
height = self._highest_block
|
||||
else:
|
||||
# Either we are processing a block and waiting for it to
|
||||
# come in, or we have not yet had a mempool update for the
|
||||
# new block height
|
||||
return
|
||||
# hashXs
|
||||
touched_hashxs = th_mp.pop(height)
|
||||
for old in [h for h in th_mp if h <= height]:
|
||||
del th_mp[old]
|
||||
for old in [h for h in th_bp if h <= height]:
|
||||
touched_hashxs.update(th_bp.pop(old))
|
||||
# outpoints
|
||||
to_mp, to_bp = self._touched_outpoints_mp, self._touched_outpoints_bp
|
||||
touched_outpoints = to_mp.pop(height)
|
||||
for old in [h for h in to_mp if h <= height]:
|
||||
del to_mp[old]
|
||||
for old in [h for h in to_bp if h <= height]:
|
||||
touched_outpoints.update(to_bp.pop(old))
|
||||
|
||||
await self.notify(
|
||||
height=height,
|
||||
touched_hashxs=touched_hashxs,
|
||||
touched_outpoints=touched_outpoints,
|
||||
)
|
||||
|
||||
async def notify(
|
||||
self,
|
||||
*,
|
||||
touched_hashxs: Set[bytes],
|
||||
touched_outpoints: Set[TxOutpoint],
|
||||
height: int,
|
||||
):
|
||||
pass
|
||||
|
||||
async def start(self, height: int, notify_func):
|
||||
self._highest_block = height
|
||||
self.notify = notify_func
|
||||
await self.notify(
|
||||
height=height,
|
||||
touched_hashxs=set(),
|
||||
touched_outpoints=set(),
|
||||
)
|
||||
|
||||
async def on_mempool(
|
||||
self,
|
||||
*,
|
||||
touched_hashxs: Set[bytes],
|
||||
touched_outpoints: Set[TxOutpoint],
|
||||
height: int,
|
||||
):
|
||||
self._touched_hashxs_mp[height] = touched_hashxs
|
||||
self._touched_outpoints_mp[height] = touched_outpoints
|
||||
await self._maybe_notify()
|
||||
|
||||
async def on_block(
|
||||
self,
|
||||
*,
|
||||
touched_hashxs: Set[bytes],
|
||||
touched_outpoints: Set[TxOutpoint],
|
||||
height: int,
|
||||
):
|
||||
self._touched_hashxs_bp[height] = touched_hashxs
|
||||
self._touched_outpoints_bp[height] = touched_outpoints
|
||||
self._highest_block = height
|
||||
await self._maybe_notify()
|
||||
|
||||
|
||||
class Controller(ServerBase):
|
||||
'''Manages server initialisation and shutdown.
|
||||
|
||||
Servers are started once the mempool is synced after the block
|
||||
processor first catches up with the daemon.
|
||||
'''
|
||||
async def serve(self, shutdown_event):
|
||||
'''Start the RPC server and wait for the mempool to synchronize. Then
|
||||
start serving external clients.
|
||||
'''
|
||||
if not (0, 25, 0) <= aiorpcx_version < (0, 26):
|
||||
raise RuntimeError(
|
||||
f'aiorpcX version {aiorpcx_version!r} does not match required: 0.25.0<=ver<0.26')
|
||||
|
||||
env = self.env
|
||||
min_str, max_str = env.coin.SESSIONCLS.protocol_min_max_strings()
|
||||
self.logger.info(f'software version: {electrumx.version}')
|
||||
self.logger.info(f'aiorpcX version: {version_string(aiorpcx_version)}')
|
||||
self.logger.info(f'supported protocol versions: {min_str}-{max_str}')
|
||||
self.logger.info(f'event loop policy: {env.loop_policy}')
|
||||
self.logger.info(f'reorg limit is {env.reorg_limit:,d} blocks')
|
||||
|
||||
notifications = Notifications()
|
||||
Daemon = env.coin.DAEMON
|
||||
BlockProcessor = env.coin.BLOCK_PROCESSOR
|
||||
|
||||
async with Daemon(env.coin, env.daemon_url) as daemon:
|
||||
db = DB(env)
|
||||
bp = BlockProcessor(env, db, daemon, notifications)
|
||||
|
||||
# Set notifications up to implement the MemPoolAPI
|
||||
def get_db_height():
|
||||
return db.db_height
|
||||
notifications.height = daemon.height
|
||||
notifications.db_height = get_db_height
|
||||
notifications.db_height_changed = db.db_flushed_event.wait
|
||||
notifications.cached_height = daemon.cached_height
|
||||
notifications.daemon_height_changed = daemon.height_changed.wait
|
||||
notifications.mempool_txids_hum = daemon.mempool_txids_hum
|
||||
notifications.raw_transactions = daemon.getrawtransactions
|
||||
notifications.lookup_utxos = db.lookup_utxos
|
||||
MemPoolAPI.register(Notifications)
|
||||
mempool = MemPool(
|
||||
env.coin, notifications,
|
||||
refresh_secs=env.daemon_poll_interval_mempool_msec/1000,
|
||||
)
|
||||
|
||||
session_mgr = SessionManager(
|
||||
env=env,
|
||||
db=db,
|
||||
block_processor=bp,
|
||||
daemon=daemon,
|
||||
mempool=mempool,
|
||||
shutdown_event=shutdown_event,
|
||||
)
|
||||
|
||||
# Test daemon authentication, and also ensure it has a cached
|
||||
# height. Do this before entering the task group.
|
||||
await daemon.height()
|
||||
|
||||
# Check if daemon is recent enough
|
||||
# note: we only check the first daemon
|
||||
# (DAEMON_URL might be set to a list of daemons)
|
||||
await daemon.check_daemon_version()
|
||||
await daemon.check_daemon_indexes()
|
||||
|
||||
caught_up_event = Event()
|
||||
mempool_event = Event()
|
||||
|
||||
async def wait_for_catchup():
|
||||
await caught_up_event.wait()
|
||||
await group.spawn(db.populate_header_merkle_cache())
|
||||
await group.spawn(mempool.keep_synchronized(mempool_event))
|
||||
|
||||
try:
|
||||
async with OldTaskGroup() as group:
|
||||
await group.spawn(session_mgr.serve(notifications, mempool_event))
|
||||
await group.spawn(bp.fetch_and_process_blocks(caught_up_event))
|
||||
await group.spawn(wait_for_catchup())
|
||||
except Exception as e:
|
||||
self.logger.exception("taskgroup died.")
|
||||
finally:
|
||||
self.logger.info("taskgroup stopped.")
|
||||
@@ -0,0 +1,638 @@
|
||||
# Copyright (c) 2016-2017, Neil Booth
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
'''Class for handling asynchronous connections to a blockchain
|
||||
daemon.'''
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import time
|
||||
from calendar import timegm
|
||||
from struct import pack
|
||||
from typing import TYPE_CHECKING, Type, Sequence, Any, Iterable, Optional
|
||||
|
||||
import aiohttp
|
||||
from aiorpcx import JSONRPC
|
||||
|
||||
from electrumx.lib.hash import hash_to_hex_str, hex_str_to_hash
|
||||
from electrumx.lib.tx import DeserializerDecred
|
||||
from electrumx.lib.util import (class_logger, hex_to_bytes, json_deserialize,
|
||||
json_serialize, pack_varint,
|
||||
unpack_le_uint16_from)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from electrumx.lib.coins import Coin
|
||||
|
||||
|
||||
class DaemonError(Exception):
|
||||
'''Raised when the daemon returns an error in its results.'''
|
||||
|
||||
|
||||
class WarmingUpError(Exception):
|
||||
'''Internal - when the daemon is warming up.'''
|
||||
|
||||
|
||||
class ServiceRefusedError(Exception):
|
||||
'''Internal - when the daemon doesn't provide a JSON response, only an HTTP error, for
|
||||
some reason.'''
|
||||
|
||||
|
||||
class DaemonParseError(Exception):
|
||||
'''Internal - when the daemon returns an RPC_PARSE_ERROR.'''
|
||||
|
||||
|
||||
class Daemon:
|
||||
'''Handles connections to a daemon at the given URL.'''
|
||||
|
||||
RPC_IN_WARMUP = -28
|
||||
RPC_PARSE_ERROR = -32700
|
||||
|
||||
id_counter = itertools.count()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coin: Type['Coin'],
|
||||
url,
|
||||
*,
|
||||
max_workqueue=10,
|
||||
init_retry=0.25,
|
||||
max_retry=4.0,
|
||||
):
|
||||
self.coin = coin
|
||||
self.logger = class_logger(__name__, self.__class__.__name__)
|
||||
self.url_index = None
|
||||
self.urls = []
|
||||
self.set_url(url)
|
||||
# Limit concurrent RPC calls to this number.
|
||||
# See DEFAULT_HTTP_WORKQUEUE in bitcoind, which is typically 16
|
||||
self.workqueue_semaphore = asyncio.Semaphore(value=max_workqueue)
|
||||
self.init_retry = init_retry
|
||||
self.max_retry = max_retry
|
||||
self._height = None
|
||||
self.height_changed = asyncio.Event()
|
||||
self.available_rpcs = {}
|
||||
self.session = None
|
||||
|
||||
self._networkinfo_cache = (None, 0)
|
||||
self._networkinfo_lock = asyncio.Lock()
|
||||
|
||||
self._mempoolinfo_cache = (None, 0)
|
||||
self._mempoolinfo_lock = asyncio.Lock()
|
||||
|
||||
async def __aenter__(self):
|
||||
self.session = aiohttp.ClientSession(connector=self.connector())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
|
||||
def connector(self):
|
||||
return None
|
||||
|
||||
async def check_daemon_version(self):
|
||||
assert self.session is not None and self.coin is not None, f"{self.session=}, {self.coin=}"
|
||||
if self.coin.MIN_REQUIRED_DAEMON_VERSION is None:
|
||||
return
|
||||
|
||||
required_version = tuple(map(int, self.coin.MIN_REQUIRED_DAEMON_VERSION.split('.')))
|
||||
network_info = await self.getnetworkinfo()
|
||||
ni_version = network_info['version']
|
||||
daemon_major = ni_version // 10_000
|
||||
daemon_minor = (ni_version % 10_000) // 100
|
||||
daemon_rev = ni_version % 100
|
||||
daemon_version = (daemon_major, daemon_minor, daemon_rev)
|
||||
if daemon_version < required_version:
|
||||
raise RuntimeError(f"Bitcoin Core {daemon_version=} < {required_version=}.")
|
||||
|
||||
async def check_daemon_indexes(self):
|
||||
assert self.session is not None and self.coin is not None, f"{self.session=}, {self.coin=}"
|
||||
if not self.coin.REQUIRED_DAEMON_INDEXES:
|
||||
return
|
||||
index_info = await self.getindexinfo()
|
||||
for req_index in self.coin.REQUIRED_DAEMON_INDEXES:
|
||||
if req_index not in index_info:
|
||||
raise RuntimeError(
|
||||
f"bitcoind missing required index: {req_index}. "
|
||||
f"You should set {req_index}=1 in your bitcoin.conf config file, and reindex.")
|
||||
if not index_info[req_index]["synced"]:
|
||||
# Should we raise? Not raising allows syncing a fresh bitcoind and e-x "in parallel".
|
||||
self.logger.warning(f"bitcoind required index {req_index!r} is still syncing!")
|
||||
|
||||
def set_url(self, url: str) -> None:
|
||||
'''Set the URLS to the given list, and switch to the first one.'''
|
||||
urls = url.split(',')
|
||||
urls = [self.coin.sanitize_url(url) for url in urls]
|
||||
for n, url in enumerate(urls):
|
||||
status = '' if n else ' (current)'
|
||||
logged_url = self.logged_url(url)
|
||||
self.logger.info(f'daemon #{n + 1} at {logged_url}{status}')
|
||||
self.url_index = 0
|
||||
self.urls = urls
|
||||
|
||||
def current_url(self) -> str:
|
||||
'''Returns the current daemon URL.'''
|
||||
return self.urls[self.url_index]
|
||||
|
||||
def logged_url(self, url=None):
|
||||
'''The host and port part, for logging.'''
|
||||
url = url or self.current_url()
|
||||
return url[url.rindex('@') + 1:]
|
||||
|
||||
def failover(self):
|
||||
'''Call to fail-over to the next daemon URL.
|
||||
|
||||
Returns False if there is only one, otherwise True.
|
||||
'''
|
||||
if len(self.urls) > 1:
|
||||
self.url_index = (self.url_index + 1) % len(self.urls)
|
||||
self.logger.info(f'failing over to {self.logged_url()}')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _send_data(self, data):
|
||||
async with self.workqueue_semaphore:
|
||||
async with self.session.post(self.current_url(), data=data) as resp:
|
||||
kind = resp.headers.get('Content-Type', None)
|
||||
if kind == 'application/json':
|
||||
return await resp.json(loads=json_deserialize)
|
||||
text = await resp.text()
|
||||
text = text.strip() or resp.reason
|
||||
raise ServiceRefusedError(text)
|
||||
|
||||
async def _send(self, payload, processor):
|
||||
'''Send a payload to be converted to JSON.
|
||||
|
||||
Handles temporary connection issues. Daemon response errors
|
||||
are raise through DaemonError.
|
||||
'''
|
||||
def log_error(error):
|
||||
nonlocal last_error_log, retry
|
||||
now = time.monotonic()
|
||||
if now - last_error_log > 60:
|
||||
last_error_log = now
|
||||
self.logger.error(f'{error}. Retrying occasionally...')
|
||||
if retry == self.max_retry and self.failover():
|
||||
retry = 0
|
||||
|
||||
on_good_message = None
|
||||
last_error_log = 0
|
||||
data = json_serialize(payload)
|
||||
retry = self.init_retry
|
||||
while True:
|
||||
try:
|
||||
result = await self._send_data(data)
|
||||
result = processor(result)
|
||||
if on_good_message:
|
||||
self.logger.info(on_good_message)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
log_error('timeout error')
|
||||
except aiohttp.ServerDisconnectedError:
|
||||
log_error('disconnected')
|
||||
on_good_message = 'connection restored'
|
||||
except ConnectionResetError:
|
||||
log_error('connection reset')
|
||||
on_good_message = 'connection restored'
|
||||
except aiohttp.ClientConnectionError:
|
||||
log_error('connection problem - check your daemon is running')
|
||||
on_good_message = 'connection restored'
|
||||
except aiohttp.ClientError as e:
|
||||
log_error(f'daemon error: {e}')
|
||||
on_good_message = 'running normally'
|
||||
except ServiceRefusedError as e:
|
||||
log_error(f'daemon service refused: {e}')
|
||||
on_good_message = 'running normally'
|
||||
except WarmingUpError:
|
||||
log_error('starting up checking blocks')
|
||||
on_good_message = 'running normally'
|
||||
except DaemonParseError as e:
|
||||
log_error(f"received RPC_PARSE_ERROR ({e=!r}). request was: {data=!r}")
|
||||
on_good_message = 'running normally'
|
||||
|
||||
await asyncio.sleep(retry)
|
||||
retry = max(min(self.max_retry, retry * 2), self.init_retry)
|
||||
|
||||
async def _send_single(self, method, params=None):
|
||||
'''Send a single request to the daemon.'''
|
||||
def processor(result):
|
||||
err = result['error']
|
||||
if not err:
|
||||
return result['result']
|
||||
if err.get('code') == self.RPC_IN_WARMUP:
|
||||
raise WarmingUpError
|
||||
if err.get('code') == self.RPC_PARSE_ERROR:
|
||||
raise DaemonParseError(err)
|
||||
raise DaemonError(err)
|
||||
|
||||
payload = {'method': method, 'id': next(self.id_counter)}
|
||||
if params:
|
||||
payload['params'] = params
|
||||
return await self._send(payload, processor)
|
||||
|
||||
async def _send_vector(self, method, params_iterable, replace_errs=False):
|
||||
'''Send several requests of the same method.
|
||||
|
||||
The result will be an array of the same length as params_iterable.
|
||||
If replace_errs is true, any item with an error is returned as None,
|
||||
otherwise an exception is raised.'''
|
||||
def processor(result):
|
||||
errs = [item['error'] for item in result if item['error']]
|
||||
if any(err.get('code') == self.RPC_IN_WARMUP for err in errs):
|
||||
raise WarmingUpError
|
||||
if any(err.get('code') == self.RPC_PARSE_ERROR for err in errs):
|
||||
raise DaemonParseError
|
||||
if not errs or replace_errs:
|
||||
return [item['result'] for item in result]
|
||||
raise DaemonError(errs)
|
||||
|
||||
payload = [{'method': method, 'params': p, 'id': next(self.id_counter)}
|
||||
for p in params_iterable]
|
||||
if payload:
|
||||
return await self._send(payload, processor)
|
||||
return []
|
||||
|
||||
async def _is_rpc_available(self, method: str) -> bool:
|
||||
'''Return whether given RPC method is available in the daemon.
|
||||
|
||||
Results are cached and the daemon will generally not be queried with
|
||||
the same method more than once.'''
|
||||
available = self.available_rpcs.get(method)
|
||||
if available is None:
|
||||
available = True
|
||||
try:
|
||||
await self._send_single(method)
|
||||
except DaemonError as e:
|
||||
err = e.args[0]
|
||||
error_code = err.get("code")
|
||||
available = error_code != JSONRPC.METHOD_NOT_FOUND
|
||||
self.available_rpcs[method] = available
|
||||
return available
|
||||
|
||||
async def block_hex_hashes(self, first: int, count: int) -> Sequence[str]:
|
||||
'''Return the hex hashes (hum) of count block starting at height first.'''
|
||||
params_iterable = ((h, ) for h in range(first, first + count))
|
||||
return await self._send_vector('getblockhash', params_iterable)
|
||||
|
||||
async def deserialised_block(self, bhash_hum: str) -> dict:
|
||||
'''Return the deserialised block with the given hex hash.'''
|
||||
return await self._send_single('getblock', (bhash_hum, True))
|
||||
|
||||
async def raw_blocks(self, bhashes_hum: Sequence[str]) -> Sequence[bytes]:
|
||||
'''Return the raw binary blocks with the given hex hashes.'''
|
||||
params_iterable = ((h, False) for h in bhashes_hum)
|
||||
blocks = await self._send_vector('getblock', params_iterable)
|
||||
# Convert hex string to bytes
|
||||
return [hex_to_bytes(block) for block in blocks]
|
||||
|
||||
async def mempool_txids_hum(self) -> Sequence[str]:
|
||||
'''Update our record of the daemon's mempool hashes.'''
|
||||
return await self._send_single('getrawmempool')
|
||||
|
||||
async def estimatefee(self, block_count, estimate_mode=None):
|
||||
'''Return the fee estimate for the block count. Units are whole
|
||||
currency units per KB, e.g. 0.00000995, or -1 if no estimate
|
||||
is available.
|
||||
'''
|
||||
if estimate_mode:
|
||||
args = (block_count, estimate_mode)
|
||||
else:
|
||||
args = (block_count, )
|
||||
if await self._is_rpc_available('estimatesmartfee'):
|
||||
estimate = await self._send_single('estimatesmartfee', args)
|
||||
return estimate.get('feerate', -1)
|
||||
return await self._send_single('estimatefee', args)
|
||||
|
||||
async def getnetworkinfo(self):
|
||||
'''Return the result of the 'getnetworkinfo' RPC call.'''
|
||||
async with self._networkinfo_lock:
|
||||
cache_val, cache_time = self._networkinfo_cache
|
||||
if time.time() - cache_time < 60: # seconds
|
||||
return cache_val
|
||||
val = await self._send_single('getnetworkinfo')
|
||||
self._networkinfo_cache = (val, time.time())
|
||||
return val
|
||||
|
||||
async def getmempoolinfo(self):
|
||||
"""Return the result of the 'getmempoolinfo' RPC call."""
|
||||
async with self._mempoolinfo_lock:
|
||||
cache_val, cache_time = self._mempoolinfo_cache
|
||||
if time.time() - cache_time < 60: # seconds
|
||||
return cache_val
|
||||
val = await self._send_single('getmempoolinfo')
|
||||
self._mempoolinfo_cache = (val, time.time())
|
||||
return val
|
||||
|
||||
async def getindexinfo(self):
|
||||
"""Return the result of the 'getindexinfo' RPC call."""
|
||||
# note: not cached as it's not exposed to client sessions
|
||||
return await self._send_single('getindexinfo')
|
||||
|
||||
async def relayfee(self):
|
||||
"""Same as getmempoolinfo['minrelaytxfee'].
|
||||
The minimum fee required for a transaction to be relayed on by the daemon to the
|
||||
bitcoin network. Doesn't guarantee mempool acceptance."""
|
||||
network_info = await self.getnetworkinfo()
|
||||
return network_info['relayfee']
|
||||
|
||||
async def mempool_info(self) -> dict[str, float]:
|
||||
mempool_info = await self.getmempoolinfo()
|
||||
return {
|
||||
# Dynamic minimum fee rate in BTC/kvB for tx to be accepted.
|
||||
# Is the maximum of minrelaytxfee and minimum mempool fee
|
||||
'mempoolminfee': mempool_info['mempoolminfee'],
|
||||
# Minimum relay fee for transactions in BTC/kvB, operator-configurable, static.
|
||||
'minrelaytxfee': mempool_info['minrelaytxfee'],
|
||||
# Minimum fee rate increment for mempool limiting or replacement in BTC/kvB,
|
||||
# operator-configurable, static.
|
||||
'incrementalrelayfee': mempool_info['incrementalrelayfee'],
|
||||
}
|
||||
|
||||
async def getrawtransaction(self, txid_hum: str, verbose=False):
|
||||
'''Return the serialized raw transaction with the given hash.'''
|
||||
# Cast to int because some coin daemons are old and require it
|
||||
return await self._send_single('getrawtransaction',
|
||||
(txid_hum, int(verbose)))
|
||||
|
||||
async def getrawtransactions(self, txids_hum: Iterable[str], replace_errs=True) -> Sequence[bytes | None]:
|
||||
'''Return the serialized raw transactions with the given hashes.
|
||||
|
||||
Replaces errors with None by default.'''
|
||||
params_iterable = ((txid_hum, 0) for txid_hum in txids_hum)
|
||||
txs = await self._send_vector('getrawtransaction', params_iterable,
|
||||
replace_errs=replace_errs)
|
||||
# Convert hex strings to bytes
|
||||
return [hex_to_bytes(tx) if tx else None for tx in txs]
|
||||
|
||||
async def gettxspendingprevout(self, prev_txid_hum: str, txout_idx: int) -> dict[str, Any]:
|
||||
"""Query the daemon to find (if any) the spender of given outpoint."""
|
||||
outpoints = [{"txid": prev_txid_hum, "vout": txout_idx}, ]
|
||||
options = {"mempool_only": False}
|
||||
tx_items = await self._send_single('gettxspendingprevout', (outpoints, options))
|
||||
return tx_items[0]
|
||||
|
||||
async def broadcast_transaction(self, raw_tx: str) -> str:
|
||||
'''Broadcast a transaction to the network.'''
|
||||
return await self._send_single('sendrawtransaction', (raw_tx, ))
|
||||
|
||||
async def broadcast_package(self, raw_txs: Sequence[str]):
|
||||
"""Broadcast a package of transactions to the network using 'submitpackage'."""
|
||||
return await self._send_single('submitpackage', (raw_txs, ))
|
||||
|
||||
async def testmempoolaccept(self, raw_txs: Sequence[str]):
|
||||
"""Query the daemon to test mempool acceptance of txs,
|
||||
without adding them to the mempool or broadcasting them.
|
||||
"""
|
||||
return await self._send_single('testmempoolaccept', (raw_txs, ))
|
||||
|
||||
async def height(self) -> int:
|
||||
'''Query the daemon for its current height.'''
|
||||
old_height = self._height
|
||||
self._height = await self._send_single('getblockcount')
|
||||
if old_height != self._height:
|
||||
self.logger.debug(f"new height: {self._height}")
|
||||
self.height_changed.set()
|
||||
self.height_changed.clear()
|
||||
return self._height
|
||||
|
||||
def cached_height(self) -> Optional[int]:
|
||||
'''Return the cached daemon height.
|
||||
|
||||
If the daemon has not been queried yet this returns None.'''
|
||||
return self._height
|
||||
|
||||
|
||||
class DashDaemon(Daemon):
|
||||
|
||||
async def masternode_broadcast(self, params):
|
||||
'''Broadcast a transaction to the network.'''
|
||||
return await self._send_single('masternodebroadcast', params)
|
||||
|
||||
async def masternode_list(self, params):
|
||||
'''Return the masternode status.'''
|
||||
return await self._send_single('masternodelist', params)
|
||||
|
||||
async def protx(self, params):
|
||||
'''Set of commands to execute ProTx related actions.'''
|
||||
return await self._send_single('protx', params)
|
||||
|
||||
|
||||
class FakeEstimateFeeDaemon(Daemon):
|
||||
'''Daemon that simulates estimatefee and relayfee RPC calls. Coin that
|
||||
wants to use this daemon must define ESTIMATE_FEE & RELAY_FEE'''
|
||||
|
||||
async def estimatefee(self, block_count):
|
||||
'''Return the fee estimate for the given parameters.'''
|
||||
return self.coin.ESTIMATE_FEE
|
||||
|
||||
async def relayfee(self):
|
||||
"""The minimum fee required for a transaction to be relayed on by the server to the
|
||||
bitcoin network. Doesn't guarantee mempool acceptance."""
|
||||
return self.coin.RELAY_FEE
|
||||
|
||||
|
||||
class LegacyRPCDaemon(Daemon):
|
||||
'''Handles connections to a daemon at the given URL.
|
||||
|
||||
This class is useful for daemons that don't have the new 'getblock'
|
||||
RPC call that returns the block in hex, the workaround is to manually
|
||||
recreate the block bytes. The recreated block bytes may not be the exact
|
||||
as in the underlying blockchain but it is good enough for our indexing
|
||||
purposes.'''
|
||||
|
||||
async def raw_blocks(self, bhashes_hum):
|
||||
'''Return the raw binary blocks with the given hex hashes.'''
|
||||
params_iterable = ((h, ) for h in bhashes_hum)
|
||||
block_info = await self._send_vector('getblock', params_iterable)
|
||||
|
||||
blocks = []
|
||||
for i in block_info:
|
||||
raw_block = await self.make_raw_block(i)
|
||||
blocks.append(raw_block)
|
||||
|
||||
# Convert hex string to bytes
|
||||
return blocks
|
||||
|
||||
async def make_raw_header(self, b):
|
||||
pbh = b.get('previousblockhash')
|
||||
if pbh is None:
|
||||
pbh = '0' * 64
|
||||
return b''.join([
|
||||
pack('<L', b.get('version')),
|
||||
hex_str_to_hash(pbh),
|
||||
hex_str_to_hash(b.get('merkleroot')),
|
||||
pack('<L', self.timestamp_safe(b['time'])),
|
||||
pack('<L', int(b.get('bits'), 16)),
|
||||
pack('<L', int(b.get('nonce')))
|
||||
])
|
||||
|
||||
async def make_raw_block(self, b):
|
||||
'''Construct a raw block'''
|
||||
|
||||
header = await self.make_raw_header(b)
|
||||
|
||||
transactions = []
|
||||
if b.get('height') > 0:
|
||||
transactions = await self.getrawtransactions(b.get('tx'), False)
|
||||
|
||||
raw_block = header
|
||||
num_txs = len(transactions)
|
||||
if num_txs > 0:
|
||||
raw_block += pack_varint(num_txs)
|
||||
raw_block += b''.join(transactions)
|
||||
else:
|
||||
raw_block += b'\x00'
|
||||
|
||||
return raw_block
|
||||
|
||||
def timestamp_safe(self, t):
|
||||
if isinstance(t, int):
|
||||
return t
|
||||
return timegm(time.strptime(t, "%Y-%m-%d %H:%M:%S %Z"))
|
||||
|
||||
|
||||
class FakeEstimateLegacyRPCDaemon(LegacyRPCDaemon, FakeEstimateFeeDaemon):
|
||||
pass
|
||||
|
||||
|
||||
class DecredDaemon(Daemon):
|
||||
async def raw_blocks(self, bhashes_hum):
|
||||
'''Return the raw binary blocks with the given hex hashes.'''
|
||||
|
||||
params_iterable = ((h, False) for h in bhashes_hum)
|
||||
blocks = await self._send_vector('getblock', params_iterable)
|
||||
|
||||
raw_blocks = []
|
||||
valid_tx_tree = {}
|
||||
for block in blocks:
|
||||
# Convert to bytes from hex
|
||||
raw_block = hex_to_bytes(block)
|
||||
raw_blocks.append(raw_block)
|
||||
# Check if previous block is valid
|
||||
prev = self.prev_hex_hash(raw_block)
|
||||
votebits = unpack_le_uint16_from(raw_block[100:102])[0]
|
||||
valid_tx_tree[prev] = self.is_valid_tx_tree(votebits)
|
||||
|
||||
processed_raw_blocks = []
|
||||
for hash, raw_block in zip(bhashes_hum, raw_blocks):
|
||||
if hash in valid_tx_tree:
|
||||
is_valid = valid_tx_tree[hash]
|
||||
else:
|
||||
# Do something complicated to figure out if this block is valid
|
||||
header = await self._send_single('getblockheader', (hash, ))
|
||||
if 'nextblockhash' not in header:
|
||||
raise DaemonError(f'Could not find next block for {hash}')
|
||||
next_hash = header['nextblockhash']
|
||||
next_header = await self._send_single('getblockheader',
|
||||
(next_hash, ))
|
||||
is_valid = self.is_valid_tx_tree(next_header['votebits'])
|
||||
|
||||
if is_valid:
|
||||
processed_raw_blocks.append(raw_block)
|
||||
else:
|
||||
# If this block is invalid remove the normal transactions
|
||||
self.logger.info(f'block {hash} is invalidated')
|
||||
processed_raw_blocks.append(self.strip_tx_tree(raw_block))
|
||||
|
||||
return processed_raw_blocks
|
||||
|
||||
@staticmethod
|
||||
def prev_hex_hash(raw_block):
|
||||
return hash_to_hex_str(raw_block[4:36])
|
||||
|
||||
@staticmethod
|
||||
def is_valid_tx_tree(votebits):
|
||||
# Check if previous block was invalidated.
|
||||
return bool(votebits & (1 << 0) != 0)
|
||||
|
||||
def strip_tx_tree(self, raw_block):
|
||||
c = self.coin
|
||||
assert issubclass(c.DESERIALIZER, DeserializerDecred)
|
||||
d = c.DESERIALIZER(raw_block, start=c.BASIC_HEADER_SIZE)
|
||||
d.read_tx_tree() # Skip normal transactions
|
||||
# Create a fake block without any normal transactions
|
||||
return raw_block[:c.BASIC_HEADER_SIZE] + b'\x00' + raw_block[d.cursor:]
|
||||
|
||||
async def height(self):
|
||||
height = await super().height()
|
||||
if height > 0:
|
||||
# Lie about the daemon height as the current tip can be invalidated
|
||||
height -= 1
|
||||
self._height = height
|
||||
return height
|
||||
|
||||
async def mempool_txids_hum(self):
|
||||
mempool = await super().mempool_txids_hum()
|
||||
# Add current tip transactions to the 'fake' mempool.
|
||||
real_height = await self._send_single('getblockcount')
|
||||
tip_hash = await self._send_single('getblockhash', (real_height,))
|
||||
tip = await self.deserialised_block(tip_hash)
|
||||
# Add normal transactions except coinbase
|
||||
mempool += tip['tx'][1:]
|
||||
# Add stake transactions if applicable
|
||||
mempool += tip.get('stx', [])
|
||||
return mempool
|
||||
|
||||
def connector(self):
|
||||
# FIXME allow self signed certificates
|
||||
return aiohttp.TCPConnector(verify_ssl=False)
|
||||
|
||||
|
||||
class PreLegacyRPCDaemon(LegacyRPCDaemon):
|
||||
'''Handles connections to a daemon at the given URL.
|
||||
|
||||
This class is useful for daemons that don't have the new 'getblock'
|
||||
RPC call that returns the block in hex, and need the False parameter
|
||||
for the getblock'''
|
||||
|
||||
async def deserialised_block(self, hex_hash):
|
||||
'''Return the deserialised block with the given hex hash.'''
|
||||
return await self._send_single('getblock', (hex_hash, False))
|
||||
|
||||
|
||||
class SmartCashDaemon(Daemon):
|
||||
|
||||
async def masternode_broadcast(self, params):
|
||||
'''Broadcast a smartnode to the network.'''
|
||||
return await self._send_single('smartnodebroadcast', params)
|
||||
|
||||
async def masternode_list(self, params):
|
||||
'''Return the smartnode status.'''
|
||||
return await self._send_single('smartnodelist', params)
|
||||
|
||||
async def smartrewards(self, params):
|
||||
'''Return smartrewards data.'''
|
||||
return await self._send_single('smartrewards', params)
|
||||
|
||||
|
||||
class ZcoinMtpDaemon(Daemon):
|
||||
|
||||
def strip_mtp_data(self, raw_block):
|
||||
if self.coin.is_mtp(raw_block):
|
||||
return \
|
||||
raw_block[:self.coin.MTP_HEADER_DATA_START*2] + \
|
||||
raw_block[self.coin.MTP_HEADER_DATA_END*2:]
|
||||
return raw_block
|
||||
|
||||
async def raw_blocks(self, bhashes_hum):
|
||||
'''Return the raw binary blocks with the given hex hashes.'''
|
||||
params_iterable = ((h, False) for h in bhashes_hum)
|
||||
blocks = await self._send_vector('getblock', params_iterable)
|
||||
# Convert hex string to bytes
|
||||
return [hex_to_bytes(self.strip_mtp_data(block)) for block in blocks]
|
||||
|
||||
async def masternode_broadcast(self, params):
|
||||
'''Broadcast a transaction to the network.'''
|
||||
return await self._send_single('znodebroadcast', params)
|
||||
|
||||
async def masternode_list(self, params):
|
||||
'''Return the masternode status.'''
|
||||
return await self._send_single('znodelist', params)
|
||||
|
||||
async def protx(self, params):
|
||||
'''Set of commands to execute ProTx related actions.'''
|
||||
return await self._send_single('protx', params)
|
||||
@@ -0,0 +1,820 @@
|
||||
# Copyright (c) 2016-2020, Neil Booth
|
||||
# Copyright (c) 2017, the ElectrumX authors
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
'''Interface to the blockchain database.'''
|
||||
|
||||
|
||||
from array import array
|
||||
import ast
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from bisect import bisect_right
|
||||
from dataclasses import dataclass
|
||||
from glob import glob
|
||||
from typing import Dict, List, Sequence, Tuple, Optional, TYPE_CHECKING, Union, Callable, Iterable
|
||||
|
||||
from aiorpcx import run_in_thread, sleep
|
||||
|
||||
import electrumx.lib.util as util
|
||||
from electrumx.lib.hash import hash_to_hex_str, HASHX_LEN, hex_str_to_hash
|
||||
from electrumx.lib.merkle import Merkle, MerkleCache
|
||||
from electrumx.lib.util import (
|
||||
formatted_time,
|
||||
)
|
||||
from electrumx.lib.tx import TXOSpendStatus
|
||||
from electrumx.server.storage import db_class, Storage
|
||||
from electrumx.server.history import (
|
||||
History,
|
||||
)
|
||||
from electrumx.server.db_util import (
|
||||
DBTooOldForMigrations,
|
||||
pack_txnum, unpack_txnum, TXNUM_LEN,
|
||||
pack_txoutidx, unpack_txoutidx, TXOUTIDX_LEN,
|
||||
pack_satoshis_val, unpack_satoshis_val,
|
||||
pack_block_height, unpack_block_height, BHEIGHT_LEN,
|
||||
pack_dyn_header_offset, unpack_dyn_header_offset, DYN_HEADER_OFFSET_LEN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from electrumx.server.env import Env
|
||||
|
||||
|
||||
@dataclass(slots=True, order=True)
|
||||
class UTXO:
|
||||
tx_num: int # index of tx in chain order
|
||||
tx_pos: int # tx output idx
|
||||
txid_rev: bytes | None
|
||||
height: int # block height
|
||||
value: int # in satoshis
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class FlushData:
|
||||
height: int
|
||||
tx_count: int
|
||||
headers: list[bytes]
|
||||
bhash_to_bheight: dict[bytes, int] # block_hash_rev -> block_height
|
||||
block_txids_rev: list[bytes]
|
||||
# The following are flushed to the UTXO DB if undo_infos is not None
|
||||
undo_infos: dict[int, Sequence[bytes]] # height -> undo_info
|
||||
adds: dict[bytes, bytes] # txid+out_idx -> hashX+tx_num+value_sats
|
||||
deletes: list[bytes] # b'h' db keys, and b'u' db keys. order does not matter
|
||||
tip: bytes
|
||||
|
||||
|
||||
COMP_TXID_LEN = 4
|
||||
|
||||
|
||||
class DB:
|
||||
'''Simple wrapper of the backend database for querying.
|
||||
|
||||
Performs no DB update, though the DB will be cleaned on opening if
|
||||
it was shutdown uncleanly.
|
||||
'''
|
||||
|
||||
DB_VERSIONS = (11, )
|
||||
DB_STATE_KEY = b'state'
|
||||
|
||||
utxo_db: Optional['Storage']
|
||||
|
||||
class DBError(Exception):
|
||||
'''Raised on general DB errors generally indicating corruption.'''
|
||||
|
||||
def __init__(self, env: 'Env'):
|
||||
self.logger = util.class_logger(__name__, self.__class__.__name__)
|
||||
self.env = env
|
||||
self.coin = env.coin
|
||||
|
||||
# Setup block header size handlers
|
||||
if self.coin.STATIC_BLOCK_HEADERS:
|
||||
self.header_offset = self.coin.static_header_offset # type: Callable[[int], int]
|
||||
self.header_len = self.coin.static_header_len # type: Callable[[int], int]
|
||||
else:
|
||||
self.header_offset = self.dynamic_header_offset # type: Callable[[int], int]
|
||||
self.header_len = self.dynamic_header_len # type: Callable[[int], int]
|
||||
|
||||
self.logger.info(f'switching current directory to {env.db_dir}')
|
||||
os.chdir(env.db_dir)
|
||||
|
||||
self.db_class = db_class(self.env.db_engine)
|
||||
self.history = History()
|
||||
|
||||
# Key: b'u' + address_hashX + txout_idx + tx_num
|
||||
# Value: the UTXO value as a 64-bit unsigned integer (in satoshis)
|
||||
# "at address, at outpoint, there is a UTXO of value v"
|
||||
# ---
|
||||
# Key: b'h' + compressed_tx_hash + txout_idx + tx_num
|
||||
# Value: hashX
|
||||
# "some outpoint created a UTXO at address"
|
||||
# ---
|
||||
# Key: b'U' + block_height
|
||||
# Value: byte-concat list of (hashX + tx_num + value_sats)
|
||||
# "undo data: list of UTXOs spent at block height"
|
||||
self.utxo_db = None
|
||||
|
||||
self.fs_height = -1
|
||||
self.fs_tx_count = 0
|
||||
self.db_height = -1
|
||||
self.db_tx_count = 0
|
||||
self.db_tip = None # type: Optional[bytes] # block_hash_rev
|
||||
self.last_flush = time.time()
|
||||
self.last_flush_tx_count = 0
|
||||
self.wall_time = 0
|
||||
self.first_sync = True
|
||||
self.db_version = -1
|
||||
self.db_flushed_event = asyncio.Event()
|
||||
|
||||
self.logger.info(f'using {self.env.db_engine} for DB backend')
|
||||
|
||||
# Header merkle cache
|
||||
self.merkle = Merkle()
|
||||
self.header_mc = MerkleCache(self.merkle, self.fs_block_hashes_rev)
|
||||
|
||||
# note: all on-disk and in-memory dbs are consistent with each other if BlockProcessor.tip == DB.tip.
|
||||
# (the mempool is out-of-scope here)
|
||||
|
||||
# on-disk: raw block headers in chain order
|
||||
self.headers_file = util.LogicalFile('meta/headers', 2, 16000000)
|
||||
# on-disk: cumulative number of txs at the end of height N
|
||||
self.tx_counts = None # type: Optional[array] # in-memory
|
||||
self.tx_counts_file = util.LogicalFile('meta/txcounts', 2, 2000000)
|
||||
# on-disk: 32 byte txids in chain order, allows (tx_num -> txid_rev) map
|
||||
self.hashes_file = util.LogicalFile('meta/hashes', 4, 16000000)
|
||||
if not self.coin.STATIC_BLOCK_HEADERS:
|
||||
self.headers_offsets_file = util.LogicalFile(
|
||||
'meta/headers_offsets', 2, 16000000)
|
||||
|
||||
# in-memory: (block_hash_rev -> block_height) full map
|
||||
self.bhash_to_bheight = None # type: Optional[dict[bytes, int]]
|
||||
|
||||
async def _read_tx_counts(self) -> None:
|
||||
if self.tx_counts is not None:
|
||||
return
|
||||
# tx_counts[N] has the cumulative number of txs at the end of
|
||||
# height N. So tx_counts[0] is 1 - the genesis coinbase
|
||||
size = (self.db_height + 1) * 8
|
||||
tx_counts = self.tx_counts_file.read(0, size)
|
||||
assert len(tx_counts) == size
|
||||
self.tx_counts = array('Q', tx_counts)
|
||||
if self.tx_counts:
|
||||
assert self.db_tx_count == self.tx_counts[-1]
|
||||
else:
|
||||
assert self.db_tx_count == 0
|
||||
|
||||
async def _open_dbs(self, *, for_sync: bool) -> None:
|
||||
assert self.utxo_db is None
|
||||
|
||||
# First UTXO DB
|
||||
def open_utxo_db():
|
||||
self.utxo_db = self.db_class('utxo', for_sync)
|
||||
if self.utxo_db.is_new:
|
||||
self.logger.info('created new database')
|
||||
self.logger.info('creating metadata directory')
|
||||
os.mkdir('meta')
|
||||
with util.open_file('COIN', create=True) as f:
|
||||
f.write(f'ElectrumX databases and metadata for '
|
||||
f'{self.coin.NAME} {self.coin.NET}'.encode())
|
||||
if not self.coin.STATIC_BLOCK_HEADERS:
|
||||
self.headers_offsets_file.write(0, b'\0\0\0\0\0\0\0\0')
|
||||
else:
|
||||
self.logger.info(f'opened UTXO DB (for sync: {for_sync})')
|
||||
self.read_utxo_state()
|
||||
await run_in_thread(open_utxo_db)
|
||||
|
||||
# Then history DB
|
||||
def open_hist_db():
|
||||
self.history.open_db(
|
||||
db_class=self.db_class,
|
||||
for_sync=for_sync,
|
||||
utxo_db_tx_count=self.db_tx_count,
|
||||
)
|
||||
self.clear_excess_undo_info()
|
||||
await run_in_thread(open_hist_db)
|
||||
|
||||
# Now prepare in-memory structures.
|
||||
# - Read TX counts (requires meta directory)
|
||||
await self._read_tx_counts()
|
||||
# - (block_hash_rev -> block_height) map
|
||||
await self._prep_bhash_to_bheight_map()
|
||||
|
||||
async def open_for_sync(self) -> None:
|
||||
'''Open the databases to sync to the daemon.
|
||||
|
||||
When syncing we want to reserve a lot of open files for the
|
||||
synchronization. When serving clients we want the open files for
|
||||
serving network connections.
|
||||
'''
|
||||
await self._open_dbs(for_sync=True)
|
||||
|
||||
async def open_for_serving(self) -> None:
|
||||
'''Open the databases for serving. If they are already open they are
|
||||
closed first.
|
||||
'''
|
||||
if self.utxo_db:
|
||||
self.logger.info('closing DBs to re-open for serving')
|
||||
self.utxo_db.close()
|
||||
self.history.close_db()
|
||||
self.utxo_db = None
|
||||
await self._open_dbs(for_sync=False)
|
||||
|
||||
# Header merkle cache
|
||||
|
||||
async def populate_header_merkle_cache(self) -> None:
|
||||
self.logger.info('populating header merkle cache...')
|
||||
length = max(1, self.db_height - self.env.reorg_limit)
|
||||
start = time.monotonic()
|
||||
await self.header_mc.initialize(length)
|
||||
elapsed = time.monotonic() - start
|
||||
self.logger.info(f'header merkle cache populated in {elapsed:.1f}s')
|
||||
|
||||
async def header_branch_and_root(self, length: int, height: int) -> tuple[list[bytes], bytes]:
|
||||
return await self.header_mc.branch_and_root(length, height)
|
||||
|
||||
# -- (block_hash -> block_height) map
|
||||
async def _prep_bhash_to_bheight_map(self) -> None:
|
||||
if self.bhash_to_bheight is not None:
|
||||
return
|
||||
self.logger.info('preparing bhash->bheight map...')
|
||||
t0 = time.monotonic()
|
||||
self.bhash_to_bheight = {}
|
||||
count = self.db_height + 1
|
||||
block_hashes = await self.fs_block_hashes_rev(0, count)
|
||||
if len(block_hashes) != count:
|
||||
raise Exception(
|
||||
f"failed to prep bhash_to_bheight. "
|
||||
f"wanted {count} bhashes, only got {len(block_hashes)}")
|
||||
for bheight, bhash in enumerate(block_hashes):
|
||||
self.bhash_to_bheight[bhash] = bheight
|
||||
assert len(self.bhash_to_bheight) == self.db_height + 1
|
||||
self.logger.info(f'bhash->bheight map populated in {time.monotonic() - t0:.1f}s')
|
||||
|
||||
def get_blockheight_from_blockhash(self, block_hash_hum: str) -> Optional[int]:
|
||||
bhash_rev = hex_str_to_hash(block_hash_hum)
|
||||
return self.bhash_to_bheight.get(bhash_rev, None)
|
||||
|
||||
# Flushing
|
||||
def assert_flushed(self, flush_data: FlushData) -> None:
|
||||
'''Asserts state is fully flushed.'''
|
||||
assert flush_data.tx_count == self.fs_tx_count == self.db_tx_count
|
||||
assert flush_data.height == self.fs_height == self.db_height
|
||||
assert flush_data.tip == self.db_tip
|
||||
assert not flush_data.headers
|
||||
assert not flush_data.bhash_to_bheight
|
||||
assert not flush_data.block_txids_rev
|
||||
assert not flush_data.adds
|
||||
assert not flush_data.deletes
|
||||
assert not flush_data.undo_infos
|
||||
self.history.assert_flushed()
|
||||
|
||||
def flush_dbs(
|
||||
self,
|
||||
*,
|
||||
flush_data: FlushData,
|
||||
flush_utxos: bool,
|
||||
estimate_txs_remaining: Callable[[], float],
|
||||
) -> None:
|
||||
'''Flush out cached state. History is always flushed; UTXOs are
|
||||
flushed if flush_utxos.'''
|
||||
if flush_data.height == self.db_height:
|
||||
assert flush_data.tip == self.db_tip, f"block_hash mismatch at {flush_data.height}"
|
||||
self.assert_flushed(flush_data)
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
prior_flush = self.last_flush
|
||||
tx_delta = flush_data.tx_count - self.last_flush_tx_count
|
||||
|
||||
# 1. Flush to file system
|
||||
self._flush_fs(flush_data)
|
||||
|
||||
# 2. Then hist_db
|
||||
self._flush_history()
|
||||
|
||||
# 3. Then utxo_db
|
||||
# Flush state last as it reads the wall time.
|
||||
with self.utxo_db.write_batch() as batch:
|
||||
if flush_utxos:
|
||||
self._flush_utxo_db(batch, flush_data)
|
||||
self._flush_state(batch)
|
||||
|
||||
# Update and put the wall time again - otherwise we drop the
|
||||
# time it took to commit the batch
|
||||
self._flush_state(self.utxo_db)
|
||||
|
||||
elapsed = self.last_flush - start_time
|
||||
self.logger.info(f'flush took '
|
||||
f'{elapsed:.1f}s. Height {flush_data.height:,d} '
|
||||
f'txs: {flush_data.tx_count:,d} ({tx_delta:+,d})')
|
||||
|
||||
# Catch-up stats
|
||||
if self.utxo_db.for_sync:
|
||||
flush_interval = self.last_flush - prior_flush
|
||||
tx_per_sec_gen = int(flush_data.tx_count / self.wall_time)
|
||||
tx_per_sec_last = 1 + int(tx_delta / flush_interval)
|
||||
eta = estimate_txs_remaining() / tx_per_sec_last
|
||||
self.logger.info(f'tx/sec since genesis: {tx_per_sec_gen:,d}, '
|
||||
f'since last flush: {tx_per_sec_last:,d}')
|
||||
self.logger.info(f'sync time: {formatted_time(self.wall_time)} '
|
||||
f'ETA: {formatted_time(eta)}')
|
||||
|
||||
def _flush_fs(self, flush_data: FlushData) -> None:
|
||||
'''Write headers, tx counts and block tx hashes to the filesystem.
|
||||
|
||||
The first height to write is self.fs_height + 1. The FS
|
||||
metadata is all append-only, so in a crash we just pick up
|
||||
again from the height stored in the DB.
|
||||
'''
|
||||
prior_tx_count = (self.tx_counts[self.fs_height]
|
||||
if self.fs_height >= 0 else 0)
|
||||
assert len(flush_data.block_txids_rev) == len(flush_data.headers)
|
||||
assert len(flush_data.bhash_to_bheight) == len(flush_data.headers)
|
||||
assert flush_data.height == self.fs_height + len(flush_data.headers)
|
||||
assert flush_data.tx_count == (self.tx_counts[-1] if self.tx_counts
|
||||
else 0)
|
||||
assert len(self.tx_counts) == flush_data.height + 1
|
||||
txids_rev = b''.join(flush_data.block_txids_rev)
|
||||
flush_data.block_txids_rev.clear()
|
||||
assert len(txids_rev) % 32 == 0
|
||||
assert len(txids_rev) // 32 == flush_data.tx_count - prior_tx_count
|
||||
|
||||
# Write the headers, tx counts, and tx hashes
|
||||
start_time = time.monotonic()
|
||||
height_start = self.fs_height + 1
|
||||
offset = self.header_offset(height_start)
|
||||
self.headers_file.write(offset, b''.join(flush_data.headers))
|
||||
self.fs_update_header_offsets(offset_start=offset, height_start=height_start, headers=flush_data.headers)
|
||||
flush_data.headers.clear()
|
||||
self.bhash_to_bheight.update(flush_data.bhash_to_bheight)
|
||||
assert len(self.bhash_to_bheight) == flush_data.height + 1
|
||||
flush_data.bhash_to_bheight.clear()
|
||||
|
||||
offset = height_start * self.tx_counts.itemsize
|
||||
self.tx_counts_file.write(offset,
|
||||
self.tx_counts[height_start:].tobytes())
|
||||
offset = prior_tx_count * 32
|
||||
self.hashes_file.write(offset, txids_rev)
|
||||
|
||||
self.fs_height = flush_data.height
|
||||
self.fs_tx_count = flush_data.tx_count
|
||||
|
||||
if self.utxo_db.for_sync:
|
||||
elapsed = time.monotonic() - start_time
|
||||
self.logger.info(f'flushed filesystem data in {elapsed:.2f}s')
|
||||
|
||||
def _flush_history(self) -> None:
|
||||
self.history.flush()
|
||||
|
||||
def _flush_utxo_db(self, batch, flush_data: FlushData) -> None:
|
||||
'''Flush the cached DB writes and UTXO set to the batch.'''
|
||||
# Care is needed because the writes generated by flushing the
|
||||
# UTXO state may have keys in common with our write cache or
|
||||
# may be in the DB already.
|
||||
start_time = time.monotonic()
|
||||
add_count = len(flush_data.adds)
|
||||
spend_count = len(flush_data.deletes) // 2
|
||||
|
||||
# Spends
|
||||
batch_delete = batch.delete
|
||||
for key in sorted(flush_data.deletes):
|
||||
batch_delete(key)
|
||||
flush_data.deletes.clear()
|
||||
|
||||
# New UTXOs
|
||||
batch_put = batch.put
|
||||
for key, value in flush_data.adds.items():
|
||||
# key: txid+out_idx, value: hashX+tx_num+value_sats
|
||||
hashX = value[:HASHX_LEN]
|
||||
txout_idx = key[-TXOUTIDX_LEN:]
|
||||
tx_num = value[HASHX_LEN: HASHX_LEN+TXNUM_LEN]
|
||||
value_sats = value[-8:]
|
||||
suffix = txout_idx + tx_num
|
||||
batch_put(b'h' + key[:COMP_TXID_LEN] + suffix, hashX)
|
||||
batch_put(b'u' + hashX + suffix, value_sats)
|
||||
flush_data.adds.clear()
|
||||
|
||||
# Add new undo information
|
||||
self.flush_undo_infos(batch_put, flush_data.undo_infos)
|
||||
flush_data.undo_infos.clear()
|
||||
# Delete old undo information
|
||||
if not self.utxo_db.for_sync: # undo infos were only added if we are nearly caught up anyway
|
||||
old_min_height = max(0, self.min_undo_height(self.db_height))
|
||||
new_min_height = max(0, self.min_undo_height(flush_data.height))
|
||||
for h in range(old_min_height, new_min_height):
|
||||
key = self.undo_key(h)
|
||||
batch_delete(key)
|
||||
|
||||
# Log stats
|
||||
if self.utxo_db.for_sync:
|
||||
block_count = flush_data.height - self.db_height
|
||||
tx_count = flush_data.tx_count - self.db_tx_count
|
||||
elapsed = time.monotonic() - start_time
|
||||
self.logger.info(f'flushed {block_count:,d} blocks with '
|
||||
f'{tx_count:,d} txs, {add_count:,d} UTXO adds, '
|
||||
f'{spend_count:,d} spends in '
|
||||
f'{elapsed:.1f}s, committing...')
|
||||
|
||||
self.db_height = flush_data.height
|
||||
self.db_tx_count = flush_data.tx_count
|
||||
self.db_tip = flush_data.tip
|
||||
|
||||
def _flush_state(self, batch):
|
||||
'''Flush chain state to the batch.'''
|
||||
now = time.time()
|
||||
self.wall_time += now - self.last_flush
|
||||
self.last_flush = now
|
||||
self.last_flush_tx_count = self.fs_tx_count
|
||||
self.write_utxo_state(batch)
|
||||
|
||||
def flush_backup(self, flush_data: FlushData, touched_hashxs: Iterable[bytes]) -> None:
|
||||
'''Like flush_dbs() but when backing up. All UTXOs are flushed.'''
|
||||
assert not flush_data.headers
|
||||
assert not flush_data.block_txids_rev
|
||||
assert flush_data.height < self.db_height
|
||||
assert len(flush_data.bhash_to_bheight) == (self.db_height - flush_data.height)
|
||||
self.history.assert_flushed()
|
||||
|
||||
start_time = time.time()
|
||||
tx_delta = flush_data.tx_count - self.last_flush_tx_count
|
||||
|
||||
# 1. (fake-)Flush to file system: this just updates pointers
|
||||
self._backup_fs(flush_data)
|
||||
|
||||
# Crucially, we flush utxo_db and hist_db in the reverse order compared to when advancing,
|
||||
# to maintain the invariant that hist_db_tx_count >= utxo_db_tx_count even if we crash.
|
||||
# 2. Then utxo_db
|
||||
with self.utxo_db.write_batch() as batch:
|
||||
self._flush_utxo_db(batch, flush_data)
|
||||
# Flush state last as it reads the wall time.
|
||||
self._flush_state(batch)
|
||||
|
||||
# 3. Then hist_db
|
||||
self.history.backup(
|
||||
hashXs=touched_hashxs,
|
||||
tx_count=flush_data.tx_count,
|
||||
)
|
||||
|
||||
# Update and put the wall time again - otherwise we drop the
|
||||
# time it took to commit the batch
|
||||
self._flush_state(self.utxo_db)
|
||||
|
||||
elapsed = self.last_flush - start_time
|
||||
self.logger.info(f'backup flush took '
|
||||
f'{elapsed:.1f}s. Height {flush_data.height:,d} '
|
||||
f'txs: {flush_data.tx_count:,d} ({tx_delta:+,d})')
|
||||
|
||||
def fs_update_header_offsets(self, *, offset_start: int, height_start: int, headers: Sequence[bytes]) -> None:
|
||||
if self.coin.STATIC_BLOCK_HEADERS:
|
||||
return
|
||||
offset = offset_start
|
||||
offsets = []
|
||||
for h in headers:
|
||||
offset += len(h)
|
||||
offsets.append(pack_dyn_header_offset(offset))
|
||||
# For each header we get the offset of the next header, hence we
|
||||
# start writing from the next height
|
||||
pos = (height_start + 1) * DYN_HEADER_OFFSET_LEN
|
||||
self.headers_offsets_file.write(pos, b''.join(offsets))
|
||||
|
||||
def dynamic_header_offset(self, height: int) -> int:
|
||||
assert not self.coin.STATIC_BLOCK_HEADERS
|
||||
offset = unpack_dyn_header_offset(self.headers_offsets_file.read(height * DYN_HEADER_OFFSET_LEN, DYN_HEADER_OFFSET_LEN))
|
||||
return offset
|
||||
|
||||
def dynamic_header_len(self, height: int) -> int:
|
||||
return self.dynamic_header_offset(height + 1)\
|
||||
- self.dynamic_header_offset(height)
|
||||
|
||||
def _backup_fs(self, flush_data: FlushData) -> None:
|
||||
'''Back up during a reorg. This just updates our pointers.'''
|
||||
self.fs_height = flush_data.height
|
||||
self.fs_tx_count = flush_data.tx_count
|
||||
|
||||
for bhash, bheight in flush_data.bhash_to_bheight.items():
|
||||
assert bheight == self.bhash_to_bheight.pop(bhash)
|
||||
flush_data.bhash_to_bheight.clear()
|
||||
assert len(self.bhash_to_bheight) == flush_data.height + 1
|
||||
|
||||
# Truncate header_mc: header count is 1 more than the height.
|
||||
self.header_mc.truncate(flush_data.height + 1)
|
||||
|
||||
async def raw_header(self, height: int) -> bytes:
|
||||
'''Return the binary header at the given height.'''
|
||||
header, n = await self.read_headers(height, 1)
|
||||
if n != 1:
|
||||
raise IndexError(f'height {height:,d} out of range')
|
||||
return header
|
||||
|
||||
async def read_headers(self, start_height: int, count: int) -> Tuple[bytes, int]:
|
||||
'''Requires start_height >= 0, count >= 0. Reads as many headers as
|
||||
are available starting at start_height up to count. This
|
||||
would be zero if start_height is beyond self.db_height, for
|
||||
example.
|
||||
|
||||
Returns a (binary, n) pair where binary is the concatenated
|
||||
binary headers, and n is the count of headers returned.
|
||||
'''
|
||||
if start_height < 0 or count < 0:
|
||||
raise self.DBError(f'{count:,d} headers starting at '
|
||||
f'{start_height:,d} not on disk')
|
||||
|
||||
def read_headers():
|
||||
# Read some from disk
|
||||
disk_count = max(0, min(count, self.db_height + 1 - start_height))
|
||||
if disk_count:
|
||||
offset = self.header_offset(start_height)
|
||||
size = self.header_offset(start_height + disk_count) - offset
|
||||
return self.headers_file.read(offset, size), disk_count
|
||||
return b'', 0
|
||||
|
||||
return await run_in_thread(read_headers)
|
||||
|
||||
def fs_txid_rev(self, tx_num: int) -> Tuple[Optional[bytes], int]:
|
||||
'''Return a pair (txid_rev, tx_height) for the given tx number.
|
||||
|
||||
If the tx_height is not on disk, returns (None, tx_height).'''
|
||||
tx_height = bisect_right(self.tx_counts, tx_num)
|
||||
if tx_height > self.db_height:
|
||||
txid_rev = None
|
||||
else:
|
||||
txid_rev = self.hashes_file.read(tx_num * 32, 32)
|
||||
return txid_rev, tx_height
|
||||
|
||||
def fs_txids_rev_at_blockheight(self, block_height: int) -> Sequence[bytes]:
|
||||
'''Return a list of txids_rev at given block height,
|
||||
in the same order as in the block.
|
||||
'''
|
||||
if block_height > self.db_height:
|
||||
raise self.DBError(f'block {block_height:,d} not on disk (>{self.db_height:,d})')
|
||||
assert block_height >= 0
|
||||
if block_height > 0:
|
||||
first_tx_num = self.tx_counts[block_height - 1]
|
||||
else:
|
||||
first_tx_num = 0
|
||||
num_txs_in_block = self.tx_counts[block_height] - first_tx_num
|
||||
txids_rev = self.hashes_file.read(first_tx_num * 32, num_txs_in_block * 32)
|
||||
assert num_txs_in_block == len(txids_rev) // 32
|
||||
return [txids_rev[idx * 32: (idx+1) * 32] for idx in range(num_txs_in_block)]
|
||||
|
||||
async def txids_rev_at_blockheight(self, block_height: int) -> Sequence[bytes]:
|
||||
return await run_in_thread(self.fs_txids_rev_at_blockheight, block_height)
|
||||
|
||||
async def fs_block_hashes_rev(self, height: int, count: int) -> Sequence[bytes]:
|
||||
headers_concat, headers_count = await self.read_headers(height, count)
|
||||
if headers_count != count:
|
||||
raise self.DBError(f'only got {headers_count:,d} headers starting '
|
||||
f'at {height:,d}, not {count:,d}')
|
||||
|
||||
def hash_headers():
|
||||
offset = 0
|
||||
headers = []
|
||||
for n in range(count):
|
||||
hlen = self.header_len(height + n)
|
||||
headers.append(headers_concat[offset:offset + hlen])
|
||||
offset += hlen
|
||||
return [self.coin.header_hash_rev(header) for header in headers]
|
||||
|
||||
bhashes = (await run_in_thread(hash_headers)) if count > 100 else hash_headers()
|
||||
return bhashes
|
||||
|
||||
async def limited_history(self, hashX: bytes, *, limit: int = 1000) -> Sequence[tuple[bytes, int]]:
|
||||
'''Return an unpruned, sorted list of (txid_rev, height) tuples of
|
||||
confirmed transactions that touched the address, earliest in
|
||||
the blockchain first. Includes both spending and receiving
|
||||
transactions. By default returns at most 1000 entries. Set
|
||||
limit to None to get them all.
|
||||
'''
|
||||
def read_history():
|
||||
tx_nums = list(self.history.get_txnums(hashX, limit))
|
||||
fs_txid_rev = self.fs_txid_rev
|
||||
return [fs_txid_rev(tx_num) for tx_num in tx_nums]
|
||||
|
||||
while True:
|
||||
history = await run_in_thread(read_history)
|
||||
if all(txid_rev is not None for txid_rev, height in history):
|
||||
return history
|
||||
self.logger.warning(f'limited_history: tx hash '
|
||||
f'not found (reorg?), retrying...')
|
||||
await sleep(0.25)
|
||||
|
||||
# -- Undo information
|
||||
|
||||
def min_undo_height(self, max_height: int) -> int:
|
||||
'''Returns a height from which we should store undo info.'''
|
||||
return max_height - self.env.reorg_limit + 1
|
||||
|
||||
def undo_key(self, height: int) -> bytes:
|
||||
'''DB key for undo information at the given height.'''
|
||||
return b'U' + pack_block_height(height)
|
||||
|
||||
def read_undo_info(self, height: int) -> bytes:
|
||||
'''Read undo information from a file for the current height.'''
|
||||
return self.utxo_db.get(self.undo_key(height))
|
||||
|
||||
def flush_undo_infos(
|
||||
self, batch_put, undo_infos: dict[int, Sequence[bytes]],
|
||||
) -> None:
|
||||
'''undo_infos is a (height -> undo_info) map.'''
|
||||
for height, undo_info in undo_infos.items():
|
||||
batch_put(self.undo_key(height), b''.join(undo_info))
|
||||
|
||||
def raw_block_prefix(self) -> str:
|
||||
return 'meta/block'
|
||||
|
||||
def raw_block_path(self, height: int) -> str:
|
||||
return f'{self.raw_block_prefix()}{height:d}'
|
||||
|
||||
def read_raw_block(self, height: int) -> bytes:
|
||||
'''Returns a raw block read from disk. Raises FileNotFoundError
|
||||
if the block isn't on-disk.'''
|
||||
with util.open_file(self.raw_block_path(height)) as f:
|
||||
return f.read(-1)
|
||||
|
||||
def write_raw_block(self, block: bytes, height: int) -> None:
|
||||
'''Write a raw block to disk.'''
|
||||
with util.open_truncate(self.raw_block_path(height)) as f:
|
||||
f.write(block)
|
||||
# Delete old blocks to prevent them accumulating
|
||||
del_height = self.min_undo_height(height) - 1
|
||||
if del_height >= 0:
|
||||
try:
|
||||
os.remove(self.raw_block_path(del_height))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def clear_excess_undo_info(self) -> None:
|
||||
'''Clear excess undo info. Only most recent N are kept.'''
|
||||
# delete aged undo_infos from utxo db
|
||||
# note: this is just a fallback cleanup path - normally already deleted by _flush_utxo_db()
|
||||
min_height = self.min_undo_height(self.db_height)
|
||||
keys = []
|
||||
for key, uhist in self.utxo_db.iterator(prefix=b'U'):
|
||||
height = unpack_block_height(key[-BHEIGHT_LEN:])
|
||||
if height >= min_height:
|
||||
break # can break as block_height is encoded as big endian
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
with self.utxo_db.write_batch() as batch:
|
||||
for key in keys:
|
||||
batch.delete(key)
|
||||
self.logger.info(f'deleted {len(keys):,d} stale undo entries')
|
||||
|
||||
# delete old block files
|
||||
# note: this is just a fallback cleanup path - normally already deleted by write_raw_block()
|
||||
prefix = self.raw_block_prefix()
|
||||
paths = [path for path in glob(f'{prefix}[0-9]*')
|
||||
if len(path) > len(prefix)
|
||||
and int(path[len(prefix):]) < min_height]
|
||||
if paths:
|
||||
for path in paths:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.logger.info(f'deleted {len(paths):,d} stale block files')
|
||||
|
||||
# -- UTXO database
|
||||
|
||||
def read_utxo_state(self) -> None:
|
||||
state = self.utxo_db.get(self.DB_STATE_KEY)
|
||||
if not state:
|
||||
self.db_height = -1
|
||||
self.db_tx_count = 0
|
||||
self.db_tip = b'\0' * 32
|
||||
self.db_version = max(self.DB_VERSIONS)
|
||||
self.wall_time = 0
|
||||
self.first_sync = True
|
||||
else:
|
||||
state = ast.literal_eval(state.decode())
|
||||
if not isinstance(state, dict):
|
||||
raise self.DBError('failed reading state from DB')
|
||||
self.db_version = state['db_version']
|
||||
if self.db_version not in self.DB_VERSIONS:
|
||||
raise DBTooOldForMigrations(
|
||||
db_name="UTXO", db_version=self.db_version, supported_versions=self.DB_VERSIONS)
|
||||
genesis_hash = state['genesis']
|
||||
if genesis_hash != self.coin.GENESIS_HASH:
|
||||
raise self.DBError(f'DB genesis hash {genesis_hash} does not '
|
||||
f'match coin {self.coin.GENESIS_HASH}')
|
||||
self.db_height = state['height']
|
||||
self.db_tx_count = state['tx_count']
|
||||
self.db_tip = state['tip']
|
||||
self.wall_time = state['wall_time']
|
||||
self.first_sync = state['first_sync']
|
||||
|
||||
# These are our state as we move ahead of DB state
|
||||
self.fs_height = self.db_height
|
||||
self.fs_tx_count = self.db_tx_count
|
||||
self.last_flush_tx_count = self.fs_tx_count
|
||||
|
||||
# Upgrade DB
|
||||
if self.db_version != max(self.DB_VERSIONS):
|
||||
raise Exception("missing db upgrade") # call future upgrade logic here
|
||||
|
||||
# Log some stats
|
||||
self.logger.info(f'UTXO DB version: {self.db_version:d}')
|
||||
self.logger.info(f'coin: {self.coin.NAME}')
|
||||
self.logger.info(f'network: {self.coin.NET}')
|
||||
self.logger.info(f'height: {self.db_height:,d}')
|
||||
self.logger.info(f'tip: {hash_to_hex_str(self.db_tip)}')
|
||||
self.logger.info(f'tx count: {self.db_tx_count:,d}')
|
||||
if self.utxo_db.for_sync:
|
||||
self.logger.info(f'flushing DB cache at {self.env.cache_MB:,d} MB')
|
||||
if self.first_sync:
|
||||
self.logger.info(
|
||||
f'sync time so far: {util.formatted_time(self.wall_time)}'
|
||||
)
|
||||
|
||||
def write_utxo_state(self, batch) -> None:
|
||||
'''Write (UTXO) state to the batch.'''
|
||||
state = {
|
||||
'genesis': self.coin.GENESIS_HASH,
|
||||
'height': self.db_height,
|
||||
'tx_count': self.db_tx_count,
|
||||
'tip': self.db_tip,
|
||||
'wall_time': self.wall_time,
|
||||
'first_sync': self.first_sync,
|
||||
'db_version': self.db_version,
|
||||
}
|
||||
batch.put(self.DB_STATE_KEY, repr(state).encode())
|
||||
|
||||
async def all_utxos(self, hashX: bytes) -> Sequence[UTXO]:
|
||||
'''Return all UTXOs for an address sorted in no particular order.'''
|
||||
def read_utxos():
|
||||
utxos = [] # type: list[UTXO]
|
||||
utxos_append = utxos.append
|
||||
# Key: b'u' + address_hashX + txout_idx + tx_num
|
||||
# Value: the UTXO value as a 64-bit unsigned integer
|
||||
prefix = b'u' + hashX
|
||||
for db_key, db_value in self.utxo_db.iterator(prefix=prefix):
|
||||
txout_idx = unpack_txoutidx(db_key[-TXNUM_LEN-TXOUTIDX_LEN:-TXNUM_LEN])
|
||||
tx_num = unpack_txnum(db_key[-TXNUM_LEN:])
|
||||
value = unpack_satoshis_val(db_value)
|
||||
txid_rev, height = self.fs_txid_rev(tx_num)
|
||||
utxos_append(UTXO(tx_num, txout_idx, txid_rev, height, value))
|
||||
return utxos
|
||||
|
||||
while True:
|
||||
utxos = await run_in_thread(read_utxos)
|
||||
if all(utxo.txid_rev is not None for utxo in utxos):
|
||||
return utxos
|
||||
self.logger.warning(f'all_utxos: tx hash not '
|
||||
f'found (reorg?), retrying...')
|
||||
await sleep(0.25)
|
||||
|
||||
async def lookup_utxos(self, prevouts: Sequence[tuple[bytes, int]]) -> Sequence[Optional[tuple[bytes, int]]]:
|
||||
'''For each prevout, lookup it up in the DB and return a (hashX,
|
||||
value) pair or None if not found.
|
||||
|
||||
Used by the mempool code.
|
||||
'''
|
||||
def lookup_hashXs() -> Sequence[tuple[Optional[bytes], Optional[bytes]]]:
|
||||
'''Return (hashX, suffix) pairs, or None if not found,
|
||||
for each prevout.
|
||||
'''
|
||||
def lookup_hashX(txid_rev: bytes, tx_idx: int) -> tuple[Optional[bytes], Optional[bytes]]:
|
||||
idx_packed = pack_txoutidx(tx_idx)
|
||||
|
||||
# Key: b'h' + compressed_tx_hash + tx_idx + tx_num
|
||||
# Value: hashX
|
||||
prefix = b'h' + txid_rev[:COMP_TXID_LEN] + idx_packed
|
||||
|
||||
# Find which entry, if any, the TX_HASH matches.
|
||||
for db_key, hashX in self.utxo_db.iterator(prefix=prefix):
|
||||
tx_num_packed = db_key[-TXNUM_LEN:]
|
||||
tx_num = unpack_txnum(tx_num_packed)
|
||||
hash, _height = self.fs_txid_rev(tx_num)
|
||||
if hash == txid_rev:
|
||||
return hashX, idx_packed + tx_num_packed
|
||||
return None, None
|
||||
return [lookup_hashX(*prevout) for prevout in prevouts]
|
||||
|
||||
def lookup_utxos(
|
||||
hashX_pairs: Sequence[tuple[Optional[bytes], Optional[bytes]]],
|
||||
) -> Sequence[Optional[tuple[bytes, int]]]:
|
||||
def lookup_utxo(hashX: bytes, suffix: bytes) -> Optional[tuple[bytes, int]]:
|
||||
if not hashX:
|
||||
# This can happen when the daemon is a block ahead
|
||||
# of us and has mempool txs spending outputs from
|
||||
# that new block
|
||||
return None
|
||||
# Key: b'u' + address_hashX + tx_idx + tx_num
|
||||
# Value: the UTXO value as a 64-bit unsigned integer
|
||||
key = b'u' + hashX + suffix
|
||||
db_value = self.utxo_db.get(key)
|
||||
if not db_value:
|
||||
# This can happen if the DB was updated between
|
||||
# getting the hashXs and getting the UTXOs
|
||||
return None
|
||||
value = unpack_satoshis_val(db_value)
|
||||
return hashX, value
|
||||
return [lookup_utxo(*hashX_pair) for hashX_pair in hashX_pairs]
|
||||
|
||||
hashX_pairs = await run_in_thread(lookup_hashXs)
|
||||
return await run_in_thread(lookup_utxos, hashX_pairs)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Copyright (C) 2026 The Electrum developers
|
||||
# Distributed under the MIT software license, see the accompanying
|
||||
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from electrumx.lib.util import (
|
||||
pack_be_uint32, unpack_be_uint32,
|
||||
pack_be_uint64, unpack_be_uint64,
|
||||
)
|
||||
|
||||
|
||||
# txnum
|
||||
TXNUM_LEN = 5
|
||||
TXNUM_PADDING = bytes(8 - TXNUM_LEN)
|
||||
|
||||
|
||||
def unpack_txnum(tx_numb: bytes) -> int:
|
||||
return unpack_be_uint64(TXNUM_PADDING + tx_numb)[0]
|
||||
|
||||
|
||||
def pack_txnum(tx_num: int) -> bytes:
|
||||
return pack_be_uint64(tx_num)[-TXNUM_LEN:]
|
||||
|
||||
|
||||
assert pack_txnum(0x123) == bytes.fromhex("0000000123")
|
||||
assert unpack_txnum(pack_txnum(0x123)) == 0x123
|
||||
|
||||
|
||||
# txout_idx
|
||||
TXOUTIDX_LEN = 3
|
||||
TXOUTIDX_PADDING = bytes(4 - TXOUTIDX_LEN)
|
||||
|
||||
|
||||
def unpack_txoutidx(txout_idx: bytes) -> int:
|
||||
return unpack_be_uint32(TXOUTIDX_PADDING + txout_idx)[0]
|
||||
|
||||
|
||||
def pack_txoutidx(txout_idx: int) -> bytes:
|
||||
return pack_be_uint32(txout_idx)[-TXOUTIDX_LEN:]
|
||||
|
||||
|
||||
assert pack_txoutidx(0x123) == bytes.fromhex("000123")
|
||||
assert unpack_txoutidx(pack_txoutidx(0x123)) == 0x123
|
||||
|
||||
|
||||
# sats
|
||||
def unpack_satoshis_val(sats: bytes) -> int:
|
||||
return unpack_be_uint64(sats)[0]
|
||||
|
||||
|
||||
def pack_satoshis_val(sats: int) -> bytes:
|
||||
return pack_be_uint64(sats)
|
||||
|
||||
|
||||
# block height
|
||||
BHEIGHT_LEN = 4
|
||||
|
||||
|
||||
def unpack_block_height(bheight: bytes) -> int:
|
||||
return unpack_be_uint32(bheight)[0]
|
||||
|
||||
|
||||
def pack_block_height(bheight: int) -> bytes:
|
||||
return pack_be_uint32(bheight)
|
||||
|
||||
|
||||
# dynamic block header offsets
|
||||
DYN_HEADER_OFFSET_LEN = 8
|
||||
|
||||
|
||||
def unpack_dyn_header_offset(offset: bytes) -> int:
|
||||
return unpack_be_uint64(offset)[0]
|
||||
|
||||
|
||||
def pack_dyn_header_offset(offset: int) -> bytes:
|
||||
return pack_be_uint64(offset)
|
||||
|
||||
|
||||
class DBTooOldForMigrations(RuntimeError):
|
||||
def __init__(self, *, db_name: str, db_version: int, supported_versions: Sequence[int]):
|
||||
cmd = 'rm -rf DB_DIRECTORY/{hist,meta,utxo}'
|
||||
super().__init__(
|
||||
f'Your {db_name} DB version is {db_version} but this software only handles versions {supported_versions}. '
|
||||
f'Manually delete your database (e.g. `{cmd}`, and start again. '
|
||||
f'Then, your DB will be rebuilt from genesis, likely taking several hours. '
|
||||
f"If you don't have time for this now, you can temporarily downgrade the software."
|
||||
)
|
||||
@@ -0,0 +1,245 @@
|
||||
# Copyright (c) 2016, Neil Booth
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
'''Class for handling environment configuration and defaults.'''
|
||||
|
||||
|
||||
import re
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from typing import Type, Sequence
|
||||
|
||||
from aiorpcx import Service, ServicePart
|
||||
|
||||
import electrumx
|
||||
from electrumx.lib.coins import Coin
|
||||
from electrumx.lib.env_base import EnvBase
|
||||
|
||||
|
||||
class ServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Env(EnvBase):
|
||||
'''Wraps environment configuration. Optionally, accepts a Coin class
|
||||
as first argument to have ElectrumX serve custom coins not part of
|
||||
the standard distribution.
|
||||
'''
|
||||
|
||||
# Peer discovery
|
||||
PD_OFF, PD_SELF, PD_ON = ('OFF', 'SELF', 'ON')
|
||||
SSL_PROTOCOLS = {'ssl', 'wss'}
|
||||
KNOWN_PROTOCOLS = {'ssl', 'tcp', 'ws', 'wss', 'rpc'}
|
||||
|
||||
coin: Type[Coin]
|
||||
|
||||
def __init__(self, coin=None):
|
||||
super().__init__()
|
||||
self.obsolete(["MAX_SUBSCRIPTIONS", "MAX_SUBS", "MAX_SESSION_SUBS", "BANDWIDTH_LIMIT",
|
||||
"HOST", "TCP_PORT", "SSL_PORT", "RPC_HOST", "RPC_PORT", "REPORT_HOST",
|
||||
"REPORT_TCP_PORT", "REPORT_SSL_PORT", "REPORT_HOST_TOR",
|
||||
"REPORT_TCP_PORT_TOR", "REPORT_SSL_PORT_TOR"])
|
||||
|
||||
# Core items
|
||||
|
||||
self.db_dir = self.required('DB_DIRECTORY')
|
||||
self.db_engine = self.db_engine_enum()
|
||||
self.daemon_url = self.required('DAEMON_URL')
|
||||
if coin is not None:
|
||||
assert issubclass(coin, Coin)
|
||||
self.coin = coin
|
||||
else:
|
||||
coin_name = self.default('COIN', 'Bitcoin').strip()
|
||||
network = self.default('NET', 'mainnet').strip()
|
||||
self.coin = Coin.lookup_coin_class(coin_name, network)
|
||||
|
||||
# Peer discovery
|
||||
|
||||
self.peer_discovery = self.peer_discovery_enum()
|
||||
self.peer_announce = self.boolean('PEER_ANNOUNCE', True)
|
||||
self.force_proxy = self.boolean('FORCE_PROXY', False)
|
||||
self.tor_proxy_host = self.default('TOR_PROXY_HOST', 'localhost')
|
||||
self.tor_proxy_port = self.integer('TOR_PROXY_PORT', None)
|
||||
|
||||
# Misc
|
||||
self.banner_file = self.default('BANNER_FILE', None)
|
||||
self.tor_banner_file = self.default('TOR_BANNER_FILE',
|
||||
self.banner_file)
|
||||
self.anon_logs = self.boolean('ANON_LOGS', False)
|
||||
self.log_sessions = self.integer('LOG_SESSIONS', 3600)
|
||||
self.log_level = self.default('LOG_LEVEL', 'info').upper()
|
||||
self.donation_address = self.default('DONATION_ADDRESS', '')
|
||||
self.drop_client = self.custom("DROP_CLIENT", None, re.compile)
|
||||
self.blacklist_url = self.default('BLACKLIST_URL', self.coin.BLACKLIST_URL)
|
||||
self.cache_MB = self.integer('CACHE_MB', 1200)
|
||||
self.reorg_limit = self.integer('REORG_LIMIT', self.coin.REORG_LIMIT)
|
||||
self.daemon_poll_interval_blocks_msec = self.integer('DAEMON_POLL_INTERVAL_BLOCKS', 5000)
|
||||
self.daemon_poll_interval_mempool_msec = self.integer('DAEMON_POLL_INTERVAL_MEMPOOL', 5000)
|
||||
|
||||
# Server limits to help prevent DoS
|
||||
|
||||
self.max_send = self.integer('MAX_SEND', self.coin.DEFAULT_MAX_SEND)
|
||||
self.max_recv = self.integer('MAX_RECV', self.coin.DEFAULT_MAX_RECV)
|
||||
self._try_raising_rlimit_nofile() # do this before setting max_sessions
|
||||
self.max_sessions = self.sane_max_sessions()
|
||||
self.cost_soft_limit = self.integer('COST_SOFT_LIMIT', 1000)
|
||||
self.cost_hard_limit = self.integer('COST_HARD_LIMIT', 10000)
|
||||
self.bw_unit_cost = self.integer('BANDWIDTH_UNIT_COST', 5000)
|
||||
self.initial_concurrent = self.integer('INITIAL_CONCURRENT', 10)
|
||||
self.request_sleep = self.integer('REQUEST_SLEEP', 2500)
|
||||
self.request_timeout = self.integer('REQUEST_TIMEOUT', 30)
|
||||
self.session_timeout = self.integer('SESSION_TIMEOUT', 600)
|
||||
self.session_group_by_subnet_ipv4 = self.integer('SESSION_GROUP_BY_SUBNET_IPV4', 24)
|
||||
self.session_group_by_subnet_ipv6 = self.integer('SESSION_GROUP_BY_SUBNET_IPV6', 48)
|
||||
self._check_and_fix_cost_limits()
|
||||
|
||||
# Services last - uses some env vars above
|
||||
|
||||
self.services = self.services_to_run()
|
||||
if {service.protocol for service in self.services}.intersection(self.SSL_PROTOCOLS):
|
||||
self.ssl_certfile = self.required('SSL_CERTFILE')
|
||||
self.ssl_keyfile = self.required('SSL_KEYFILE')
|
||||
self.report_services = self.services_to_report()
|
||||
|
||||
def _try_raising_rlimit_nofile(self) -> None:
|
||||
"""Try raising the max num open file soft limit to the hard limit."""
|
||||
try:
|
||||
import resource
|
||||
except ImportError:
|
||||
return # No resource module on Windows
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
if soft >= hard:
|
||||
return
|
||||
try:
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
|
||||
except (ValueError, OSError):
|
||||
self.logger.warning(
|
||||
f"error raising RLIMIT_NOFILE (ulimit -n) soft limit. Stuck with {soft}. "
|
||||
f"You must ensure that electrumx has a large open file limit. See HOWTO in docs.")
|
||||
else:
|
||||
self.logger.info(
|
||||
f"successfully raised RLIMIT_NOFILE (ulimit -n) soft limit from {soft} to {hard}")
|
||||
|
||||
def sane_max_sessions(self):
|
||||
'''Return the maximum number of sessions to permit. Normally this
|
||||
is MAX_SESSIONS. However, to prevent open file exhaustion, adjust
|
||||
downwards if running with a small open file rlimit.'''
|
||||
env_value = self.integer('MAX_SESSIONS', 1000)
|
||||
# No resource module on Windows
|
||||
try:
|
||||
import resource
|
||||
nofile_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
|
||||
# We give the DB 250 files; allow ElectrumX 100 for itself
|
||||
value = max(0, min(env_value, nofile_limit - 350))
|
||||
if value < env_value:
|
||||
self.logger.warning(
|
||||
f'lowered maximum sessions from {env_value:,d} to '
|
||||
f'{value:,d} because your open file limit is '
|
||||
f'{nofile_limit:,d}'
|
||||
)
|
||||
except ImportError:
|
||||
value = 512 # that is what returned by stdio's _getmaxstdio()
|
||||
return value
|
||||
|
||||
def _check_and_fix_cost_limits(self):
|
||||
if self.cost_hard_limit < self.cost_soft_limit:
|
||||
raise self.Error(f"COST_HARD_LIMIT must be >= COST_SOFT_LIMIT. "
|
||||
f"got (COST_HARD_LIMIT={self.cost_hard_limit} "
|
||||
f"and COST_SOFT_LIMIT={self.cost_soft_limit})")
|
||||
# hard limit should be strictly higher than soft limit (unless both are 0)
|
||||
if self.cost_hard_limit == self.cost_soft_limit and self.cost_soft_limit > 0:
|
||||
self.logger.info("found COST_HARD_LIMIT == COST_SOFT_LIMIT. "
|
||||
"bumping COST_HARD_LIMIT by 1.")
|
||||
self.cost_hard_limit = self.cost_soft_limit + 1
|
||||
|
||||
def _parse_services(self, services_str, default_func) -> Sequence[Service]:
|
||||
result = []
|
||||
for service_str in services_str.split(','):
|
||||
if not service_str:
|
||||
continue
|
||||
try:
|
||||
service = Service.from_string(service_str, default_func=default_func)
|
||||
except Exception as e:
|
||||
raise ServiceError(f'"{service_str}" invalid: {e}') from None
|
||||
if service.protocol not in self.KNOWN_PROTOCOLS:
|
||||
raise ServiceError(f'"{service_str}" invalid: unknown protocol')
|
||||
result.append(service)
|
||||
|
||||
# Find duplicate addresses
|
||||
service_map = {service.address: [] for service in result}
|
||||
for service in result:
|
||||
service_map[service.address].append(service)
|
||||
for address, services in service_map.items():
|
||||
if len(services) > 1:
|
||||
raise ServiceError(f'address {address} has multiple services')
|
||||
|
||||
return result
|
||||
|
||||
def services_to_run(self) -> Sequence[Service]:
|
||||
def default_part(protocol, part):
|
||||
return default_services.get(protocol, {}).get(part)
|
||||
|
||||
default_services = {protocol: {ServicePart.HOST: 'all_interfaces'}
|
||||
for protocol in self.KNOWN_PROTOCOLS}
|
||||
default_services['rpc'] = {ServicePart.HOST: 'localhost', ServicePart.PORT: 8000}
|
||||
services = self._parse_services(self.default('SERVICES', ''), default_part)
|
||||
|
||||
# Find onion hosts
|
||||
for service in services:
|
||||
if str(service.host).endswith('.onion'):
|
||||
raise ServiceError(f'bad host for SERVICES: {service}')
|
||||
|
||||
return services
|
||||
|
||||
def services_to_report(self):
|
||||
services = self._parse_services(self.default('REPORT_SERVICES', ''), None)
|
||||
|
||||
for service in services:
|
||||
if service.protocol == 'rpc':
|
||||
raise ServiceError(f'bad protocol for REPORT_SERVICES: {service.protocol}')
|
||||
if isinstance(service.host, (IPv4Address, IPv6Address)):
|
||||
ip_addr = service.host
|
||||
if (ip_addr.is_multicast or ip_addr.is_unspecified or
|
||||
(ip_addr.is_private and self.peer_announce)):
|
||||
raise ServiceError(f'bad IP address for REPORT_SERVICES: {ip_addr}')
|
||||
elif service.host.lower() == 'localhost':
|
||||
raise ServiceError(f'bad host for REPORT_SERVICES: {service.host}')
|
||||
|
||||
return services
|
||||
|
||||
def peer_discovery_enum(self):
|
||||
pd = self.default('PEER_DISCOVERY', 'on').strip().lower()
|
||||
if pd in ('off', ''):
|
||||
return self.PD_OFF
|
||||
elif pd == 'self':
|
||||
return self.PD_SELF
|
||||
else:
|
||||
return self.PD_ON
|
||||
|
||||
def db_engine_enum(self) -> str:
|
||||
from electrumx.server.storage import list_db_engine_choices
|
||||
choices = list_db_engine_choices()
|
||||
db_eng = self.default('DB_ENGINE', None)
|
||||
|
||||
if db_eng is None:
|
||||
raise self.Error(
|
||||
f"required envvar DB_ENGINE not set.\n"
|
||||
f"Choose one from: {choices}.\n"
|
||||
f"The corresponding dependencies also need to be installed. See pip extras.\n"
|
||||
f"In ElectrumX 1.x versions, the default was leveldb.\n"
|
||||
f"Warning: It is not possible to switch back and forth, "
|
||||
f"the on-disk DB formats are not compatible with each other: when switching, "
|
||||
f"you have to resync from genesis.\n"
|
||||
f"LevelDB was written with HDDs in mind. RocksDB is more modern and performs better (on an SSD)."
|
||||
)
|
||||
|
||||
db_eng = db_eng.lower()
|
||||
if db_eng in choices:
|
||||
return db_eng
|
||||
else:
|
||||
raise self.Error(
|
||||
f"bad value for envvar DB_ENGINE: {db_eng!r}. Choose one from: {choices}."
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
# Copyright (c) 2016-2018, Neil Booth
|
||||
# Copyright (c) 2017, the ElectrumX authors
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
'''History by script hash (address).'''
|
||||
|
||||
import ast
|
||||
import bisect
|
||||
import time
|
||||
from array import array
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Type, Optional, Sequence, Iterable
|
||||
|
||||
import electrumx.lib.util as util
|
||||
from electrumx.lib.hash import HASHX_LEN
|
||||
from electrumx.server.db_util import (
|
||||
DBTooOldForMigrations,
|
||||
pack_txnum, unpack_txnum, TXNUM_LEN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from electrumx.server.storage import Storage
|
||||
|
||||
|
||||
class History:
|
||||
|
||||
DB_VERSIONS = (4, )
|
||||
DB_STATE_KEY = b'state\0\0'
|
||||
|
||||
db: Optional['Storage']
|
||||
|
||||
def __init__(self):
|
||||
self.logger = util.class_logger(__name__, self.__class__.__name__)
|
||||
self.unflushed = set() # type: set[bytes]
|
||||
self.hist_db_tx_count = 0
|
||||
self.hist_db_tx_count_next = 0 # after next flush, next value for self.hist_db_tx_count
|
||||
self.db_version = max(self.DB_VERSIONS)
|
||||
self.upgrade_cursor = -1
|
||||
|
||||
# Key: b'H' + address_hashX + tx_num
|
||||
# Value: <null>
|
||||
self.db = None
|
||||
|
||||
def open_db(
|
||||
self,
|
||||
*,
|
||||
db_class: Type['Storage'],
|
||||
for_sync: bool,
|
||||
utxo_db_tx_count: int,
|
||||
) -> None:
|
||||
self.db = db_class('hist', for_sync)
|
||||
self.read_state()
|
||||
self.clear_excess(utxo_db_tx_count)
|
||||
|
||||
def close_db(self) -> None:
|
||||
if self.db:
|
||||
self.db.close()
|
||||
self.db = None
|
||||
|
||||
def read_state(self) -> None:
|
||||
state = self.db.get(self.DB_STATE_KEY)
|
||||
if state:
|
||||
state = ast.literal_eval(state.decode())
|
||||
if not isinstance(state, dict):
|
||||
raise RuntimeError('failed reading state from history DB')
|
||||
self.db_version = state.get('db_version', 0)
|
||||
self.upgrade_cursor = state.get('upgrade_cursor', -1)
|
||||
self.hist_db_tx_count = state.get('hist_db_tx_count', 0)
|
||||
self.hist_db_tx_count_next = self.hist_db_tx_count
|
||||
|
||||
if self.db_version not in self.DB_VERSIONS:
|
||||
raise DBTooOldForMigrations(
|
||||
db_name="history", db_version=self.db_version, supported_versions=self.DB_VERSIONS)
|
||||
if self.db_version != max(self.DB_VERSIONS):
|
||||
raise Exception("missing db upgrade") # call future upgrade logic here
|
||||
self.logger.info(f'history DB version: {self.db_version}')
|
||||
|
||||
def clear_excess(self, utxo_db_tx_count: int) -> None:
|
||||
# self.hist_db_tx_count != utxo_db_tx_count might happen as
|
||||
# both DBs cannot be updated atomically.
|
||||
# However, we MUST maintain the invariant that hist_db_tx_count >= utxo_db_tx_count:
|
||||
# - when advancing blocks, hist_db is flushed first, so its count can be higher;
|
||||
# but when backing up (during reorg), hist_db is flushed last, so its count can be higher.
|
||||
if self.hist_db_tx_count == utxo_db_tx_count:
|
||||
return # happy path
|
||||
elif self.hist_db_tx_count < utxo_db_tx_count:
|
||||
raise RuntimeError(f"corrupted DB. {self.hist_db_tx_count=} < {utxo_db_tx_count=}")
|
||||
|
||||
assert self.hist_db_tx_count > utxo_db_tx_count
|
||||
self.logger.info(
|
||||
'DB shut down uncleanly. Scanning for excess history flushes... '
|
||||
'This might take a loooong time. :/'
|
||||
)
|
||||
|
||||
utxo_db_tx_count_bytes = pack_txnum(utxo_db_tx_count)
|
||||
keys = [] # FIXME could grow large in-memory?
|
||||
for iter_cnt, (db_key, _db_val) in enumerate(self.db.iterator(prefix=b'H')):
|
||||
if iter_cnt % 10_000_000 == 0:
|
||||
self.logger.info(
|
||||
f'Scanning for excess history flushes... num history entries checked: '
|
||||
f'{iter_cnt // 1_000_000} million. will delete: {len(keys):,d}')
|
||||
tx_numb = db_key[-TXNUM_LEN:]
|
||||
# we exploit that pack_txnum uses big-endian byte-order, and just compare the bytes directly:
|
||||
# tx_num = unpack_txnum(tx_numb)
|
||||
if tx_numb >= utxo_db_tx_count_bytes:
|
||||
keys.append(db_key)
|
||||
|
||||
self.logger.info(f'deleting {len(keys):,d} history entries')
|
||||
|
||||
self.hist_db_tx_count = utxo_db_tx_count
|
||||
self.hist_db_tx_count_next = self.hist_db_tx_count
|
||||
with self.db.write_batch() as batch:
|
||||
for key in keys:
|
||||
batch.delete(key)
|
||||
self._write_state(batch)
|
||||
|
||||
self.logger.info('deleted excess history entries')
|
||||
|
||||
def _write_state(self, batch) -> None:
|
||||
'''Write state to the history DB.'''
|
||||
state = {
|
||||
'hist_db_tx_count': self.hist_db_tx_count,
|
||||
'db_version': self.db_version,
|
||||
'upgrade_cursor': self.upgrade_cursor,
|
||||
}
|
||||
batch.put(self.DB_STATE_KEY, repr(state).encode())
|
||||
|
||||
def add_unflushed(
|
||||
self,
|
||||
hashXs_by_tx: list[list[bytes]],
|
||||
first_tx_num: int,
|
||||
) -> None:
|
||||
"""This method must be thread-safe. (runs on bp.pool_executor)"""
|
||||
unflushed = self.unflushed
|
||||
for tx_num, hashXs in enumerate(hashXs_by_tx, start=first_tx_num):
|
||||
tx_numb = pack_txnum(tx_num)
|
||||
for hashX in hashXs:
|
||||
unflushed.add(hashX + tx_numb)
|
||||
|
||||
def update_tx_count_next(self, tx_count: int) -> None:
|
||||
self.hist_db_tx_count_next = tx_count
|
||||
|
||||
def unflushed_memsize(self) -> int:
|
||||
# note: the magic numbers here were estimated using util.deep_getsizeof
|
||||
return len(self.unflushed) * 85 + len(self.unflushed) * (HASHX_LEN + TXNUM_LEN)
|
||||
|
||||
def assert_flushed(self) -> None:
|
||||
assert not self.unflushed
|
||||
|
||||
def flush(self) -> None:
|
||||
start_time = time.monotonic()
|
||||
unflushed = self.unflushed
|
||||
|
||||
with self.db.write_batch() as batch:
|
||||
for hashX_txnum in sorted(unflushed):
|
||||
db_key = b'H' + hashX_txnum
|
||||
batch.put(db_key, b'')
|
||||
self.hist_db_tx_count = self.hist_db_tx_count_next
|
||||
self._write_state(batch)
|
||||
|
||||
count = len(unflushed)
|
||||
unflushed.clear()
|
||||
|
||||
if self.db.for_sync:
|
||||
elapsed = time.monotonic() - start_time
|
||||
self.logger.info(f'flushed history in {elapsed:.1f}s '
|
||||
f'for {count:,d} history entries')
|
||||
|
||||
def backup(self, *, hashXs: Iterable[bytes], tx_count: int) -> None:
|
||||
self.assert_flushed()
|
||||
nremoves = 0
|
||||
with self.db.write_batch() as batch:
|
||||
for hashX in sorted(hashXs):
|
||||
deletes = []
|
||||
prefix = b'H' + hashX
|
||||
for db_key, db_val in self.db.iterator(prefix=prefix, reverse=True):
|
||||
tx_numb = db_key[-TXNUM_LEN:]
|
||||
tx_num = unpack_txnum(tx_numb)
|
||||
if tx_num >= tx_count:
|
||||
nremoves += 1
|
||||
deletes.append(db_key)
|
||||
else:
|
||||
# note: we can break now, due to 'reverse=True' and txnums being big endian
|
||||
break
|
||||
for key in deletes:
|
||||
batch.delete(key)
|
||||
self.hist_db_tx_count = tx_count
|
||||
self.hist_db_tx_count_next = self.hist_db_tx_count
|
||||
self._write_state(batch)
|
||||
|
||||
self.logger.info(f'backing up removed {nremoves:,d} history entries')
|
||||
|
||||
def get_txnums(self, hashX: bytes, limit: int = 1000) -> Iterable[int]:
|
||||
'''Generator that returns an unpruned, sorted list of tx_nums in the
|
||||
history of a hashX. Includes both spending and receiving
|
||||
transactions. By default yields at most 1000 entries. Set
|
||||
limit to None to get them all. '''
|
||||
limit = util.resolve_limit(limit)
|
||||
prefix = b'H' + hashX
|
||||
for db_key, db_val in self.db.iterator(prefix=prefix):
|
||||
tx_numb = db_key[-TXNUM_LEN:]
|
||||
if limit == 0:
|
||||
return
|
||||
tx_num = unpack_txnum(tx_numb)
|
||||
yield tx_num
|
||||
limit -= 1
|
||||
@@ -0,0 +1,626 @@
|
||||
# Copyright (c) 2016-2018, Neil Booth
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
'''Mempool handling.'''
|
||||
|
||||
import itertools
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence, Tuple, TYPE_CHECKING, Type, Dict, Optional, Set, Iterable
|
||||
import math
|
||||
from graphlib import TopologicalSorter
|
||||
|
||||
from aiorpcx import run_in_thread, sleep, ignore_after
|
||||
|
||||
from electrumx.lib.hash import hash_to_hex_str, hex_str_to_hash
|
||||
from electrumx.lib.tx import SkipTxDeserialize
|
||||
from electrumx.lib.util import class_logger, chunks, OldTaskGroup, LogTimeTaken
|
||||
from electrumx.lib.tx import TXOSpendStatus, TxOutpoint
|
||||
from electrumx.server.db import UTXO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from electrumx.lib.coins import Coin
|
||||
|
||||
|
||||
DB_UTXO_MAP = dict[TxOutpoint, Optional[tuple[bytes, int]]] # prevout->(hashX,value_in_sats)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MemPoolTx:
|
||||
prevouts: Sequence[TxOutpoint] # (txid_rev, txout_idx)
|
||||
# A pair is a (hashX, value) tuple
|
||||
in_pairs: Optional[Sequence[tuple[bytes, int]]] # (hashX, value_in_sats)
|
||||
out_pairs: Sequence[tuple[bytes, int]] # (hashX, value_in_sats)
|
||||
fee: int # in sats
|
||||
size: int # in vbytes
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MemPoolTxSummary:
|
||||
txid_rev: bytes
|
||||
fee: int # in sats
|
||||
has_unconfirmed_inputs: bool
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True, kw_only=True)
|
||||
class RecentMemPoolTx:
|
||||
txid_rev: bytes
|
||||
fee: int # in sats
|
||||
vsize: int # in vbytes
|
||||
|
||||
|
||||
class DBSyncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MemPoolAPI(ABC):
|
||||
'''A concrete instance of this class is passed to the MemPool object
|
||||
and used by it to query DB and blockchain state.'''
|
||||
|
||||
@abstractmethod
|
||||
async def height(self) -> int:
|
||||
'''Query bitcoind for its height.'''
|
||||
|
||||
@abstractmethod
|
||||
def cached_height(self) -> Optional[int]:
|
||||
'''Return the height of bitcoind the last time it was queried,
|
||||
for any reason, without actually querying it.
|
||||
'''
|
||||
|
||||
@abstractmethod
|
||||
async def daemon_height_changed(self) -> None:
|
||||
'''Wait until bitcoind's height changes.'''
|
||||
|
||||
@abstractmethod
|
||||
def db_height(self) -> int:
|
||||
'''Return the height flushed to the on-disk DB.'''
|
||||
|
||||
@abstractmethod
|
||||
async def db_height_changed(self) -> None:
|
||||
'''Wait until the on-disk DB height changes.'''
|
||||
|
||||
@abstractmethod
|
||||
async def mempool_txids_hum(self) -> Sequence[str]:
|
||||
'''Query bitcoind for the txids of all transactions in its
|
||||
mempool, returned as a list.'''
|
||||
|
||||
@abstractmethod
|
||||
async def raw_transactions(self, txids_hum: Iterable[str]) -> Sequence[bytes | None]:
|
||||
'''Query bitcoind for the serialized raw transactions with the given
|
||||
txids. Missing transactions are returned as None.
|
||||
|
||||
txids_hum is an iterable of hexadecimal hash strings.'''
|
||||
|
||||
@abstractmethod
|
||||
async def lookup_utxos(self, prevouts: Sequence[TxOutpoint]) -> Sequence[Optional[Tuple[bytes, int]]]:
|
||||
'''Return a list of (hashX, value) pairs, one for each prevout if unspent,
|
||||
otherwise return None if spent or not found (for the given prevout).
|
||||
|
||||
prevouts - an iterable of (txid_rev, txout_idx) pairs
|
||||
'''
|
||||
|
||||
@abstractmethod
|
||||
async def on_mempool(
|
||||
self,
|
||||
*,
|
||||
touched_hashxs: Set[bytes],
|
||||
touched_outpoints: Set[TxOutpoint],
|
||||
height: int,
|
||||
):
|
||||
'''Called each time the mempool is synchronized. touched_hashxs and
|
||||
touched_outpoints are sets of hashXs and tx outpoints touched since
|
||||
the previous call. height is the daemon's height at the time the
|
||||
mempool was obtained.
|
||||
'''
|
||||
|
||||
|
||||
class MemPool:
|
||||
'''Representation of the daemon's mempool.
|
||||
|
||||
coin - a coin class from coins.py
|
||||
api - an object implementing MemPoolAPI
|
||||
|
||||
Updated regularly in caught-up state. Goal is to enable efficient
|
||||
response to the calls in the external interface. To that end we
|
||||
maintain the following maps:
|
||||
|
||||
tx: txid_rev -> MemPoolTx
|
||||
hashXs: hashX -> set of all txids_rev of txs touching the hashX
|
||||
'''
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coin: Type['Coin'],
|
||||
api: MemPoolAPI,
|
||||
*,
|
||||
refresh_secs=5.0,
|
||||
log_status_secs=60.0,
|
||||
):
|
||||
assert isinstance(api, MemPoolAPI)
|
||||
self.coin = coin
|
||||
self.api = api
|
||||
self.logger = class_logger(__name__, self.__class__.__name__)
|
||||
self.txs = {} # type: Dict[bytes, MemPoolTx] # txid_rev->tx
|
||||
self.hashXs = defaultdict(set) # type: Dict[Optional[bytes], Set[bytes]] # hashX->txids_rev
|
||||
self.txo_to_spender = {} # type: Dict[TxOutpoint, bytes] # prevout->txid_rev
|
||||
self.cached_compact_histogram = [] # type: Sequence[tuple[float, int]]
|
||||
self.refresh_secs = refresh_secs
|
||||
self.log_status_secs = log_status_secs
|
||||
# Prevents mempool refreshes during fee histogram calculation
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def _logging(self, synchronized_event):
|
||||
'''Print regular logs of mempool stats.'''
|
||||
self.logger.info('beginning processing of daemon mempool. '
|
||||
'This can take some time...')
|
||||
start = time.monotonic()
|
||||
await synchronized_event.wait()
|
||||
elapsed = time.monotonic() - start
|
||||
self.logger.info(f'synced in {elapsed:.2f}s')
|
||||
while True:
|
||||
mempool_size = sum(tx.size for tx in self.txs.values()) / 1_000_000
|
||||
self.logger.info(f'{len(self.txs):,d} txs {mempool_size:.2f} MB, '
|
||||
f'touching {len(self.hashXs):,d} addresses. '
|
||||
f'{len(self.txo_to_spender):,d} spends.')
|
||||
await sleep(self.log_status_secs)
|
||||
await synchronized_event.wait()
|
||||
|
||||
async def _refresh_histogram(self, synchronized_event):
|
||||
while True:
|
||||
await synchronized_event.wait()
|
||||
async with self.lock:
|
||||
# Threaded as can be expensive
|
||||
bin_size = self.coin.MEMPOOL_COMPACT_HISTOGRAM_BINSIZE
|
||||
await run_in_thread(self._update_histogram, bin_size)
|
||||
await sleep(self.coin.MEMPOOL_HISTOGRAM_REFRESH_SECS)
|
||||
|
||||
def _update_histogram(self, bin_size):
|
||||
# Build a histogram by fee rate
|
||||
t0 = time.monotonic()
|
||||
histogram = defaultdict(int)
|
||||
for tx in self.txs.values():
|
||||
fee_rate = tx.fee / tx.size
|
||||
# use 0.1 sat/byte resolution
|
||||
# note: rounding *down* is intentional. This ensures txs
|
||||
# with a given fee rate will end up counted in the expected
|
||||
# bucket/interval of the compact histogram.
|
||||
fee_rate = math.floor(10 * fee_rate) / 10
|
||||
histogram[fee_rate] += tx.size
|
||||
|
||||
compact = self._compress_histogram(histogram, bin_size=bin_size)
|
||||
self.logger.info(f'compact fee histogram: {compact}. took {time.monotonic() - t0:.3f}s.')
|
||||
self.cached_compact_histogram = compact
|
||||
|
||||
@classmethod
|
||||
def _compress_histogram(
|
||||
cls, histogram: Dict[float, int], *, bin_size: int
|
||||
) -> Sequence[Tuple[float, int]]:
|
||||
'''Calculate and return a compact fee histogram as needed for
|
||||
"mempool.get_fee_histogram" protocol request.
|
||||
|
||||
histogram: feerate (sat/vbyte) -> total size in bytes of txs that pay approx feerate
|
||||
bin_size: ~minimum vsize of a bucket of txs in the result (e.g. 100 kb)
|
||||
'''
|
||||
# Now compact it. For efficiency, get_fees returns a
|
||||
# compact histogram with variable bin size. The compact
|
||||
# histogram is an array of (fee_rate, vsize) values.
|
||||
# vsize_n is the cumulative virtual size of mempool
|
||||
# transactions with a fee rate in the interval
|
||||
# [rate_(n-1), rate_n)], and rate_(n-1) > rate_n.
|
||||
# Intervals are chosen to create tranches containing at
|
||||
# least a certain cumulative size (bin_size) of transactions.
|
||||
assert bin_size > 0
|
||||
compact = []
|
||||
cum_size = 0
|
||||
prev_fee_rate = None
|
||||
for fee_rate, size in sorted(histogram.items(), reverse=True):
|
||||
# if there is a big lump of txns at this specific size,
|
||||
# consider adding the previous item now (if not added already)
|
||||
if size > 2 * bin_size and prev_fee_rate is not None and cum_size > 0:
|
||||
compact.append((prev_fee_rate, cum_size))
|
||||
cum_size = 0
|
||||
bin_size *= 1.1
|
||||
# now consider adding this item
|
||||
cum_size += size
|
||||
if cum_size > bin_size:
|
||||
compact.append((fee_rate, cum_size))
|
||||
cum_size = 0
|
||||
bin_size *= 1.1
|
||||
prev_fee_rate = fee_rate
|
||||
return compact
|
||||
|
||||
def _accept_transactions(
|
||||
self,
|
||||
*,
|
||||
tx_map: Dict[bytes, MemPoolTx], # txid_rev->tx
|
||||
utxo_map: DB_UTXO_MAP, # prevout->(hashX,value_in_sats)
|
||||
touched_hashxs: Set[bytes], # set of hashXs
|
||||
touched_outpoints: Set[TxOutpoint], # set of outpoints
|
||||
topologically_sort: bool,
|
||||
) -> tuple[dict[bytes, MemPoolTx], DB_UTXO_MAP]:
|
||||
'''Accept transactions in tx_map to the mempool if all their inputs
|
||||
can be found in the existing mempool or a utxo_map from the
|
||||
DB.
|
||||
|
||||
Returns an (unprocessed tx_map, unspent utxo_map) pair.
|
||||
'''
|
||||
hashXs = self.hashXs
|
||||
txs = self.txs
|
||||
txo_to_spender = self.txo_to_spender
|
||||
|
||||
if topologically_sort:
|
||||
# Sort tx_map so that parent txs come first (in case of unconf chain).
|
||||
# This is just an optimization so that fewer txs will get "deferred", leading to better perf.
|
||||
# Dependencies outside tx_map (already in chainstate (utxo_map), or our mempool (self.txs),
|
||||
# or MISSING) are ignored.
|
||||
cand_to_parents = {
|
||||
txid: {parent_txid for (parent_txid, txout_idx) in tx.prevouts
|
||||
if parent_txid in tx_map}
|
||||
for (txid, tx) in tx_map.items()}
|
||||
cand_txids = list(TopologicalSorter(cand_to_parents).static_order())
|
||||
assert len(tx_map) == len(cand_to_parents) == len(cand_txids), \
|
||||
f"{len(tx_map)=}, {len(cand_to_parents)=}, {len(cand_txids)=}"
|
||||
del cand_to_parents
|
||||
else:
|
||||
cand_txids = tx_map.keys()
|
||||
|
||||
deferred = {} # type: dict[bytes, MemPoolTx]
|
||||
unspent = set(utxo_map)
|
||||
# Try to find all prevouts so we can accept the candidate TXs from tx_map into our mempool
|
||||
for txid_rev in cand_txids:
|
||||
tx = tx_map[txid_rev]
|
||||
in_pairs = []
|
||||
try:
|
||||
for prevout in tx.prevouts:
|
||||
# first, look for parent tx among confirmed UTXOs:
|
||||
utxo = utxo_map.get(prevout)
|
||||
if not utxo: # second, look for parent tx in mempool
|
||||
prev_hash, prev_index = prevout
|
||||
# Raises KeyError if prev_hash is not in txs
|
||||
utxo = txs[prev_hash].out_pairs[prev_index]
|
||||
in_pairs.append(utxo)
|
||||
except KeyError:
|
||||
deferred[txid_rev] = tx
|
||||
continue
|
||||
|
||||
# Spend the prevouts
|
||||
unspent.difference_update(tx.prevouts)
|
||||
|
||||
# Save the in_pairs, compute the fee and accept the TX
|
||||
tx.in_pairs = tuple(in_pairs)
|
||||
# Avoid negative fees if dealing with generation-like transactions
|
||||
# because some in_parts would be missing
|
||||
tx.fee = max(0, (sum(v for _, v in tx.in_pairs) -
|
||||
sum(v for _, v in tx.out_pairs)))
|
||||
txs[txid_rev] = tx
|
||||
|
||||
for hashX, _value in itertools.chain(tx.in_pairs, tx.out_pairs):
|
||||
touched_hashxs.add(hashX)
|
||||
hashXs[hashX].add(txid_rev)
|
||||
for prevout in tx.prevouts:
|
||||
txo_to_spender[prevout] = txid_rev
|
||||
touched_outpoints.add(prevout)
|
||||
for out_idx, out_pair in enumerate(tx.out_pairs):
|
||||
touched_outpoints.add((txid_rev, out_idx))
|
||||
|
||||
return deferred, {prevout: utxo_map[prevout] for prevout in unspent}
|
||||
|
||||
async def _refresh_hashes(self, synchronized_event):
|
||||
'''Refresh our view of the daemon's mempool.'''
|
||||
# touched_* accumulates between calls to on_mempool and each
|
||||
# call transfers ownership
|
||||
touched_hashxs = set()
|
||||
touched_outpoints = set()
|
||||
prev_mempool_height = -1
|
||||
while True:
|
||||
height = self.api.cached_height()
|
||||
is_new_block = height != prev_mempool_height
|
||||
with LogTimeTaken(self.logger, "getrawmempool RPC", enabled=is_new_block):
|
||||
txids_hum = await self.api.mempool_txids_hum()
|
||||
if height != await self.api.height(): # if height changed *again*, re-start
|
||||
continue
|
||||
with LogTimeTaken(self.logger, "convert_txids", enabled=is_new_block):
|
||||
txids_rev = await run_in_thread(lambda: {hex_str_to_hash(hh) for hh in txids_hum})
|
||||
if self.api.db_height() < height:
|
||||
# DB not yet caught up. give it some time...
|
||||
async with ignore_after(self.refresh_secs):
|
||||
await self.api.db_height_changed()
|
||||
try:
|
||||
async with self.lock:
|
||||
await self._process_mempool(
|
||||
all_txids_rev=txids_rev,
|
||||
touched_hashxs=touched_hashxs,
|
||||
touched_outpoints=touched_outpoints,
|
||||
mempool_height=height,
|
||||
debug_logs=is_new_block,
|
||||
)
|
||||
except DBSyncError:
|
||||
# The UTXO DB is not at the same height as the
|
||||
# mempool; wait and try again
|
||||
self.logger.debug('waiting for DB to sync')
|
||||
else:
|
||||
synchronized_event.set()
|
||||
synchronized_event.clear()
|
||||
await self.api.on_mempool(
|
||||
touched_hashxs=touched_hashxs,
|
||||
touched_outpoints=touched_outpoints,
|
||||
height=height,
|
||||
)
|
||||
prev_mempool_height = height
|
||||
touched_hashxs = set()
|
||||
touched_outpoints = set()
|
||||
# poll bitcoind's whole mempool every few seconds; or instantly after the DB height changes
|
||||
async with ignore_after(self.refresh_secs):
|
||||
async with OldTaskGroup(wait=any) as group:
|
||||
await group.spawn(self.api.db_height_changed())
|
||||
await group.spawn(self.api.daemon_height_changed())
|
||||
|
||||
async def _process_mempool(
|
||||
self,
|
||||
*,
|
||||
all_txids_rev: Set[bytes], # set of txids_rev # complete view of daemon's mempool
|
||||
touched_hashxs: Set[bytes], # set of hashXs
|
||||
touched_outpoints: Set[TxOutpoint], # set of outpoints
|
||||
mempool_height: int,
|
||||
debug_logs: bool = False,
|
||||
) -> None:
|
||||
# Re-sync with the new set of hashes
|
||||
txs = self.txs
|
||||
hashXs = self.hashXs
|
||||
txo_to_spender = self.txo_to_spender
|
||||
|
||||
if mempool_height != self.api.db_height(): # FIXME should compare blockhash
|
||||
raise DBSyncError
|
||||
|
||||
# 1. Handle txs that have disappeared (evicted, just got mined, etc)
|
||||
# TODO split disappeared txs workload into a threadpool, chunks of ~200 txs
|
||||
def handle_disappeared_txs() -> None:
|
||||
nonlocal touched_hashxs
|
||||
for txid_rev in disappeared_hashes:
|
||||
tx = txs.pop(txid_rev)
|
||||
# hashXs
|
||||
tx_hashXs = {hashX for hashX, value in tx.in_pairs}
|
||||
tx_hashXs.update(hashX for hashX, value in tx.out_pairs)
|
||||
for hashX in tx_hashXs:
|
||||
hashXs[hashX].remove(txid_rev)
|
||||
if not hashXs[hashX]:
|
||||
del hashXs[hashX]
|
||||
touched_hashxs |= tx_hashXs
|
||||
# outpoints
|
||||
for prevout in tx.prevouts:
|
||||
del txo_to_spender[prevout]
|
||||
touched_outpoints.add(prevout)
|
||||
for out_idx, out_pair in enumerate(tx.out_pairs):
|
||||
touched_outpoints.add((txid_rev, out_idx))
|
||||
|
||||
with LogTimeTaken(self.logger, "_process_mempool() removing txs", enabled=debug_logs):
|
||||
disappeared_hashes = await run_in_thread(lambda: set(txs) - all_txids_rev)
|
||||
handle_disappeared_txs()
|
||||
|
||||
# 2. Process new transactions
|
||||
t0 = time.monotonic()
|
||||
new_hashes = await run_in_thread(lambda: list(all_txids_rev.difference(txs)))
|
||||
if new_hashes:
|
||||
# 2.1. fetch raw txs from bitcoin daemon
|
||||
group = OldTaskGroup()
|
||||
for hashes in chunks(new_hashes, 200):
|
||||
coro = self._fetch_raw_txs_and_utxos(
|
||||
new_txids_rev=hashes,
|
||||
all_txids_rev=all_txids_rev,
|
||||
)
|
||||
await group.spawn(coro)
|
||||
if mempool_height != self.api.db_height():
|
||||
raise DBSyncError
|
||||
|
||||
tx_map = {} # type: dict[bytes, MemPoolTx]
|
||||
utxo_map = {} # type: DB_UTXO_MAP
|
||||
async for task in group:
|
||||
partial_tx_map, partial_utxo_map = task.result()
|
||||
tx_map.update(partial_tx_map)
|
||||
utxo_map.update(partial_utxo_map)
|
||||
if debug_logs:
|
||||
self.logger.debug(f'_process_mempool() fetching txs took {time.monotonic() - t0:.3f}s. {len(new_hashes)=}')
|
||||
|
||||
# 2.2. accept txs into our mempool
|
||||
def accept_txs_loop() -> None:
|
||||
# Accept candidate txs from tx_map into our mempool.
|
||||
# We only accept candidates for which we can find a UTXO for each tx input:
|
||||
# - some UTXOs we find in the DB=utxo_map (chainstate),
|
||||
# - some we find in self.txs (our memool),
|
||||
# - some we might only find in tx_map (among the candidates: consider long chain of unconfirmed txs)
|
||||
# - some we might not find anywhere: if DB is corrupted or bitcoind gave inconsistent mempool, or other race
|
||||
# In each iteration, we loop over tx_map and move accepted transactions from it to self.txs.
|
||||
# In the worst degenerate case, this could be O(n^2). To avoid that, we topologically sort tx_map.
|
||||
nonlocal tx_map, utxo_map
|
||||
prior_txmap_size = 0
|
||||
while tx_map and len(tx_map) != prior_txmap_size:
|
||||
prior_txmap_size = len(tx_map)
|
||||
tx_map, utxo_map = self._accept_transactions(
|
||||
tx_map=tx_map,
|
||||
utxo_map=utxo_map,
|
||||
touched_hashxs=touched_hashxs,
|
||||
touched_outpoints=touched_outpoints,
|
||||
topologically_sort=True,
|
||||
)
|
||||
if tx_map:
|
||||
self.logger.error(f'{len(tx_map)} txs dropped')
|
||||
|
||||
with LogTimeTaken(self.logger, f"_process_mempool() accepting txs ({len(new_hashes)=})", enabled=debug_logs):
|
||||
accept_txs_loop()
|
||||
|
||||
async def _fetch_raw_txs_and_utxos(
|
||||
self,
|
||||
*,
|
||||
new_txids_rev: set[bytes], # (some) new candidate txs for our mempool
|
||||
all_txids_rev: set[bytes], # complete view of daemon's mempool
|
||||
) -> tuple[dict[bytes, MemPoolTx], DB_UTXO_MAP]:
|
||||
'''Fetch a list of mempool transactions, and lookup corresponding UTXOs in the DB.'''
|
||||
txids_hum_iter = (hash_to_hex_str(hash) for hash in new_txids_rev)
|
||||
raw_txs = await self.api.raw_transactions(txids_hum_iter)
|
||||
|
||||
def deserialize_txs() -> Dict[bytes, MemPoolTx]:
|
||||
"""This function is pure"""
|
||||
to_hashX = self.coin.hashX_from_script
|
||||
deserializer = self.coin.DESERIALIZER
|
||||
|
||||
txs = {} # type: Dict[bytes, MemPoolTx]
|
||||
for txid_rev, raw_tx in zip(new_txids_rev, raw_txs):
|
||||
# The daemon may have evicted the tx from its
|
||||
# mempool or it may have gotten in a block
|
||||
if not raw_tx:
|
||||
continue
|
||||
try:
|
||||
tx, tx_size = deserializer(raw_tx).read_tx_and_vsize()
|
||||
except SkipTxDeserialize as ex:
|
||||
self.logger.debug(f'skipping tx {hash_to_hex_str(txid_rev)}: {ex}')
|
||||
continue
|
||||
# Convert the inputs and outputs into (hashX, value) pairs
|
||||
# Drop generation-like inputs from MemPoolTx.prevouts
|
||||
txin_pairs = tuple((txin.prev_txid_rev, txin.prev_idx)
|
||||
for txin in tx.inputs
|
||||
if not txin.is_generation())
|
||||
txout_pairs = tuple((to_hashX(txout.pk_script), txout.value)
|
||||
for txout in tx.outputs)
|
||||
txs[txid_rev] = MemPoolTx(
|
||||
prevouts=txin_pairs,
|
||||
in_pairs=None,
|
||||
out_pairs=txout_pairs,
|
||||
fee=0,
|
||||
size=tx_size,
|
||||
)
|
||||
return txs
|
||||
|
||||
# Thread this potentially slow operation so as not to block
|
||||
tx_map = await run_in_thread(deserialize_txs) # type: Dict[bytes, MemPoolTx]
|
||||
|
||||
# Determine all prevouts not in the mempool, and fetch the
|
||||
# UTXO information from the database. Failed prevout lookups
|
||||
# return None - concurrent database updates happen - which is
|
||||
# relied upon by _accept_transactions. Ignore prevouts that are
|
||||
# generation-like.
|
||||
prevouts = tuple(prevout for tx in tx_map.values()
|
||||
for prevout in tx.prevouts
|
||||
if prevout[0] not in all_txids_rev)
|
||||
utxos = await self.api.lookup_utxos(prevouts)
|
||||
utxo_map = {prevout: utxo for prevout, utxo in zip(prevouts, utxos)}
|
||||
|
||||
return tx_map, utxo_map
|
||||
|
||||
#
|
||||
# External interface
|
||||
#
|
||||
|
||||
async def keep_synchronized(self, synchronized_event: asyncio.Event) -> None:
|
||||
'''Keep the mempool synchronized with the daemon.'''
|
||||
async with OldTaskGroup() as group:
|
||||
await group.spawn(self._refresh_hashes(synchronized_event))
|
||||
await group.spawn(self._refresh_histogram(synchronized_event))
|
||||
await group.spawn(self._logging(synchronized_event))
|
||||
|
||||
async def balance_delta(self, hashX: bytes) -> int:
|
||||
'''Return the unconfirmed amount in the mempool for hashX.
|
||||
|
||||
Can be positive or negative.
|
||||
'''
|
||||
value = 0
|
||||
if hashX in self.hashXs:
|
||||
for txid_rev in self.hashXs[hashX]:
|
||||
tx = self.txs[txid_rev]
|
||||
value -= sum(v for h168, v in tx.in_pairs if h168 == hashX)
|
||||
value += sum(v for h168, v in tx.out_pairs if h168 == hashX)
|
||||
return value
|
||||
|
||||
async def compact_fee_histogram(self) -> Sequence[tuple[float, int]]:
|
||||
'''Return a compact fee histogram of the current mempool.'''
|
||||
return self.cached_compact_histogram
|
||||
|
||||
async def potential_spends(self, hashX: bytes) -> set[TxOutpoint]:
|
||||
'''Return a set of (prev_hash, prev_idx) pairs from mempool
|
||||
transactions that touch hashX.
|
||||
|
||||
None, some or all of these may be spends of the hashX, but all
|
||||
actual spends of it (in the DB or mempool) will be included.
|
||||
'''
|
||||
result = set()
|
||||
for txid_rev in self.hashXs.get(hashX, ()):
|
||||
tx = self.txs[txid_rev]
|
||||
result.update(tx.prevouts)
|
||||
return result
|
||||
|
||||
async def transaction_summaries(self, hashX: bytes) -> Sequence[MemPoolTxSummary]:
|
||||
'''Return a list of MemPoolTxSummary objects for the hashX,
|
||||
sorted as expected by protocol methods.
|
||||
'''
|
||||
result = [] # type: list[MemPoolTxSummary]
|
||||
for txid_rev in self.hashXs.get(hashX, ()):
|
||||
tx = self.txs[txid_rev]
|
||||
has_ui = any(hash in self.txs for hash, idx in tx.prevouts)
|
||||
result.append(MemPoolTxSummary(txid_rev, tx.fee, has_ui))
|
||||
result.sort(key=lambda x: (x.has_unconfirmed_inputs, x.txid_rev[::-1]))
|
||||
return result
|
||||
|
||||
async def unordered_UTXOs(self, hashX: bytes) -> Sequence[UTXO]:
|
||||
'''Return an unordered list of UTXO named tuples from mempool
|
||||
transactions that pay to hashX.
|
||||
|
||||
This does not consider if any other mempool transactions spend
|
||||
the outputs.
|
||||
'''
|
||||
utxos = []
|
||||
for txid_rev in self.hashXs.get(hashX, ()):
|
||||
tx = self.txs.get(txid_rev)
|
||||
for pos, (hX, value) in enumerate(tx.out_pairs):
|
||||
if hX == hashX:
|
||||
utxos.append(UTXO(-1, pos, txid_rev, 0, value))
|
||||
return utxos
|
||||
|
||||
async def spender_for_txo(self, prev_txid_rev: bytes, txout_idx: int) -> 'TXOSpendStatus':
|
||||
'''For an outpoint, returns its spend-status.
|
||||
This only considers the mempool, not the DB/blockchain, so e.g. mined
|
||||
txs are not distinguished from txs that never existed.
|
||||
'''
|
||||
# look up funding tx
|
||||
prev_tx = self.txs.get(prev_txid_rev, None)
|
||||
if prev_tx is None:
|
||||
# funding tx already mined or never existed
|
||||
funder_height = None
|
||||
else:
|
||||
if len(prev_tx.out_pairs) <= txout_idx:
|
||||
# output idx out of bounds...?
|
||||
return TXOSpendStatus(funder_height=None)
|
||||
prev_has_ui = any(hash in self.txs for hash, idx in prev_tx.prevouts)
|
||||
funder_height = -prev_has_ui
|
||||
prevout = (prev_txid_rev, txout_idx)
|
||||
# look up spending tx
|
||||
spender_txid_rev = self.txo_to_spender.get(prevout, None)
|
||||
if spender_txid_rev is None:
|
||||
return TXOSpendStatus(funder_height=funder_height)
|
||||
spender_tx = self.txs.get(spender_txid_rev, None)
|
||||
if spender_tx is None:
|
||||
self.logger.warning(f"spender_tx {hash_to_hex_str(spender_txid_rev)} not in"
|
||||
f"mempool, but txo_to_spender referenced it as spender "
|
||||
f"of {hash_to_hex_str(prev_txid_rev)}:{txout_idx} ?!")
|
||||
return TXOSpendStatus(funder_height=funder_height)
|
||||
spender_has_ui = any(txid_rev in self.txs for txid_rev, idx in spender_tx.prevouts)
|
||||
spender_height = -spender_has_ui
|
||||
return TXOSpendStatus(
|
||||
funder_height=funder_height,
|
||||
spender_txid_rev=spender_txid_rev,
|
||||
spender_height=spender_height,
|
||||
)
|
||||
|
||||
async def get_recently_added_txs(self, *, count: int) -> Sequence[RecentMemPoolTx]:
|
||||
# note: inefficient for large "count"s
|
||||
it = reversed(self.txs.items())
|
||||
count = min(count, len(self.txs))
|
||||
mempool_txs = [next(it) for _ in range(count)]
|
||||
return [
|
||||
RecentMemPoolTx(txid_rev=hash, fee=mtx.fee, vsize=mtx.size)
|
||||
for hash, mtx in mempool_txs]
|
||||
@@ -0,0 +1,634 @@
|
||||
# Copyright (c) 2017-2018, Neil Booth
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
'''Peer management.'''
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from typing import TYPE_CHECKING, Type, Any, Optional
|
||||
from functools import partial
|
||||
|
||||
import aiohttp
|
||||
from aiorpcx import (Event, Notification, RPCSession, SOCKSError,
|
||||
SOCKSProxy, TaskTimeout, connect_rs,
|
||||
handler_invocation, ignore_after, sleep)
|
||||
from aiorpcx.jsonrpc import CodeMessageError
|
||||
|
||||
from electrumx.lib.peer import Peer
|
||||
from electrumx.lib.util import class_logger, json_deserialize, OldTaskGroup
|
||||
from electrumx.server.session import RPCSessionWithTaskGroup
|
||||
from electrumx.server.transport import PaddedRSTransport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from electrumx.server.env import Env
|
||||
from electrumx.server.db import DB
|
||||
|
||||
|
||||
PEER_GOOD, PEER_STALE, PEER_NEVER, PEER_BAD = range(4)
|
||||
STATUS_DESCS = ('good', 'stale', 'never', 'bad')
|
||||
STALE_SECS = 3 * 3600
|
||||
WAKEUP_SECS = 300
|
||||
PEER_ADD_PAUSE = 600
|
||||
|
||||
|
||||
class BadPeerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def assert_good(message, result, instance):
|
||||
if not isinstance(result, instance):
|
||||
raise BadPeerError(f'{message} returned bad result type '
|
||||
f'{type(result).__name__}')
|
||||
|
||||
|
||||
class PeerSession(RPCSessionWithTaskGroup):
|
||||
'''An outgoing session to a peer.'''
|
||||
|
||||
async def handle_request(self, request):
|
||||
# We subscribe so might be unlucky enough to get a notification...
|
||||
if (isinstance(request, Notification) and
|
||||
request.method == 'blockchain.headers.subscribe'):
|
||||
pass
|
||||
else:
|
||||
await handler_invocation(None, request) # Raises
|
||||
|
||||
|
||||
class PeerManager:
|
||||
'''Looks after the DB of peer network servers.
|
||||
|
||||
Attempts to maintain a connection with up to 8 peers.
|
||||
Issues a 'peers.subscribe' RPC to them and tells them our data.
|
||||
'''
|
||||
def __init__(self, env: 'Env', db: 'DB'):
|
||||
self.logger = class_logger(__name__, self.__class__.__name__)
|
||||
# Initialise the Peer class
|
||||
Peer.DEFAULT_PORTS = env.coin.PEER_DEFAULT_PORTS
|
||||
self.env = env
|
||||
self.db = db
|
||||
|
||||
# Our reported clearnet and Tor Peers, if any
|
||||
sclass = env.coin.SESSIONCLS
|
||||
self.myselves = [Peer(str(service.host), sclass.server_features(env), 'env')
|
||||
for service in env.report_services]
|
||||
self.server_version_args = sclass.server_version_args()
|
||||
# Peers have one entry per hostname. Once connected, the
|
||||
# ip_addr property is either None, an onion peer, or the
|
||||
# IP address that was connected to. Adding a peer will evict
|
||||
# any other peers with the same host name or IP address.
|
||||
self.peers = set()
|
||||
self.permit_onion_peer_time = time.time()
|
||||
self.proxy = None
|
||||
self.group = OldTaskGroup()
|
||||
self.recent_peer_adds = {}
|
||||
# refreshed
|
||||
self.blacklist = set()
|
||||
|
||||
def _my_clearnet_peer(self):
|
||||
'''Returns the clearnet peer representing this server, if any.'''
|
||||
clearnet = [peer for peer in self.myselves if not peer.is_tor]
|
||||
return clearnet[0] if clearnet else None
|
||||
|
||||
def _set_peer_statuses(self):
|
||||
'''Set peer statuses.'''
|
||||
cutoff = time.time() - STALE_SECS
|
||||
for peer in self.peers:
|
||||
if peer.bad:
|
||||
peer.status = PEER_BAD
|
||||
elif peer.last_good > cutoff:
|
||||
peer.status = PEER_GOOD
|
||||
elif peer.last_good:
|
||||
peer.status = PEER_STALE
|
||||
else:
|
||||
peer.status = PEER_NEVER
|
||||
|
||||
def _features_to_register(self, peer, remote_peers):
|
||||
'''If we should register ourselves to the remote peer, which has
|
||||
reported the given list of known peers, return the clearnet
|
||||
identity features to register, otherwise None.
|
||||
'''
|
||||
# Announce ourself if not present. Don't if disabled, we
|
||||
# are a non-public IP address, or to ourselves.
|
||||
if not self.env.peer_announce or peer in self.myselves:
|
||||
return None
|
||||
my = self._my_clearnet_peer()
|
||||
if not my or not my.is_public:
|
||||
return None
|
||||
# Register if no matches, or ports have changed
|
||||
for peer in my.matches(remote_peers):
|
||||
if peer.tcp_port == my.tcp_port and peer.ssl_port == my.ssl_port:
|
||||
return None
|
||||
return my.features
|
||||
|
||||
def _permit_new_onion_peer(self, now):
|
||||
'''Accept a new onion peer only once per random time interval.'''
|
||||
if now < self.permit_onion_peer_time:
|
||||
return False
|
||||
self.permit_onion_peer_time = now + random.randrange(0, 1200)
|
||||
return True
|
||||
|
||||
async def _import_peers(self):
|
||||
'''Import hard-coded peers from a file or the coin defaults.'''
|
||||
imported_peers = self.myselves.copy()
|
||||
# Add the hard-coded ones unless only reporting ourself
|
||||
if self.env.peer_discovery != self.env.PD_SELF:
|
||||
imported_peers.extend(Peer.from_real_name(real_name, 'coins.py')
|
||||
for real_name in self.env.coin.PEERS)
|
||||
await self._note_peers(imported_peers, limit=None)
|
||||
|
||||
async def _refresh_blacklist(self):
|
||||
url = self.env.blacklist_url
|
||||
if not url:
|
||||
return
|
||||
|
||||
async def read_blacklist():
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
text = await response.text()
|
||||
return {entry.lower() for entry in json_deserialize(text)}
|
||||
|
||||
while True:
|
||||
try:
|
||||
self.blacklist = await read_blacklist()
|
||||
except Exception as e:
|
||||
self.logger.error(f'could not retrieve blacklist from {url}: {e}')
|
||||
else:
|
||||
self.logger.info(f'blacklist from {url} has {len(self.blacklist)} entries')
|
||||
# Got new blacklist. Now check our current peers against it
|
||||
for peer in self.peers:
|
||||
if self._is_blacklisted(peer):
|
||||
peer.retry_event.set()
|
||||
await sleep(600)
|
||||
|
||||
def _is_blacklisted(self, peer):
|
||||
host = peer.host.lower()
|
||||
second_level_domain = '*.' + '.'.join(host.split('.')[-2:])
|
||||
return any(item in self.blacklist
|
||||
for item in (host, second_level_domain, peer.ip_addr))
|
||||
|
||||
def _get_recent_good_peers(self):
|
||||
cutoff = time.time() - STALE_SECS
|
||||
recent = [peer for peer in self.peers
|
||||
if peer.last_good > cutoff and
|
||||
not peer.bad and peer.is_public]
|
||||
recent = [peer for peer in recent if not self._is_blacklisted(peer)]
|
||||
return recent
|
||||
|
||||
async def _detect_proxy(self):
|
||||
'''Detect a proxy if we don't have one and some time has passed since
|
||||
the last attempt.
|
||||
|
||||
If found self.proxy is set to a SOCKSProxy instance, otherwise None.
|
||||
'''
|
||||
host = self.env.tor_proxy_host
|
||||
if self.env.tor_proxy_port is None:
|
||||
ports = [9050, 9150, 1080]
|
||||
else:
|
||||
ports = [self.env.tor_proxy_port]
|
||||
while True:
|
||||
self.logger.info(f'trying to detect proxy on "{host}" '
|
||||
f'ports {ports}')
|
||||
proxy = await SOCKSProxy.auto_detect_at_host(host, ports, None)
|
||||
if proxy:
|
||||
self.proxy = proxy
|
||||
self.logger.info(f'detected {proxy}')
|
||||
return
|
||||
self.logger.info('no proxy detected, will try later')
|
||||
await sleep(900)
|
||||
|
||||
async def _note_peers(self, peers, limit=2, check_ports=False, source=None):
|
||||
'''Add a limited number of peers that are not already present.'''
|
||||
new_peers = []
|
||||
match_set = self.peers.copy()
|
||||
for peer in peers:
|
||||
if not peer.is_public or (peer.is_tor and not self.proxy):
|
||||
continue
|
||||
|
||||
matches = peer.matches(match_set)
|
||||
if matches:
|
||||
if check_ports:
|
||||
for match in matches:
|
||||
if match.check_ports(peer):
|
||||
self.logger.info(f'ports changed for {peer}')
|
||||
# Retry connecting to the peer. First we will try the existing
|
||||
# ports and then try the new ports. Note that check_ports above
|
||||
# had a side_effect to temporarily store the new ports.
|
||||
# If we manage to connect, we will call 'server.features',
|
||||
# and the ports for this peer will be updated to the return values.
|
||||
match.retry_event.set()
|
||||
else:
|
||||
match_set.add(peer)
|
||||
new_peers.append(peer)
|
||||
|
||||
if new_peers:
|
||||
source = source or new_peers[0].source
|
||||
if limit:
|
||||
random.shuffle(new_peers)
|
||||
use_peers = new_peers[:limit]
|
||||
else:
|
||||
use_peers = new_peers
|
||||
for peer in use_peers:
|
||||
self.logger.info(f'accepted new peer {peer} from {source}')
|
||||
peer.retry_event = Event()
|
||||
self.peers.add(peer)
|
||||
await self.group.spawn(self._monitor_peer(peer))
|
||||
|
||||
return True
|
||||
|
||||
async def _monitor_peer(self, peer):
|
||||
# Stop monitoring if we were dropped (a duplicate peer)
|
||||
while peer in self.peers:
|
||||
if await self._should_drop_peer(peer):
|
||||
self.peers.discard(peer)
|
||||
break
|
||||
# Figure out how long to sleep before retrying. Retry a
|
||||
# good connection when it is about to turn stale, otherwise
|
||||
# exponentially back off retries.
|
||||
if peer.try_count == 0:
|
||||
pause = STALE_SECS - WAKEUP_SECS * 2
|
||||
else:
|
||||
pause = WAKEUP_SECS * 2 ** peer.try_count
|
||||
async with ignore_after(pause):
|
||||
await peer.retry_event.wait()
|
||||
peer.retry_event.clear()
|
||||
|
||||
async def _should_drop_peer(self, peer):
|
||||
peer.try_count += 1
|
||||
is_good = False
|
||||
for kind, port, family in peer.connection_tuples():
|
||||
peer.last_try = time.time()
|
||||
|
||||
kwargs = {'family': family}
|
||||
if kind == 'SSL':
|
||||
sslc = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
sslc.check_hostname = False
|
||||
sslc.verify_mode = ssl.CERT_NONE
|
||||
kwargs['ssl'] = sslc
|
||||
|
||||
if self.env.force_proxy or peer.is_tor:
|
||||
if not self.proxy:
|
||||
return
|
||||
kwargs['proxy'] = self.proxy
|
||||
kwargs['resolve'] = not peer.is_tor
|
||||
else:
|
||||
# Use our listening Host/IP for outgoing non-proxy
|
||||
# connections so our peers see the correct source.
|
||||
local_hosts = {service.host for service in self.env.services
|
||||
if isinstance(service.host, (IPv4Address, IPv6Address))
|
||||
and service.protocol != 'rpc'}
|
||||
if local_hosts:
|
||||
kwargs['local_addr'] = (str(local_hosts.pop()), None)
|
||||
|
||||
session_factory = partial(
|
||||
PeerSession,
|
||||
manager_taskgroup=self.group,
|
||||
)
|
||||
peer_text = f'[{peer}:{port} {kind}]'
|
||||
try:
|
||||
async with connect_rs(
|
||||
peer.host, port,
|
||||
session_factory=session_factory,
|
||||
transport=PaddedRSTransport,
|
||||
**kwargs,
|
||||
) as session:
|
||||
session.sent_request_timeout = 120 if peer.is_tor else 30
|
||||
await self._verify_peer(session, peer)
|
||||
is_good = True
|
||||
break
|
||||
except BadPeerError as e:
|
||||
self.logger.error(f'{peer_text} marking bad: ({e})')
|
||||
peer.mark_bad()
|
||||
break
|
||||
except CodeMessageError as e:
|
||||
self.logger.error(f'{peer_text} RPC error: {e.message} '
|
||||
f'({e.code})')
|
||||
except (OSError, SOCKSError, ConnectionError, TaskTimeout) as e:
|
||||
self.logger.info(f'{peer_text} {e}')
|
||||
|
||||
if is_good:
|
||||
# Monotonic time would be better, but last_good and last_try are
|
||||
# exported to admin RPC client.
|
||||
now = time.time()
|
||||
elapsed = now - peer.last_try
|
||||
self.logger.info(f'{peer_text} verified in {elapsed:.1f}s')
|
||||
peer.try_count = 0
|
||||
peer.last_good = now
|
||||
peer.source = 'peer'
|
||||
# At most 2 matches if we're a host name, potentially
|
||||
# several if we're an IP address (several instances
|
||||
# can share a NAT).
|
||||
matches = peer.matches(self.peers)
|
||||
for match in matches:
|
||||
if match.ip_address:
|
||||
if len(matches) > 1:
|
||||
self.peers.remove(match)
|
||||
# Force the peer's monitoring task to exit
|
||||
match.retry_event.set()
|
||||
elif peer.host in match.features['hosts']:
|
||||
match.update_features_from_peer(peer)
|
||||
# Trim this data structure
|
||||
self.recent_peer_adds = {k: v for k, v in self.recent_peer_adds.items()
|
||||
if v + PEER_ADD_PAUSE < now}
|
||||
else:
|
||||
# Forget the peer if long-term unreachable
|
||||
if peer.last_good and not peer.bad:
|
||||
try_limit = 10
|
||||
else:
|
||||
try_limit = 3
|
||||
if peer.try_count >= try_limit:
|
||||
desc = 'bad' if peer.bad else 'unreachable'
|
||||
self.logger.info(f'forgetting {desc} peer: {peer}')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _verify_peer(self, session, peer):
|
||||
# store IP address for peer
|
||||
if not peer.is_tor:
|
||||
address = session.remote_address()
|
||||
if address and isinstance(address.host, (IPv4Address, IPv6Address)):
|
||||
peer.ip_addr = str(address.host)
|
||||
|
||||
if self._is_blacklisted(peer):
|
||||
raise BadPeerError('blacklisted')
|
||||
|
||||
# Bucket good recent peers; forbid many servers from similar IPs
|
||||
# FIXME there's a race here, when verifying multiple peers
|
||||
# that belong to the same bucket ~simultaneously
|
||||
recent_peers = self._get_recent_good_peers()
|
||||
if peer in recent_peers:
|
||||
recent_peers.remove(peer)
|
||||
onion_peers = []
|
||||
buckets = defaultdict(list)
|
||||
for other_peer in recent_peers:
|
||||
if other_peer.is_tor:
|
||||
onion_peers.append(other_peer)
|
||||
else:
|
||||
buckets[other_peer.bucket_for_internal_purposes()].append(other_peer)
|
||||
if peer.is_tor:
|
||||
# keep number of onion peers below half of all peers,
|
||||
# but up to 100 is OK regardless
|
||||
if len(onion_peers) > len(recent_peers) // 2 >= 100:
|
||||
raise BadPeerError('too many onion peers already')
|
||||
else:
|
||||
bucket = peer.bucket_for_internal_purposes()
|
||||
if buckets[bucket]:
|
||||
raise BadPeerError(f'too many peers already in bucket {bucket}')
|
||||
|
||||
# server.version goes first
|
||||
message = 'server.version'
|
||||
try:
|
||||
result = await session.send_request(message, self.server_version_args)
|
||||
except asyncio.CancelledError:
|
||||
raise BadPeerError('terminated before server.version response')
|
||||
|
||||
assert_good(message, result, list)
|
||||
|
||||
# Protocol version 1.1 returns a pair with the version first
|
||||
if len(result) != 2 or not all(isinstance(x, str) for x in result):
|
||||
raise BadPeerError(f'bad server.version result: {result}')
|
||||
server_version, _protocol_version = result
|
||||
peer.server_version = server_version
|
||||
peer.features['server_version'] = server_version
|
||||
|
||||
async with OldTaskGroup() as g:
|
||||
await g.spawn(self._send_headers_subscribe(session))
|
||||
await g.spawn(self._send_server_features(session, peer))
|
||||
peers_task = await g.spawn(self._send_peers_subscribe
|
||||
(session, peer))
|
||||
|
||||
# Process reported peers if remote peer is good
|
||||
peers = peers_task.result()
|
||||
await self._note_peers(peers)
|
||||
|
||||
features = self._features_to_register(peer, peers)
|
||||
if features:
|
||||
self.logger.info(f'registering ourself with {peer}')
|
||||
# We only care to wait for the response
|
||||
try:
|
||||
await session.send_request('server.add_peer', [features])
|
||||
except asyncio.CancelledError:
|
||||
raise BadPeerError('terminated before server.add_peer response')
|
||||
|
||||
async def _send_headers_subscribe(self, session):
|
||||
message = 'blockchain.headers.subscribe'
|
||||
result = await session.send_request(message)
|
||||
assert_good(message, result, dict)
|
||||
|
||||
# Compare heights.
|
||||
MAX_TIP_HEIGHT_DIFF = 5
|
||||
our_height = self.db.db_height
|
||||
their_height = result.get('height')
|
||||
if not isinstance(their_height, int):
|
||||
raise BadPeerError(f'invalid height {their_height}')
|
||||
if abs(our_height - their_height) > MAX_TIP_HEIGHT_DIFF:
|
||||
raise BadPeerError(f'bad height {their_height:,d} '
|
||||
f'(ours: {our_height:,d})')
|
||||
|
||||
# Check prior header too in case of hard fork.
|
||||
# Allow short chain-splits. On Bitcoin testnet4, <6 block splits and reorgs are *very* common.
|
||||
MAX_CHAINSPLIT_LEN = 10
|
||||
check_height = max(0, min(our_height, their_height) - MAX_CHAINSPLIT_LEN)
|
||||
raw_header = await self.db.raw_header(check_height)
|
||||
ours = raw_header.hex()
|
||||
message = 'blockchain.block.header'
|
||||
theirs = await session.send_request(message, [check_height])
|
||||
assert_good(message, theirs, str)
|
||||
if ours != theirs:
|
||||
raise BadPeerError(f'our header {ours} and '
|
||||
f'theirs {theirs} differ')
|
||||
|
||||
async def _send_server_features(self, session, peer):
|
||||
message = 'server.features'
|
||||
features = await session.send_request(message)
|
||||
assert_good(message, features, dict)
|
||||
features_hosts = features.get('hosts', {})
|
||||
assert_good(message, features_hosts, dict)
|
||||
hosts = [host.lower() for host in features_hosts]
|
||||
if self.env.coin.GENESIS_HASH != features.get('genesis_hash'):
|
||||
raise BadPeerError('incorrect genesis hash')
|
||||
if peer.host.lower() in hosts:
|
||||
peer.update_features(features)
|
||||
else:
|
||||
raise BadPeerError(f'not listed in own hosts list {hosts}')
|
||||
|
||||
async def _send_peers_subscribe(self, session, peer):
|
||||
message = 'server.peers.subscribe'
|
||||
raw_peers = await session.send_request(message)
|
||||
assert_good(message, raw_peers, list)
|
||||
|
||||
# Check the peers list we got from a remote peer.
|
||||
# Each is expected to be of the form:
|
||||
# [ip_addr, hostname, ['v1.0', 't51001', 's51002']]
|
||||
# Call add_peer if the remote doesn't appear to know about us.
|
||||
try:
|
||||
real_names = [' '.join([u[1]] + u[2]) for u in raw_peers]
|
||||
return [Peer.from_real_name(real_name, str(peer))
|
||||
for real_name in real_names]
|
||||
except Exception:
|
||||
raise BadPeerError('bad server.peers.subscribe response')
|
||||
|
||||
#
|
||||
# External interface
|
||||
#
|
||||
async def discover_peers(self):
|
||||
'''Perform peer maintenance. This includes
|
||||
|
||||
1) Forgetting unreachable peers.
|
||||
2) Verifying connectivity of new peers.
|
||||
3) Retrying old peers at regular intervals.
|
||||
'''
|
||||
self.logger.info(f'peer discovery: {self.env.peer_discovery}')
|
||||
if self.env.peer_discovery != self.env.PD_ON:
|
||||
self.logger.info('peer discovery is disabled')
|
||||
return
|
||||
|
||||
self.logger.info(f'announce ourself: {self.env.peer_announce}')
|
||||
self.logger.info(f'my clearnet self: {self._my_clearnet_peer()}')
|
||||
self.logger.info(f'force use of proxy: {self.env.force_proxy}')
|
||||
self.logger.info(f'beginning peer discovery...')
|
||||
try:
|
||||
async with self.group as group:
|
||||
await group.spawn(self._refresh_blacklist())
|
||||
await group.spawn(self._detect_proxy())
|
||||
await group.spawn(self._import_peers())
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"PeerManager.group died! Please open a bug report about this, "
|
||||
"and include at least this traceback and maybe a bit more of "
|
||||
"the log (if it looks relevant).")
|
||||
while True:
|
||||
await sleep(3600)
|
||||
self.logger.warning(
|
||||
"Peer discovery is not working as PeerManager.group died. "
|
||||
"This is not normal. You should inspect the log.")
|
||||
|
||||
def info(self):
|
||||
'''The number of peers.'''
|
||||
self._set_peer_statuses()
|
||||
counter = Counter(peer.status for peer in self.peers)
|
||||
return {
|
||||
'bad': counter[PEER_BAD],
|
||||
'good': counter[PEER_GOOD],
|
||||
'never': counter[PEER_NEVER],
|
||||
'stale': counter[PEER_STALE],
|
||||
'total': len(self.peers),
|
||||
}
|
||||
|
||||
async def add_localRPC_peer(self, real_name):
|
||||
'''Add a peer passed by the admin over LocalRPC.'''
|
||||
await self._note_peers([Peer.from_real_name(real_name, 'RPC')], check_ports=True)
|
||||
|
||||
async def on_add_peer(self, features: dict[str, Any] | Any, source_addr: Optional[str]):
|
||||
'''Add a peer (but only if the peer resolves to the source).'''
|
||||
if self.env.peer_discovery != self.env.PD_ON:
|
||||
return False
|
||||
if not source_addr:
|
||||
self.logger.info('ignored add_peer request: no source info')
|
||||
return False
|
||||
source = str(source_addr.host)
|
||||
peers = Peer.peers_from_features(features, source)
|
||||
if not peers:
|
||||
self.logger.info('ignored add_peer request: no peers given')
|
||||
return False
|
||||
|
||||
# Just look at the first peer, require it
|
||||
peer = peers[0]
|
||||
host = peer.host
|
||||
now = time.time()
|
||||
|
||||
# Rate limit peer adds by domain to one every 10 minutes
|
||||
if peer.ip_address is not None:
|
||||
bucket = 'ip_addr'
|
||||
else:
|
||||
bucket = '.'.join(host.lower().split('.')[-2:])
|
||||
last = self.recent_peer_adds.get(bucket, 0)
|
||||
self.recent_peer_adds[bucket] = now
|
||||
if last + PEER_ADD_PAUSE >= now:
|
||||
return False
|
||||
|
||||
if peer.is_tor:
|
||||
permit = self._permit_new_onion_peer(now)
|
||||
reason = 'rate limiting'
|
||||
else:
|
||||
getaddrinfo = asyncio.get_event_loop().getaddrinfo
|
||||
try:
|
||||
infos = await getaddrinfo(host, 80, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
permit = False
|
||||
reason = 'address resolution failure'
|
||||
else:
|
||||
permit = any(source == info[-1][0] for info in infos)
|
||||
reason = 'source-destination mismatch'
|
||||
|
||||
if permit:
|
||||
self.logger.info(f'accepted add_peer request from {source} for {host}')
|
||||
await self._note_peers([peer], check_ports=True)
|
||||
else:
|
||||
self.logger.warning(f'rejected add_peer request from {source} '
|
||||
f'for {host} ({reason})')
|
||||
|
||||
return permit
|
||||
|
||||
def on_peers_subscribe(self, is_tor):
|
||||
'''Returns the server peers as a list of (ip, host, details) tuples.
|
||||
|
||||
We return all peers we've connected to in the last day.
|
||||
Additionally, if we don't have onion routing, we return a few
|
||||
hard-coded onion servers.
|
||||
'''
|
||||
recent = self._get_recent_good_peers()
|
||||
|
||||
# Always report ourselves if valid (even if not public)
|
||||
cutoff = time.time() - STALE_SECS
|
||||
peers = {
|
||||
myself
|
||||
for myself in self.myselves
|
||||
if myself.last_good > cutoff
|
||||
}
|
||||
|
||||
# Bucket the clearnet peers and select up to two from each
|
||||
onion_peers = []
|
||||
buckets = defaultdict(list)
|
||||
for peer in recent:
|
||||
if peer.is_tor:
|
||||
onion_peers.append(peer)
|
||||
else:
|
||||
buckets[peer.bucket_for_external_interface()].append(peer)
|
||||
for bucket_peers in buckets.values():
|
||||
random.shuffle(bucket_peers)
|
||||
peers.update(bucket_peers[:2])
|
||||
|
||||
# Add up to 20% onion peers (but up to 10 is OK anyway)
|
||||
random.shuffle(onion_peers)
|
||||
max_onion = 50 if is_tor else max(10, len(peers) // 4)
|
||||
|
||||
peers.update(onion_peers[:max_onion])
|
||||
|
||||
return [peer.to_tuple() for peer in peers]
|
||||
|
||||
def proxy_address(self):
|
||||
'''Return the NetAddress of the proxy, if there is a proxy, otherwise
|
||||
None.'''
|
||||
return self.proxy.address if self.proxy else None
|
||||
|
||||
def rpc_data(self):
|
||||
'''Peer data for the peers RPC method.'''
|
||||
self._set_peer_statuses()
|
||||
|
||||
def peer_data(peer):
|
||||
data = peer.serialize()
|
||||
data['status'] = STATUS_DESCS[peer.status]
|
||||
return data
|
||||
|
||||
def peer_key(peer):
|
||||
return peer.bad, -peer.last_good
|
||||
|
||||
return [peer_data(peer) for peer in sorted(self.peers, key=peer_key)]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,219 @@
|
||||
# Copyright (c) 2016-2017, the ElectrumX authors
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
'''Backend database abstraction.'''
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import Type, Sequence, TYPE_CHECKING
|
||||
|
||||
import electrumx.lib.util as util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rocksdb import DB as rocksdb_db
|
||||
from plyvel import DB as plyvel_db
|
||||
|
||||
|
||||
def db_class(name: str) -> Type['Storage']:
|
||||
'''Returns a DB engine class.'''
|
||||
for db_class in util.subclasses(Storage):
|
||||
if db_class.__name__.lower() == name.lower():
|
||||
db_class.import_module()
|
||||
return db_class
|
||||
raise RuntimeError(f'unrecognised DB engine "{name}"')
|
||||
|
||||
|
||||
def list_db_engine_choices() -> Sequence[str]:
|
||||
return [db_class.__name__.lower() for db_class in util.subclasses(Storage)]
|
||||
|
||||
|
||||
class Storage:
|
||||
'''Abstract base class of the DB backend abstraction.'''
|
||||
|
||||
def __init__(self, name: str, for_sync: bool):
|
||||
self.is_new = not os.path.exists(name)
|
||||
self.for_sync = for_sync or self.is_new
|
||||
self.open(name, create=self.is_new)
|
||||
|
||||
@classmethod
|
||||
def import_module(cls) -> None:
|
||||
'''Import the DB engine module.'''
|
||||
raise NotImplementedError
|
||||
|
||||
def open(self, name: str, create: bool):
|
||||
'''Open an existing database or create a new one.'''
|
||||
raise NotImplementedError
|
||||
|
||||
def close(self) -> None:
|
||||
'''Close an existing database.'''
|
||||
raise NotImplementedError
|
||||
|
||||
def get(self, key: bytes) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
def put(self, key: bytes, value: bytes) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def write_batch(self):
|
||||
'''Return a context manager that provides `put` and `delete`.
|
||||
|
||||
Changes should only be committed when the context manager
|
||||
closes without an exception.
|
||||
'''
|
||||
raise NotImplementedError
|
||||
|
||||
def iterator(self, prefix: bytes = b'', reverse: bool = False):
|
||||
'''Return an iterator that yields (key, value) pairs from the
|
||||
database sorted by key.
|
||||
|
||||
If `prefix` is set, only keys starting with `prefix` will be
|
||||
included. If `reverse` is True the items are returned in
|
||||
reverse order.
|
||||
|
||||
The iterator supports .seek(key), which moves it just left of `key`.
|
||||
- if forward-iterating
|
||||
- if `key` is present, it will be the next item
|
||||
- if `key` is not present, the next item will be the smallest still greater than `key`
|
||||
- if reverse-iterating
|
||||
- the next item will be the largest still smaller than `key`
|
||||
'''
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LevelDB(Storage):
|
||||
'''LevelDB database engine.'''
|
||||
|
||||
db: 'plyvel_db'
|
||||
|
||||
@classmethod
|
||||
def import_module(cls):
|
||||
try:
|
||||
import plyvel
|
||||
except ImportError as e:
|
||||
raise Exception(f"{e}. For the leveldb DB-backend, you should install ElectrumX with the [leveldb] pip extra.") from e
|
||||
|
||||
cls.module = plyvel
|
||||
|
||||
def open(self, name, create):
|
||||
mof = 512 if self.for_sync else 128
|
||||
# Use snappy compression (the default)
|
||||
self.db = self.module.DB(name, create_if_missing=create,
|
||||
max_open_files=mof)
|
||||
self.close = self.db.close
|
||||
self.get = self.db.get
|
||||
self.put = self.db.put
|
||||
self.write_batch = partial(self.db.write_batch, transaction=True,
|
||||
sync=True)
|
||||
|
||||
def iterator(self, prefix=b'', reverse=False):
|
||||
return LevelDBIterator(db=self.db, prefix=prefix, reverse=reverse)
|
||||
|
||||
|
||||
class LevelDBIterator:
|
||||
'''An iterator for LevelDB.'''
|
||||
|
||||
def __init__(self, *, db: 'plyvel_db', prefix: bytes, reverse: bool):
|
||||
self.prefix = prefix
|
||||
self.iterator = db.iterator(prefix=prefix, reverse=reverse)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
k, v = next(self.iterator)
|
||||
if not k.startswith(self.prefix):
|
||||
raise StopIteration
|
||||
return k, v
|
||||
|
||||
def seek(self, key: bytes) -> None:
|
||||
self.iterator.seek(key)
|
||||
|
||||
|
||||
class RocksDB(Storage):
|
||||
'''RocksDB database engine.'''
|
||||
|
||||
db: 'rocksdb_db'
|
||||
|
||||
@classmethod
|
||||
def import_module(cls):
|
||||
try:
|
||||
import rocksdb
|
||||
except ImportError as e:
|
||||
raise Exception(f"{e}. For the rocksdb DB-backend, you should install ElectrumX with the [rocksdb] pip extra.") from e
|
||||
cls.module = rocksdb
|
||||
|
||||
def open(self, name, create):
|
||||
mof = 512 if self.for_sync else 128
|
||||
# Use snappy compression (the default)
|
||||
options = self.module.Options(create_if_missing=create,
|
||||
use_fsync=True,
|
||||
target_file_size_base=33554432,
|
||||
max_open_files=mof)
|
||||
self.db = self.module.DB(name, options)
|
||||
self.close = self.db.close
|
||||
self.get = self.db.get
|
||||
self.put = self.db.put
|
||||
|
||||
def write_batch(self):
|
||||
return RocksDBWriteBatch(self.db)
|
||||
|
||||
def iterator(self, prefix=b'', reverse=False):
|
||||
return RocksDBIterator(db=self.db, prefix=prefix, reverse=reverse)
|
||||
|
||||
|
||||
class RocksDBWriteBatch:
|
||||
'''A write batch for RocksDB.'''
|
||||
|
||||
def __init__(self, db: 'rocksdb_db'):
|
||||
self.batch = RocksDB.module.WriteBatch()
|
||||
self.db = db
|
||||
|
||||
def __enter__(self):
|
||||
return self.batch
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if not exc_val:
|
||||
self.db.write(self.batch)
|
||||
|
||||
|
||||
class RocksDBIterator:
|
||||
'''An iterator for RocksDB.'''
|
||||
|
||||
def __init__(self, *, db: 'rocksdb_db', prefix: bytes, reverse: bool):
|
||||
self.prefix = prefix
|
||||
self._is_reverse = reverse
|
||||
if reverse:
|
||||
self.iterator = reversed(db.iteritems())
|
||||
nxt_prefix = util.increment_byte_string(prefix)
|
||||
if nxt_prefix:
|
||||
self.iterator.seek(nxt_prefix)
|
||||
try:
|
||||
next(self.iterator)
|
||||
except StopIteration:
|
||||
self.iterator.seek(nxt_prefix)
|
||||
else:
|
||||
self.iterator.seek_to_last()
|
||||
else:
|
||||
self.iterator = db.iteritems()
|
||||
self.iterator.seek(prefix)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
k, v = next(self.iterator)
|
||||
if not k.startswith(self.prefix):
|
||||
raise StopIteration
|
||||
return k, v
|
||||
|
||||
def seek(self, key: bytes) -> None:
|
||||
self.iterator.seek(key)
|
||||
if self._is_reverse:
|
||||
try:
|
||||
next(self)
|
||||
except StopIteration:
|
||||
self.iterator.seek_to_last()
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2025, The Electrum developers
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# See the file "LICENCE" for information about the copyright
|
||||
# and warranty status of this software.
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from aiorpcx.rawsocket import RSTransport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .session import RPCSessionWithTaskGroup
|
||||
|
||||
|
||||
class PaddedRSTransport(RSTransport):
|
||||
"""A raw socket transport that provides basic countermeasures against traffic analysis
|
||||
by padding the jsonrpc payload with whitespaces to have ~uniform-size TCP packets.
|
||||
(it is assumed that a network observer does not see plaintext transport contents,
|
||||
due to it being wrapped e.g. in TLS)
|
||||
"""
|
||||
|
||||
MIN_PACKET_SIZE = 1024
|
||||
WAIT_FOR_BUFFER_GROWTH_SECONDS = 1.0
|
||||
# amount of (unpadded) bytes sent instantly before beginning with polling.
|
||||
# This makes the initial handshake where a few small messages are exchanged faster.
|
||||
WARMUP_BUDGET_SIZE = 1024
|
||||
|
||||
session: Optional['RPCSessionWithTaskGroup']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
RSTransport.__init__(self, *args, **kwargs)
|
||||
self._sbuffer = bytearray() # "send buffer"
|
||||
self._sbuffer_task = None # type: Optional[asyncio.Task]
|
||||
self._sbuffer_has_data_evt = asyncio.Event()
|
||||
self._last_send = time.monotonic()
|
||||
self._force_send = False # type: bool
|
||||
|
||||
# note: this does not call super().write() but is a complete reimplementation
|
||||
async def write(self, message):
|
||||
await self._can_send.wait()
|
||||
if self.is_closing():
|
||||
return
|
||||
framed_message = self._framer.frame(message)
|
||||
self._sbuffer += framed_message
|
||||
self._sbuffer_has_data_evt.set()
|
||||
self._maybe_consume_sbuffer()
|
||||
|
||||
def _maybe_consume_sbuffer(self) -> None:
|
||||
"""Maybe take some data from sbuffer and send it on the wire."""
|
||||
if not self._can_send.is_set() or self.is_closing():
|
||||
return
|
||||
buf = self._sbuffer
|
||||
if not buf:
|
||||
return
|
||||
# if there is enough data in the buffer, or if we haven't sent in a while, send now:
|
||||
if not (
|
||||
self._force_send
|
||||
or len(buf) >= self.MIN_PACKET_SIZE
|
||||
or self._last_send + self.WAIT_FOR_BUFFER_GROWTH_SECONDS < time.monotonic()
|
||||
or self.session.send_size < self.WARMUP_BUDGET_SIZE
|
||||
):
|
||||
return
|
||||
assert buf[-2:] in (b"}\n", b"]\n"), f"unexpected json-rpc terminator: {buf[-2:]=!r}"
|
||||
# either (1) pad length to next power of two, to create "lsize" packet:
|
||||
payload_lsize = len(buf)
|
||||
total_lsize = max(self.MIN_PACKET_SIZE, 2 ** (payload_lsize.bit_length()))
|
||||
npad_lsize = total_lsize - payload_lsize
|
||||
# or if that wasted a lot of bandwidth with padding, (2) defer sending some messages
|
||||
# and create a packet with half that size ("ssize", s for small)
|
||||
total_ssize = max(self.MIN_PACKET_SIZE, total_lsize // 2)
|
||||
payload_ssize = buf.rfind(b"\n", 0, total_ssize)
|
||||
if payload_ssize != -1:
|
||||
payload_ssize += 1 # for "\n" char
|
||||
npad_ssize = total_ssize - payload_ssize
|
||||
else:
|
||||
npad_ssize = float("inf")
|
||||
# decide between (1) and (2):
|
||||
if self._force_send or npad_lsize <= npad_ssize:
|
||||
# (1) create "lsize" packet: consume full buffer
|
||||
npad = npad_lsize
|
||||
p_idx = payload_lsize
|
||||
else:
|
||||
# (2) create "ssize" packet: consume some, but defer some for later
|
||||
npad = npad_ssize
|
||||
p_idx = payload_ssize
|
||||
# pad by adding spaces near end
|
||||
json_rpc_terminator = buf[p_idx-2:p_idx]
|
||||
assert json_rpc_terminator in (b"}\n", b"]\n"), f"unexpected {json_rpc_terminator=!r}"
|
||||
buf2 = buf[:p_idx-2] + (npad * b" ") + json_rpc_terminator
|
||||
self._asyncio_transport.write(buf2)
|
||||
self._last_send = time.monotonic()
|
||||
del self._sbuffer[:p_idx]
|
||||
if not self._sbuffer:
|
||||
self._sbuffer_has_data_evt.clear()
|
||||
|
||||
async def _poll_sbuffer(self):
|
||||
while not self.is_closing():
|
||||
await self._can_send.wait()
|
||||
await self._sbuffer_has_data_evt.wait() # to avoid busy-waiting
|
||||
self._maybe_consume_sbuffer()
|
||||
# If there is still data in the buffer, sleep until it would time out.
|
||||
# note: If the transport is ~idle, when we wake up, we will send the current buf data,
|
||||
# but if busy, we might wake up to completely new buffer contents. Either is fine.
|
||||
if len(self._sbuffer) > 0:
|
||||
timeout_abs = self._last_send + self.WAIT_FOR_BUFFER_GROWTH_SECONDS
|
||||
timeout_rel = max(0.0, timeout_abs - time.monotonic())
|
||||
await asyncio.sleep(timeout_rel)
|
||||
|
||||
def connection_made(self, transport: asyncio.BaseTransport):
|
||||
super().connection_made(transport)
|
||||
coro = self.session.taskgroup.spawn(self._poll_sbuffer())
|
||||
self._sbuffer_task = self.loop.create_task(coro)
|
||||
|
||||
async def close(self, *args, **kwargs):
|
||||
'''Close the connection and return when closed.'''
|
||||
# Flush buffer before disconnecting. This makes ReplyAndDisconnect work:
|
||||
self._force_send = True
|
||||
self._maybe_consume_sbuffer()
|
||||
await super().close(*args, **kwargs)
|
||||
Reference in new issue
Block a user