diff --git a/src/electrumx/server/env.py b/src/electrumx/server/env.py index 7ff248253..404639a26 100644 --- a/src/electrumx/server/env.py +++ b/src/electrumx/server/env.py @@ -56,6 +56,39 @@ def __init__(self, coin=None): network = self.default('NET', 'mainnet').strip() self.coin = Coin.lookup_coin_class(coin_name, network) + # Ravencoin backend capability is installed only for RVN. This keeps + # generic ElectrumX and every other coin on their existing daemon, + # session and protocol paths. The new RPC is additive, so legacy + # Electrum clients continue to use the inherited handlers unchanged. + self.ravencoin_backend_identity = None + self.ravencoin_backend_info_max_age = 5 + if self.coin.NAME == 'Ravencoin': + from electrumx.server.ravencoin_backend import ( + BackendIdentity, + configure_ravencoin_coin, + ) + + configure_ravencoin_coin(self.coin) + self.ravencoin_backend_info_max_age = self.integer( + 'RAVENCOIN_BACKEND_INFO_MAX_AGE', 5 + ) + if not 0 <= self.ravencoin_backend_info_max_age <= 60: + raise self.Error( + 'RAVENCOIN_BACKEND_INFO_MAX_AGE must be between 0 and 60 seconds' + ) + try: + self.ravencoin_backend_identity = BackendIdentity.from_config( + repository=self.default('RAVENCOIN_SOURCE_REPOSITORY', ''), + tag=self.default('RAVENCOIN_SOURCE_TAG', ''), + commit=self.default('RAVENCOIN_SOURCE_COMMIT', ''), + artifact_sha256=self.default('RAVENCOIN_ARTIFACT_SHA256', ''), + evidence=self.default('RAVENCOIN_IDENTITY_EVIDENCE', ''), + ) + except ValueError as exc: + raise self.Error( + f'invalid Ravencoin backend identity configuration: {exc}' + ) from exc + # Peer discovery self.peer_discovery = self.peer_discovery_enum() @@ -141,7 +174,7 @@ def sane_max_sessions(self): f'{nofile_limit:,d}' ) except ImportError: - value = 512 # that is what returned by stdio's _getmaxstdio() + value = 512 # that is what returned by stdio's maxstdio() return value def _check_and_fix_cost_limits(self): diff --git a/src/electrumx/server/ravencoin_backend.py b/src/electrumx/server/ravencoin_backend.py new file mode 100644 index 000000000..96a180664 --- /dev/null +++ b/src/electrumx/server/ravencoin_backend.py @@ -0,0 +1,361 @@ +# Copyright (c) 2026, the ElectrumX-RVN community maintainers +# +# The MIT License (MIT). See LICENCE for details. + +'''Ravencoin-only backend capability for ElectrumX. + +This module is intentionally isolated from the generic ElectrumX server paths. +It is activated by :mod:`electrumx.server.env` only for the Ravencoin coin +class. Legacy Electrum protocol handlers and every other coin keep using the +existing generic Daemon and ElectrumX classes unchanged. +''' + +import asyncio +from dataclasses import dataclass +import re +import time + +import electrumx +from electrumx.lib import util +from electrumx.lib.coins import CoinError +from electrumx.lib.hash import hash_to_hex_str +from electrumx.server.daemon import Daemon +from electrumx.server.session import ElectrumX + + +MINIMUM_SAFE_CORE = (4, 8, 0, 0) +MINIMUM_SAFE_CORE_STRING = '4.8.0' +INCIDENT_CHECKPOINT_HEIGHT = 4_487_775 +INCIDENT_CHECKPOINT_HASH = ( + '000000000002d64509e06e76ddbbe418c725291687ec62b41ecfc40386a091fd' +) +KAWPOW_HEIGHT_ENFORCEMENT_HEIGHT = 4_487_776 +SAFETY_PROFILE = 'rvn-consensus-2026-08-v1' + + +class IdentityEvidence: + '''How strongly the operator can identify the running Ravencoin Core build.''' + + BUILD_VERIFIED = 'BUILD_IDENTITY_VERIFIED' + ATTESTED = 'BUILD_IDENTITY_ATTESTED' + VERSION_ONLY = 'VERSION_ONLY' + UNKNOWN = 'UNKNOWN' + ALL = (BUILD_VERIFIED, ATTESTED, VERSION_ONLY, UNKNOWN) + + +@dataclass(frozen=True) +class BackendIdentity: + '''Operator-supplied identity of the configured Ravencoin Core build.''' + + repository: str | None = None + tag: str | None = None + commit: str | None = None + artifact_sha256: str | None = None + evidence: str = IdentityEvidence.VERSION_ONLY + + @classmethod + def from_config( + cls, + repository='', + tag='', + commit='', + artifact_sha256='', + evidence='', + ): + repository = (repository or '').strip() or None + tag = (tag or '').strip() or None + commit = (commit or '').strip().lower() or None + artifact_sha256 = (artifact_sha256 or '').strip().lower() or None + declared = (evidence or '').strip().upper() or None + + if commit is not None and not re.fullmatch(r'[0-9a-f]{40}', commit): + raise ValueError('RAVENCOIN_SOURCE_COMMIT must be a 40-character hex commit') + if artifact_sha256 is not None and not re.fullmatch(r'[0-9a-f]{64}', artifact_sha256): + raise ValueError('RAVENCOIN_ARTIFACT_SHA256 must be a 64-character hex digest') + if declared is not None and declared not in IdentityEvidence.ALL: + raise ValueError(f'unknown RAVENCOIN_IDENTITY_EVIDENCE {declared!r}') + + # A partial identity must not look stronger than version-only evidence. + if repository is None or commit is None: + return cls(evidence=IdentityEvidence.VERSION_ONLY) + + if declared == IdentityEvidence.BUILD_VERIFIED and artifact_sha256 is None: + raise ValueError( + 'BUILD_IDENTITY_VERIFIED requires RAVENCOIN_ARTIFACT_SHA256' + ) + + return cls( + repository=repository, + tag=tag, + commit=commit, + artifact_sha256=artifact_sha256, + evidence=declared or IdentityEvidence.ATTESTED, + ) + + def public_dict(self): + result = {'evidence': self.evidence} + if self.repository is not None and self.commit is not None: + result['sourceRepository'] = self.repository + result['sourceCommit'] = self.commit + if self.tag is not None: + result['sourceTag'] = self.tag + if self.artifact_sha256 is not None: + result['artifactSha256'] = self.artifact_sha256 + return result + + +def parse_core_version(version): + '''Decode Ravencoin Core's integer version; e.g. 4080000 -> (4, 8, 0, 0).''' + if isinstance(version, bool) or not isinstance(version, int) or version < 0: + raise ValueError(f'invalid Ravencoin Core version: {version!r}') + major, remainder = divmod(version, 1_000_000) + minor, remainder = divmod(remainder, 10_000) + patch, build = divmod(remainder, 100) + return major, minor, patch, build + + +def core_version_string(version_tuple): + major, minor, patch, build = version_tuple + base = f'{major}.{minor}.{patch}' + return f'{base}.{build}' if build else base + + +def expected_daemon_chain(electrum_network): + mapping = {'mainnet': 'main', 'testnet': 'test', 'regtest': 'regtest'} + try: + return mapping[electrum_network] + except KeyError as exc: + raise ValueError(f'unsupported Ravencoin network: {electrum_network!r}') from exc + + +@dataclass(frozen=True) +class RavencoinBackendStatus: + version_number: int + version_tuple: tuple + subversion: str + network: str + blocks: int + headers: int + initial_block_download: bool | None + version_safe: bool + network_matches: bool + synchronized: bool + checkpoint_known: bool + checkpoint_verified: bool + observed_at: int + + @property + def core_safe(self): + # Synchronization is intentionally reported separately, matching the + # Electrum-Ravencoin evidence contract. + return self.version_safe and self.network_matches and self.checkpoint_known + + def public_dict(self, server_version, identity=None, kawpow_height_validation=False): + identity = identity or BackendIdentity() + return { + 'server': 'ElectrumX', + 'serverVersion': server_version, + 'backend': { + 'name': 'Ravencoin Core', + 'version': core_version_string(self.version_tuple), + 'versionNumber': self.version_number, + 'subversion': self.subversion, + 'network': self.network, + 'blocks': self.blocks, + 'headers': self.headers, + 'initialBlockDownload': self.initial_block_download, + 'identity': identity.public_dict(), + }, + 'compatibility': { + 'minimumSafeCore': MINIMUM_SAFE_CORE_STRING, + 'safetyProfile': SAFETY_PROFILE, + 'identityEvidence': identity.evidence, + 'coreSafe': self.core_safe, + 'networkMatches': self.network_matches, + 'backendSynchronized': self.synchronized, + 'kawpowHeightValidation': bool(kawpow_height_validation), + 'checkpoint4487775': self.checkpoint_verified, + }, + 'observedAt': self.observed_at, + } + + +def evaluate_backend( + network_info, + blockchain_info, + electrum_network, + checkpoint_hash=None, + observed_at=None, +): + '''Build sanitized backend evidence from Ravencoin Core RPC results.''' + version_number = network_info.get('version') + version_tuple = parse_core_version(version_number) + + subversion = network_info.get('subversion') + if not isinstance(subversion, str) or not subversion: + raise ValueError('Ravencoin Core subversion is missing or malformed') + + network = blockchain_info.get('chain') + blocks = blockchain_info.get('blocks') + headers = blockchain_info.get('headers') + ibd = blockchain_info.get('initialblockdownload') + + if not isinstance(network, str) or not network: + raise ValueError('Ravencoin Core network is missing or malformed') + if isinstance(blocks, bool) or not isinstance(blocks, int) or blocks < 0: + raise ValueError('Ravencoin Core block height is missing or malformed') + if isinstance(headers, bool) or not isinstance(headers, int) or headers < blocks: + raise ValueError('Ravencoin Core header height is missing or malformed') + if ibd not in (True, False, None): + raise ValueError('Ravencoin Core IBD state is malformed') + + network_matches = network == expected_daemon_chain(electrum_network) + version_safe = version_tuple >= MINIMUM_SAFE_CORE + checkpoint_required = network == 'main' and blocks >= INCIDENT_CHECKPOINT_HEIGHT + checkpoint_matches = ( + isinstance(checkpoint_hash, str) + and checkpoint_hash.lower() == INCIDENT_CHECKPOINT_HASH + ) + checkpoint_known = not checkpoint_required or checkpoint_matches + checkpoint_verified = checkpoint_required and checkpoint_matches + synchronized = ibd is not True and blocks == headers + + return RavencoinBackendStatus( + version_number=version_number, + version_tuple=version_tuple, + subversion=subversion, + network=network, + blocks=blocks, + headers=headers, + initial_block_download=ibd, + version_safe=version_safe, + network_matches=network_matches, + synchronized=synchronized, + checkpoint_known=checkpoint_known, + checkpoint_verified=checkpoint_verified, + observed_at=int(time.time() if observed_at is None else observed_at), + ) + + +class RavencoinDaemon(Daemon): + '''Daemon adapter used only by Ravencoin instances.''' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._ravencoin_backend_status = None + self._ravencoin_backend_checked = 0.0 + self._ravencoin_backend_lock = asyncio.Lock() + + async def ravencoin_backend_status(self, electrum_network, max_age=5): + '''Return fresh/cached status of the configured ravend.''' + now = time.monotonic() + if ( + self._ravencoin_backend_status is not None + and max_age > 0 + and now - self._ravencoin_backend_checked <= max_age + ): + return self._ravencoin_backend_status + + async with self._ravencoin_backend_lock: + now = time.monotonic() + if ( + self._ravencoin_backend_status is not None + and max_age > 0 + and now - self._ravencoin_backend_checked <= max_age + ): + return self._ravencoin_backend_status + + network_info, blockchain_info = await asyncio.gather( + self._send_single('getnetworkinfo'), + self._send_single('getblockchaininfo'), + ) + + checkpoint_hash = None + if ( + blockchain_info.get('chain') == 'main' + and blockchain_info.get('blocks', -1) >= INCIDENT_CHECKPOINT_HEIGHT + ): + checkpoint_hash = await self._send_single( + 'getblockhash', (INCIDENT_CHECKPOINT_HEIGHT,) + ) + + status = evaluate_backend( + network_info, + blockchain_info, + electrum_network, + checkpoint_hash=checkpoint_hash, + ) + self._ravencoin_backend_status = status + self._ravencoin_backend_checked = time.monotonic() + return status + + +class RavencoinElectrumX(ElectrumX): + '''Add one optional Ravencoin RPC without changing existing handlers.''' + + def set_request_handlers(self, ptuple): + # This preserves every protocol-version-dependent handler configured by + # ElectrumX, including handlers used by older Electrum clients. + super().set_request_handlers(ptuple) + self.request_handlers['server.ravencoin_backend'] = self.phandle_ravencoin_backend + + async def phandle_ravencoin_backend(self): + self.bump_cost(0.5) + status = await self.session_mgr.daemon.ravencoin_backend_status( + self.coin.NET, + self.env.ravencoin_backend_info_max_age, + ) + return status.public_dict( + server_version=electrumx.version, + identity=self.env.ravencoin_backend_identity, + kawpow_height_validation=getattr( + self.coin, 'KAWPOW_HEIGHT_VALIDATION', False + ), + ) + + +def _ravencoin_block_header(cls, block, height): + '''Return a Ravencoin header after incident-era invariant checks.''' + expected_size = cls.static_header_len(height) + header = block[:expected_size] + if len(header) != expected_size: + raise CoinError( + f'Ravencoin header at height {height} is {len(header)} bytes, ' + f'expected {expected_size}' + ) + + if cls.NET == 'mainnet' and height >= KAWPOW_HEIGHT_ENFORCEMENT_HEIGHT: + declared_height = util.unpack_le_uint32_from(header, 76)[0] + if declared_height != height: + raise CoinError( + f'Ravencoin KAWPOW header at chain height {height} declares ' + f'nHeight={declared_height}' + ) + + if cls.NET == 'mainnet' and height == INCIDENT_CHECKPOINT_HEIGHT: + actual_hash = hash_to_hex_str(cls.header_hash_rev(header)) + if actual_hash != INCIDENT_CHECKPOINT_HASH: + raise CoinError( + f'Ravencoin incident checkpoint mismatch at {height}: ' + f'{actual_hash} != {INCIDENT_CHECKPOINT_HASH}' + ) + + return header + + +def configure_ravencoin_coin(coin): + '''Attach the isolated Ravencoin daemon/session and header checks.''' + if getattr(coin, 'NAME', None) != 'Ravencoin': + raise ValueError('Ravencoin backend capability can only configure Ravencoin') + + coin.DAEMON = RavencoinDaemon + coin.SESSIONCLS = RavencoinElectrumX + coin.INCIDENT_CHECKPOINT_HEIGHT = INCIDENT_CHECKPOINT_HEIGHT + coin.INCIDENT_CHECKPOINT_HASH = INCIDENT_CHECKPOINT_HASH + coin.KAWPOW_HEIGHT_ENFORCEMENT_HEIGHT = KAWPOW_HEIGHT_ENFORCEMENT_HEIGHT + coin.KAWPOW_HEIGHT_VALIDATION = coin.NET == 'mainnet' + + # Setting this classmethod is intentionally local to the selected Ravencoin + # class. No generic Coin or non-RVN protocol behavior is changed. + coin.block_header = classmethod(_ravencoin_block_header) + return coin diff --git a/tests/server/test_ravencoin_backend.py b/tests/server/test_ravencoin_backend.py new file mode 100644 index 000000000..5297d4010 --- /dev/null +++ b/tests/server/test_ravencoin_backend.py @@ -0,0 +1,209 @@ +import pytest + +from electrumx.lib.coins import Bitcoin, CoinError, Ravencoin, RavencoinTestnet +from electrumx.server.ravencoin_backend import ( + BackendIdentity, + INCIDENT_CHECKPOINT_HASH, + KAWPOW_HEIGHT_ENFORCEMENT_HEIGHT, + RavencoinDaemon, + RavencoinElectrumX, + configure_ravencoin_coin, + evaluate_backend, + parse_core_version, +) +from electrumx.server.session import ElectrumX + + +SAFE_NETWORK_INFO = { + 'version': 4_080_000, + 'protocolversion': 70028, + 'subversion': '/Ravencoin:4.8.0/', +} +SAFE_BLOCKCHAIN_INFO = { + 'chain': 'main', + 'blocks': 4_500_000, + 'headers': 4_500_000, + 'initialblockdownload': False, +} + + +class TestRavencoin(Ravencoin): + pass + + +class TestRavencoinTestnet(RavencoinTestnet): + pass + + +def _header_with_height(coin, height): + header = bytearray(coin.KAWPOW_HEADER_SIZE) + header[76:80] = int(height).to_bytes(4, 'little') + return bytes(header) + + +def test_ravencoin_version_encoding_is_not_bitcoin_encoding(): + assert parse_core_version(4_080_000) == (4, 8, 0, 0) + + +def test_backend_payload_matches_electrum_ravencoin_contract(): + status = evaluate_backend( + SAFE_NETWORK_INFO, + SAFE_BLOCKCHAIN_INFO, + 'mainnet', + checkpoint_hash=INCIDENT_CHECKPOINT_HASH, + observed_at=1_777_000_000, + ) + identity = BackendIdentity.from_config( + repository='2miners/Ravencoin', + tag='v4.8.0', + commit='b60f50e04f1fba425b28804e61be2694faaf3469', + artifact_sha256=( + '966cf8978af1f2e3f36e9733d011eb92' + 'f4116750af6f8e77c5a5ced525577c4c' + ), + evidence='BUILD_IDENTITY_VERIFIED', + ) + payload = status.public_dict( + 'ElectrumX 2.0.0', + identity=identity, + kawpow_height_validation=True, + ) + + assert payload['backend']['name'] == 'Ravencoin Core' + assert payload['backend']['version'] == '4.8.0' + assert payload['backend']['versionNumber'] == 4_080_000 + assert payload['backend']['subversion'] == '/Ravencoin:4.8.0/' + assert payload['backend']['network'] == 'main' + assert payload['backend']['identity']['sourceRepository'] == '2miners/Ravencoin' + assert payload['compatibility']['minimumSafeCore'] == '4.8.0' + assert payload['compatibility']['coreSafe'] is True + assert payload['compatibility']['networkMatches'] is True + assert payload['compatibility']['backendSynchronized'] is True + assert payload['compatibility']['kawpowHeightValidation'] is True + assert payload['compatibility']['checkpoint4487775'] is True + + +def test_pre_480_backend_is_reported_unsafe(): + network_info = dict( + SAFE_NETWORK_INFO, + version=4_070_000, + subversion='/Ravencoin:4.7.0/', + ) + status = evaluate_backend( + network_info, + SAFE_BLOCKCHAIN_INFO, + 'mainnet', + checkpoint_hash=INCIDENT_CHECKPOINT_HASH, + observed_at=1_777_000_000, + ) + assert status.version_safe is False + assert status.core_safe is False + + +def test_wrong_checkpoint_is_reported_unsafe(): + status = evaluate_backend( + SAFE_NETWORK_INFO, + SAFE_BLOCKCHAIN_INFO, + 'mainnet', + checkpoint_hash='00' * 32, + observed_at=1_777_000_000, + ) + assert status.checkpoint_verified is False + assert status.core_safe is False + + +def test_verified_identity_requires_artifact_digest(): + with pytest.raises(ValueError, match='ARTIFACT_SHA256'): + BackendIdentity.from_config( + repository='2miners/Ravencoin', + commit='b60f50e04f1fba425b28804e61be2694faaf3469', + evidence='BUILD_IDENTITY_VERIFIED', + ) + + +def test_ravencoin_configuration_is_coin_local(): + original_bitcoin_daemon = Bitcoin.DAEMON + original_bitcoin_session = Bitcoin.SESSIONCLS + + configure_ravencoin_coin(TestRavencoin) + + assert TestRavencoin.DAEMON is RavencoinDaemon + assert TestRavencoin.SESSIONCLS is RavencoinElectrumX + assert TestRavencoin.KAWPOW_HEIGHT_VALIDATION is True + assert Bitcoin.DAEMON is original_bitcoin_daemon + assert Bitcoin.SESSIONCLS is original_bitcoin_session + + +def test_kawpow_declared_height_is_enforced_on_mainnet(): + configure_ravencoin_coin(TestRavencoin) + height = KAWPOW_HEIGHT_ENFORCEMENT_HEIGHT + + good_header = _header_with_height(TestRavencoin, height) + assert TestRavencoin.block_header(good_header, height) == good_header + + bad_header = _header_with_height(TestRavencoin, height - 1) + with pytest.raises(CoinError, match='nHeight'): + TestRavencoin.block_header(bad_header, height) + + +def test_testnet_does_not_claim_mainnet_height_validation(): + configure_ravencoin_coin(TestRavencoinTestnet) + assert TestRavencoinTestnet.KAWPOW_HEIGHT_VALIDATION is False + + +def test_session_extension_preserves_existing_legacy_handlers(monkeypatch): + legacy_version_handler = object() + legacy_balance_handler = object() + + def fake_base_handlers(self, ptuple): + self.protocol_tuple = ptuple + self.request_handlers = { + 'server.version': legacy_version_handler, + 'blockchain.scripthash.get_balance': legacy_balance_handler, + } + self.notification_handlers = {} + + monkeypatch.setattr(ElectrumX, 'set_request_handlers', fake_base_handlers) + session = object.__new__(RavencoinElectrumX) + + for ptuple in ((1, 0), (1, 4, 2), (1, 6), (1, 7)): + RavencoinElectrumX.set_request_handlers(session, ptuple) + assert session.request_handlers['server.version'] is legacy_version_handler + assert ( + session.request_handlers['blockchain.scripthash.get_balance'] + is legacy_balance_handler + ) + assert ( + session.request_handlers['server.ravencoin_backend'] + == session.phandle_ravencoin_backend + ) + + +@pytest.mark.asyncio +async def test_daemon_collects_and_caches_sanitized_status(monkeypatch): + daemon = RavencoinDaemon( + TestRavencoin, + 'rpc_user:rpc_pass@127.0.0.1:8766', + ) + calls = {'network': 0, 'chain': 0, 'checkpoint': 0} + + async def send_single(method, params=None): + if method == 'getnetworkinfo': + calls['network'] += 1 + return dict(SAFE_NETWORK_INFO) + if method == 'getblockchaininfo': + calls['chain'] += 1 + return dict(SAFE_BLOCKCHAIN_INFO) + assert method == 'getblockhash' + assert params == (4_487_775,) + calls['checkpoint'] += 1 + return INCIDENT_CHECKPOINT_HASH + + monkeypatch.setattr(daemon, '_send_single', send_single) + + first = await daemon.ravencoin_backend_status('mainnet', max_age=5) + second = await daemon.ravencoin_backend_status('mainnet', max_age=5) + + assert first is second + assert first.core_safe is True + assert calls == {'network': 1, 'chain': 1, 'checkpoint': 1}