diff --git a/test/functional/chronik_electrum_basic.py b/test/functional/chronik_electrum_basic.py new file mode 100644 --- /dev/null +++ b/test/functional/chronik_electrum_basic.py @@ -0,0 +1,45 @@ +# Copyright (c) 2024-present The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +""" +Test Chronik's electrum interface +""" +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal + + +class ChronikElectrumBasic(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [["-chronik"]] + + def skip_test_if_missing_module(self): + self.skip_if_no_chronik() + + def run_test(self): + self.client = self.nodes[0].get_chronik_electrum_client() + self.test_errors() + self.test_success() + + def test_errors(self): + """Test adherence to the JSON RPC spec (error codes...) + See https://www.jsonrpc.org/specification + """ + response = self.client.foobar() + assert_equal(response.result, None) + assert_equal(response.error, {"code": -32600, "message": "Invalid request"}) + + response = self.client.spam.foo.bar() + assert_equal(response.result, None) + assert_equal(response.error, {"code": -32601, "message": "Method not found"}) + + def test_success(self): + # This method return {... "result":null} which JSON-decodes to None + response = self.client.server.ping() + assert_equal(response.result, None) + assert_equal(response.error, None) + + +if __name__ == "__main__": + ChronikElectrumBasic().main() diff --git a/test/functional/test_framework/jsonrpctools.py b/test/functional/test_framework/jsonrpctools.py new file mode 100644 --- /dev/null +++ b/test/functional/test_framework/jsonrpctools.py @@ -0,0 +1,119 @@ +# Copyright (c) 2024-present The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +from __future__ import annotations + +import json +import socket +from typing import Any, Optional + + +class OversizedResponseError(Exception): + pass + + +class JsonRpcResponse: + def __init__( + self, + id_: Optional[int], + result: Optional[Any] = None, + error: Optional[dict] = None, + ): + self.id = id_ + self.result = result + self.error = error + + def __str__(self): + return ( + f"JsonRpcResponse(id={self.id}, result={self.result}, error={self.error})" + ) + + +class MethodNameProxy: + """Recursive proxy. The final proxy in the chain is the one doing the RPC call""" + + def __init__(self, client: ChronikElectrumClient, name: str, parent_name: str = ""): + self.client = client + self.parent_name = parent_name + self.name = name + + def __getattr__(self, item) -> MethodNameProxy: + if self.parent_name: + parent_name = f"{self.parent_name}.{self.name}" + else: + parent_name = self.name + return MethodNameProxy(self.client, item, parent_name) + + def __call__(self, *args, **kwargs) -> JsonRpcResponse: + method = f"{self.parent_name}.{self.name}" if self.parent_name else self.name + params: Optional[list[Any] | dict[str, Any]] + if not kwargs and not args: + params = None + elif not kwargs: + # all positional params. Make it a list, as json doesn't support tuples + params = list(args) + elif not args: + # all named params + params = kwargs + else: + raise RuntimeError("Params must be all positional or all named arguments") + return self.client.synchronous_request(method, params) + + +class ChronikElectrumClient: + """JSONRPC client. + + >>> client = ChronikElectrumClient("127.0.0.1", 500001) + >>> client.blockchain.transaction.get_height("3fbe7aebbe4210d667c2eb96d7efa5b43bb3d7a4c00dc08c16ad4e4ce4d2ea9b") + JsonRpcResponse(id=0, result=875001, error=None) + >>> client.spam.foo.bar() + JsonRpcResponse(id=0, result=None, error={'code': -32601, 'message': 'Method not found'}) + """ + + DEFAULT_TIMEOUT = 30 + MAX_DATA_SIZE = 10_000_000 + + def __init__(self, host: str, port: int, timeout=DEFAULT_TIMEOUT) -> None: + self.host = host + self.port = port + self.timeout = timeout + + self.id = -1 + + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(timeout) + self.sock.connect((host, port)) + + def __getattr__(self, item): + """Build a recursive proxy. For instance if the caller calls + client.blockchain.transaction.get(txid), it will create a + MethodNameProxy(name="blockchain") which will in turn create a + MethodNameProxy(name="transaction", parent_name="blockchain") which will + create a MethodNameProxy(name="get", parent_name="blockchain.transaction"). + That last level of proxy will then execute the jsonrpc call with + method blockchain.transaction.get and params [txid]. + """ + return MethodNameProxy(self, item) + + def synchronous_request( + self, method: str, params: Optional[list | dict] + ) -> JsonRpcResponse: + self.id += 1 + request = {"jsonrpc": "2.0", "method": method, "id": self.id} + if params is not None: + request["params"] = params + self.sock.send(json.dumps(request).encode("utf-8") + b"\n") + + data = b"" + while b"\n" not in data: + data += self.sock.recv(1024) + if len(data) > self.MAX_DATA_SIZE: + raise OversizedResponseError() + + json_reply = json.loads(data.decode("utf-8")) + # As per the JSONRPC spec, we cannot have both an error and a result + assert "error" not in json_reply or "result" not in json_reply + assert json_reply.get("id") == self.id + return JsonRpcResponse( + json_reply.get("id"), json_reply.get("result"), json_reply.get("error") + ) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -30,6 +30,7 @@ assert_equal, check_json_precision, chronik_port, + chronikelectrum_port, get_datadir_path, initialize_datadir, p2p_port, @@ -588,6 +589,7 @@ rpc_port=rpc_port(i), p2p_port=p2p_port(i), chronik_port=chronik_port(i), + chronik_electrum_port=chronikelectrum_port(i), timewait=self.rpc_timeout, timeout_factor=self.options.timeout_factor, bitcoind=binary[i], @@ -913,6 +915,7 @@ rpc_port=rpc_port(CACHE_NODE_ID), p2p_port=p2p_port(CACHE_NODE_ID), chronik_port=chronik_port(CACHE_NODE_ID), + chronik_electrum_port=chronikelectrum_port(CACHE_NODE_ID), timewait=self.rpc_timeout, timeout_factor=self.options.timeout_factor, bitcoind=self.options.bitcoind, diff --git a/test/functional/test_framework/test_node.py b/test/functional/test_framework/test_node.py --- a/test/functional/test_framework/test_node.py +++ b/test/functional/test_framework/test_node.py @@ -25,6 +25,7 @@ from .address import ADDRESS_ECREG_UNSPENDABLE from .authproxy import JSONRPCException from .descriptors import descsum_create +from .jsonrpctools import ChronikElectrumClient from .messages import XEC, CTransaction, FromHex from .p2p import P2P_SUBVERSION from .util import ( @@ -75,6 +76,7 @@ rpc_port, p2p_port, chronik_port, + chronik_electrum_port, timewait, timeout_factor, bitcoind, @@ -106,6 +108,7 @@ self.rpc_port = rpc_port self.p2p_port = p2p_port self.chronik_port = chronik_port + self.chronik_electrum_port = chronik_electrum_port self.name = f"testnode-{i}" self.rpc_timeout = timewait self.binary = bitcoind @@ -962,6 +965,16 @@ timeout=DEFAULT_TIMEOUT * self.timeout_factor, ) + def get_chronik_electrum_client(self) -> ChronikElectrumClient: + # host is always None in practice, we should get rid of it at some + # point. In the meantime, let's properly handle the API. + host = self.host if self.host is not None else "127.0.0.1" + return ChronikElectrumClient( + host, + self.chronik_electrum_port, + timeout=ChronikElectrumClient.DEFAULT_TIMEOUT * self.timeout_factor, + ) + def bumpmocktime(self, seconds): """Fast forward using setmocktime to self.mocktime + seconds. Requires setmocktime to have been called at some point in the past.""" diff --git a/test/functional/test_framework/util.py b/test/functional/test_framework/util.py --- a/test/functional/test_framework/util.py +++ b/test/functional/test_framework/util.py @@ -307,6 +307,7 @@ P2P = 0 RPC = 1 CHRONIK = 2 + CHRONIKELECTRUM = 3 # The maximum number of nodes a single test can spawn @@ -323,6 +324,7 @@ PortName.P2P: 0, PortName.RPC: PORT_RANGE, PortName.CHRONIK: PORT_RANGE * 2, + PortName.CHRONIKELECTRUM: PORT_RANGE * 3, } # Globals used for incrementing ports. Initially uninitialized because they @@ -418,6 +420,10 @@ return unique_port(PortName.CHRONIK, n) +def chronikelectrum_port(n: int) -> int: + return unique_port(PortName.CHRONIKELECTRUM, n) + + def rpc_url(datadir, chain, host, port): rpc_u, rpc_p = get_auth_cookie(datadir, chain) if host is None: @@ -460,6 +466,7 @@ f.write(f"port={p2p_port(n)}\n") f.write(f"rpcport={rpc_port(n)}\n") f.write(f"chronikbind=127.0.0.1:{chronik_port(n)}\n") + f.write(f"chronikelectrumbind=127.0.0.1:{chronikelectrum_port(n)}\n") # Disable server-side timeouts to avoid intermittent issues f.write("rpcservertimeout=99000\n") # Chronik by default is tuned for initial sync, tune it down for regtest