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