From d7c15a33732fecdf67a2f121a400037a6153a7dc Mon Sep 17 00:00:00 2001 From: BD Himes Date: Mon, 8 Jun 2026 15:52:09 +0200 Subject: [PATCH 1/8] Adds new methods `runtime_calls` which allows for sending multiple runtime calls in one message --- async_substrate_interface/async_substrate.py | 234 +++++++++++++++++- async_substrate_interface/sync_substrate.py | 185 ++++++++++++++ tests/e2e_tests/test_e2e_async.py | 30 +++ tests/e2e_tests/test_e2e_sync.py | 29 +++ .../test_substrate_interface_async_unit.py | 92 +++++++ .../test_substrate_interface_sync_unit.py | 88 +++++++ 6 files changed, 648 insertions(+), 10 deletions(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 9a923d8..22d2229 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -911,12 +911,26 @@ async def _handler(self, ws: ClientConnection) -> Optional[Exception]: ) async with self._lock: + resent_batches: set[str] = set() for original_id in list(self._inflight.keys()): payload = self._inflight.pop(original_id) - self._received[original_id] = loop.create_future() - to_send = json.loads(payload) - logger.debug(f"Resubmitting {to_send['id']}") - await self._sending.put(to_send) + parsed = json.loads(payload) + if isinstance(parsed, list): + # Every id in a batch maps to the same frame; recreate all + # of their futures but only re-enqueue the frame once. + if payload in resent_batches: + continue + resent_batches.add(payload) + for sub in parsed: + self._received[sub["id"]] = loop.create_future() + logger.debug( + f"Resubmitting batch {[sub['id'] for sub in parsed]}" + ) + await self._sending.put(parsed) + else: + self._received[original_id] = loop.create_future() + logger.debug(f"Resubmitting {parsed['id']}") + await self._sending.put(parsed) logger.debug("Attempting reconnection...") await self.connect(True) @@ -993,6 +1007,16 @@ async def _recv(self, recd: bytes) -> None: if self._log_raw_websockets: raw_websocket_logger.debug(f"WEBSOCKET_RECEIVE> {recd.decode()}") response = json.loads(recd) + if isinstance(response, list): + # JSON-RPC 2.0 batch response: a single frame carrying an array of + # individual responses. Each is demuxed to its own future by `id` + # (order is not guaranteed by the spec, hence id-based dispatch). + for item in response: + await self._dispatch_response(item) + else: + await self._dispatch_response(response) + + async def _dispatch_response(self, response: dict) -> None: if "id" in response: async with self._lock: inflight_item = self._inflight.pop(response["id"], None) @@ -1062,10 +1086,15 @@ async def _start_sending(self, ws) -> Exception: to_send_ = await self._sending.get() logger.debug("Retrieved item from sending queue") self._sending.task_done() - send_id = to_send_["id"] to_send = json.dumps(to_send_) async with self._lock: - self._inflight[send_id] = to_send + if isinstance(to_send_, list): + # JSON-RPC batch frame: every sub-request shares this frame, + # so track each id as inflight against the same payload string. + for sub in to_send_: + self._inflight[sub["id"]] = to_send + else: + self._inflight[to_send_["id"]] = to_send if self._log_raw_websockets: raw_websocket_logger.debug(f"WEBSOCKET_SEND> {to_send}") await ws.send(to_send) @@ -1086,10 +1115,12 @@ async def _start_sending(self, ws) -> Exception: exc_info=e, ) if to_send is not None: - to_send_ = json.loads(to_send) - if to_send_["id"] in self._received: - self._received[to_send_["id"]].set_exception(e) - self._received[to_send_["id"]].cancel() + parsed = json.loads(to_send) + items = parsed if isinstance(parsed, list) else [parsed] + for item in items: + if item["id"] in self._received: + self._received[item["id"]].set_exception(e) + self._received[item["id"]].cancel() else: for i in self._received.keys(): self._received[i].set_exception(e) @@ -1121,6 +1152,40 @@ async def send(self, payload: dict) -> str: await self._sending.put(to_send) return original_id + async def send_batch(self, payloads: list[dict]) -> list[str]: + """ + Sends multiple payloads as a single JSON-RPC 2.0 batch: one websocket frame + containing an array of requests. Each sub-request is assigned its own id and + future, so the responses are demuxed and can be retrieved individually with + `retrieve` (in whatever order the server returns them). + + Args: + payloads: list of JSON-RPC payload dicts, each with "jsonrpc", "method", + and "params" (without "id" — an id is assigned here per sub-request). + + Returns: + list of internal request ids, in the same order as `payloads`. + """ + # Acquire one subscription permit per sub-request (released individually by + # `retrieve`). Done before taking the lock to match `send` and avoid blocking + # the lock on a full semaphore. + for _ in payloads: + await self.max_subscriptions.acquire() + ids: list[str] = [] + batch: list[dict] = [] + async with self._lock: + loop = asyncio.get_running_loop() + for payload in payloads: + original_id = get_next_id() + while original_id in self._in_use_ids: + original_id = get_next_id() + self._in_use_ids.add(original_id) + self._received[original_id] = loop.create_future() + ids.append(original_id) + batch.append({**payload, "id": original_id}) + await self._sending.put(batch) + return ids + async def unsubscribe( self, subscription_id: str, method: str = "author_unwatchExtrinsic" ) -> None: @@ -3440,6 +3505,155 @@ async def runtime_call( obj = await self.decode_scale(output_type_string, result_bytes, runtime=runtime) return obj.value + async def runtime_calls( + self, + calls: list[tuple[str, str, Optional[list | dict]]], + block_hash: Optional[str] = None, + ) -> list[ScaleValue]: + """ + Calls multiple runtime API methods in a single JSON-RPC 2.0 batch request. + + This is the runtime-call analogue of `query_multi`: rather than one `state_call` + round-trip per call, every call is encoded and sent as a single websocket frame + (a JSON-RPC batch), and the responses are demuxed and decoded individually. All + calls are executed at the same `block_hash`, giving a consistent snapshot. + + Example: + + ``` + results = await substrate.runtime_calls( + [ + ("AccountNonceApi", "account_nonce", [account_id]), + ("TransactionPaymentApi", "query_fee_details", [extrinsic, length]), + ] + ) + ``` + + Note: + - Requires the RPC node to support JSON-RPC 2.0 batch requests. All + Substrate/jsonrpsee nodes do so by default. + - Only the modern (metadata v15) runtime-call path is supported. If the + runtime predates metadata v15, or any call resolves to a legacy + runtime-call definition, a `NotImplementedError` is raised — use + `runtime_call` (optionally with `asyncio.gather`) for those. + - This is not necessarily faster than gathering individual `runtime_call`s + with `asyncio.gather`: that already pipelines the requests over the shared + websocket (~1 round-trip total), and nodes typically execute the items of + a batch sequentially while running separate messages concurrently — so + `gather` is often the lower-latency choice. The advantage of batching here + is that all calls travel as a single JSON-RPC message, which helps against + endpoints that rate-limit per message, plus the consistent-snapshot and + one-message-one-reply semantics. + + Args: + calls: list of `(api, method, params)` tuples. `params` may be a list, a + dict, or `None` (same semantics as `runtime_call`). + block_hash: Hash of the block at which to make the runtime API calls. + + Returns: + list of decoded runtime-call results, in the same order as `calls`. + """ + if not calls: + return [] + + # Pin to a concrete block so every call in the batch hits the same state, + # even if the chain advances mid-request (a plain `None` would let each + # call resolve "best block" independently). + if block_hash is None: + block_hash = await self.get_chain_head() + + runtime = await self.init_runtime(block_hash=block_hash) + + if runtime.metadata_v15 is None: + raise NotImplementedError( + "runtime_calls only supports the modern (metadata v15) runtime-call " + "path. Use runtime_call for legacy runtimes." + ) + + payloads: list[dict] = [] + call_defs: list[dict] = [] + for api, method, params in calls: + if params is None: + params = {} + + try: + runtime_call_def = runtime.runtime_api_map[api][method] + except KeyError: + raise ValueError( + f"Runtime API Call '{api}.{method}' not found in registry" + ) + + if _determine_if_old_runtime_call(runtime_call_def, runtime): + raise NotImplementedError( + f"Runtime call '{api}.{method}' uses the legacy call path, which " + f"runtime_calls does not support. Use runtime_call instead." + ) + + if isinstance(params, list) and len(params) != len( + runtime_call_def["inputs"] + ): + raise ValueError( + f"Number of parameter provided ({len(params)}) does not " + f"match definition {len(runtime_call_def['inputs'])} for " + f"'{api}.{method}'" + ) + + # Encode params + param_data = b"" + for idx, param in enumerate(runtime_call_def["inputs"]): + param_type_string = f"scale_info::{param['ty']}" + if isinstance(params, list): + param_data += await self.encode_scale( + param_type_string, params[idx], runtime=runtime + ) + else: + if param["name"] not in params: + raise ValueError( + f"Runtime Call param '{param['name']}' is missing for " + f"'{api}.{method}'" + ) + param_data += await self.encode_scale( + param_type_string, params[param["name"]], runtime=runtime + ) + + payloads.append( + { + "jsonrpc": "2.0", + "method": "state_call", + "params": [f"{api}_{method}", param_data.hex(), block_hash], + } + ) + call_defs.append(runtime_call_def) + + # Send all calls as one JSON-RPC batch frame, then gather responses by id. + async with self.ws as ws: + await ws.mark_waiting_for_response() + item_ids = await ws.send_batch(payloads) + responses: dict[str, dict] = {} + pending = set(item_ids) + while pending: + for item_id in list(pending): + if (response := await ws.retrieve(item_id)) is not None: + responses[item_id] = response + pending.discard(item_id) + if pending: + await asyncio.sleep(0.01) + await ws.mark_response_received() + + # Decode each result against its own output type, preserving input order. + results: list[ScaleValue] = [] + for item_id, runtime_call_def in zip(item_ids, call_defs): + result_data = responses[item_id] + if "error" in result_data: + raise SubstrateRequestException(result_data["error"]["message"]) + output_type_string = f"scale_info::{runtime_call_def['output']}" + result_bytes = hex_to_bytes(result_data["result"]) + obj = await self.decode_scale( + output_type_string, result_bytes, runtime=runtime + ) + results.append(obj.value) + return results + async def get_account_nonce(self, account_address: str) -> int: """ Returns current nonce for given account address diff --git a/async_substrate_interface/sync_substrate.py b/async_substrate_interface/sync_substrate.py index cf86495..72a03d0 100644 --- a/async_substrate_interface/sync_substrate.py +++ b/async_substrate_interface/sync_substrate.py @@ -1855,6 +1855,62 @@ def _make_rpc_request( return request_manager.get_results() + def _make_batch_rpc_request( + self, payloads: list[dict], attempt: int = 1 + ) -> list[dict]: + """ + Sends multiple payloads as a single JSON-RPC 2.0 batch (one websocket frame + containing an array of requests) and gathers the responses, demuxed by id. + + Args: + payloads: list of JSON-RPC payload dicts, each with "jsonrpc", "method", + and "params" (without "id" — an id is assigned here per sub-request). + attempt: current attempt number, used for retry/reconnect bookkeeping. + + Returns: + list of raw response dicts, in the same order as `payloads`. + """ + ids = [get_next_id() for _ in payloads] + batch = [{**payload, "id": id_} for payload, id_ in zip(payloads, ids)] + id_set = set(ids) + results: dict[str, dict] = {} + + ws = self.connect(init=False if attempt == 1 else True) + to_send = json.dumps(batch) + if self.log_raw_websockets: + raw_websocket_logger.debug(f"WEBSOCKET_SEND> {to_send}") + ws.send(to_send) + + while len(results) < len(ids): + try: + recd = ws.recv(timeout=self.retry_timeout, decode=False) + if self.log_raw_websockets: + raw_websocket_logger.debug(f"WEBSOCKET_RECEIVE> {recd.decode()}") + response = json.loads(recd) + except (TimeoutError, ConnectionClosed): + if attempt >= self.max_retries: + logger.warning( + f"Timed out waiting for RPC requests {attempt} times. Exiting." + ) + raise MaxRetriesExceeded("Max retries reached.") + return self._make_batch_rpc_request(payloads, attempt + 1) + + if isinstance(response, list): + items = response + elif "error" in response: + # A non-array reply to a batch is almost always a top-level error. + raise SubstrateRequestException(str(response)) + else: + items = [response] + + # Demux by id; ignore any frame not part of this batch (e.g. a stray + # late subscription notification on the shared connection). + for item in items: + if item.get("id") in id_set: + results[item["id"]] = item + + return [results[id_] for id_ in ids] + @functools.lru_cache(maxsize=SUBSTRATE_CACHE_METHOD_SIZE) def supports_rpc_method(self, name: str) -> bool: """ @@ -2495,6 +2551,135 @@ def runtime_call( # protect against `None`s from decode_scale return obj.value + def runtime_calls( + self, + calls: list[tuple[str, str, Optional[list | dict]]], + block_hash: Optional[str] = None, + ) -> list[ScaleValue]: + """ + Calls multiple runtime API methods in a single JSON-RPC 2.0 batch request. + + This is the runtime-call analogue of `query_multi`: rather than one `state_call` + round-trip per call, every call is encoded and sent as a single websocket frame + (a JSON-RPC batch), and the responses are demuxed and decoded individually. All + calls are executed at the same `block_hash`, giving a consistent snapshot. + + Example: + + ``` + results = substrate.runtime_calls( + [ + ("AccountNonceApi", "account_nonce", [account_id]), + ("TransactionPaymentApi", "query_fee_details", [extrinsic, length]), + ] + ) + ``` + + Note: + - Requires the RPC node to support JSON-RPC 2.0 batch requests. All + Substrate/jsonrpsee nodes do so by default. + - Only the modern (metadata v15) runtime-call path is supported. If the + runtime predates metadata v15, or any call resolves to a legacy + runtime-call definition, a `NotImplementedError` is raised — use + `runtime_call` for those. + - The advantage of batching here is that all calls travel as a single + JSON-RPC message, which helps against endpoints that rate-limit per + message, plus the consistent-snapshot and one-message-one-reply semantics. + + Args: + calls: list of `(api, method, params)` tuples. `params` may be a list, a + dict, or `None` (same semantics as `runtime_call`). + block_hash: Hash of the block at which to make the runtime API calls. + + Returns: + list of decoded runtime-call results, in the same order as `calls`. + """ + if not calls: + return [] + + # Pin to a concrete block so every call in the batch hits the same state, + # even if the chain advances mid-request (a plain `None` would let each + # call resolve "best block" independently). + if block_hash is None: + block_hash = self.get_chain_head() + + runtime = self.init_runtime(block_hash=block_hash) + + if runtime.metadata_v15 is None: + raise NotImplementedError( + "runtime_calls only supports the modern (metadata v15) runtime-call " + "path. Use runtime_call for legacy runtimes." + ) + + payloads: list[dict] = [] + call_defs: list[dict] = [] + for api, method, params in calls: + if params is None: + params = {} + + try: + runtime_call_def = runtime.runtime_api_map[api][method] + except KeyError: + raise ValueError( + f"Runtime API Call '{api}.{method}' not found in registry" + ) + + if _determine_if_old_runtime_call(runtime_call_def, runtime): + raise NotImplementedError( + f"Runtime call '{api}.{method}' uses the legacy call path, which " + f"runtime_calls does not support. Use runtime_call instead." + ) + + if isinstance(params, list) and len(params) != len( + runtime_call_def["inputs"] + ): + raise ValueError( + f"Number of parameter provided ({len(params)}) does not " + f"match definition {len(runtime_call_def['inputs'])} for " + f"'{api}.{method}'" + ) + + # Encode params + param_data = b"" + for idx, param in enumerate(runtime_call_def["inputs"]): + param_type_string = f"scale_info::{param['ty']}" + if isinstance(params, list): + param_data += self.encode_scale( + param_type_string, params[idx], runtime=runtime + ) + else: + if param["name"] not in params: + raise ValueError( + f"Runtime Call param '{param['name']}' is missing for " + f"'{api}.{method}'" + ) + param_data += self.encode_scale( + param_type_string, params[param["name"]], runtime=runtime + ) + + payloads.append( + { + "jsonrpc": "2.0", + "method": "state_call", + "params": [f"{api}_{method}", param_data.hex(), block_hash], + } + ) + call_defs.append(runtime_call_def) + + # Send all calls as one JSON-RPC batch frame, then gather responses by id. + responses = self._make_batch_rpc_request(payloads) + + # Decode each result against its own output type, preserving input order. + results: list[ScaleValue] = [] + for result_data, runtime_call_def in zip(responses, call_defs): + if "error" in result_data: + raise SubstrateRequestException(result_data["error"]["message"]) + output_type_string = f"scale_info::{runtime_call_def['output']}" + result_bytes = hex_to_bytes(result_data["result"]) + obj = self.decode_scale(output_type_string, result_bytes) + results.append(obj.value) + return results + def get_account_nonce(self, account_address: str) -> int: """ Returns current nonce for given account address diff --git a/tests/e2e_tests/test_e2e_async.py b/tests/e2e_tests/test_e2e_async.py index d7602bb..63f0c8b 100644 --- a/tests/e2e_tests/test_e2e_async.py +++ b/tests/e2e_tests/test_e2e_async.py @@ -683,6 +683,36 @@ async def test_old_runtime_calls_natively(substrate): ] +@pytest.mark.asyncio +async def test_runtime_calls(substrate): + """Two runtime calls batched into a single JSON-RPC request return the same + results as making them individually at the same block.""" + print("Testing test_runtime_calls") + block_hash = await substrate.get_chain_finalised_head() + + calls = [ + ("SubnetInfoRuntimeApi", "get_all_dynamic_info", []), + ("SwapRuntimeApi", "current_alpha_price", [1]), + ] + + batched = await substrate.runtime_calls(calls, block_hash=block_hash) + assert len(batched) == 2 + + dynamic_info, alpha_price = batched + # get_all_dynamic_info returns a list (one entry per subnet). + assert isinstance(dynamic_info, list) + assert len(dynamic_info) > 0 + assert alpha_price is not None + + # Batched results must match the individual runtime_call results at the same block. + individual = [ + await substrate.runtime_call(api, method, params=params, block_hash=block_hash) + for api, method, params in calls + ] + assert batched == individual + print("test_runtime_calls succeeded") + + @pytest.mark.asyncio async def test_reconnection(): """ diff --git a/tests/e2e_tests/test_e2e_sync.py b/tests/e2e_tests/test_e2e_sync.py index 6b50bfd..a9ef396 100644 --- a/tests/e2e_tests/test_e2e_sync.py +++ b/tests/e2e_tests/test_e2e_sync.py @@ -617,6 +617,35 @@ def test_old_runtime_calls_natively(substrate): ] +def test_runtime_calls(substrate): + """Two runtime calls batched into a single JSON-RPC request return the same + results as making them individually at the same block.""" + print("Testing test_runtime_calls") + block_hash = substrate.get_chain_finalised_head() + + calls = [ + ("SubnetInfoRuntimeApi", "get_all_dynamic_info", []), + ("SwapRuntimeApi", "current_alpha_price", [1]), + ] + + batched = substrate.runtime_calls(calls, block_hash=block_hash) + assert len(batched) == 2 + + dynamic_info, alpha_price = batched + # get_all_dynamic_info returns a list (one entry per subnet). + assert isinstance(dynamic_info, list) + assert len(dynamic_info) > 0 + assert alpha_price is not None + + # Batched results must match the individual runtime_call results at the same block. + individual = [ + substrate.runtime_call(api, method, params=params, block_hash=block_hash) + for api, method, params in calls + ] + assert batched == individual + print("test_runtime_calls succeeded") + + def test_runtime_switching(substrate): print("Testing test_runtime_switching") block = 6067945 # block where a runtime switch happens diff --git a/tests/unit_tests/asyncio_/test_substrate_interface_async_unit.py b/tests/unit_tests/asyncio_/test_substrate_interface_async_unit.py index 30a9183..6a23cf6 100644 --- a/tests/unit_tests/asyncio_/test_substrate_interface_async_unit.py +++ b/tests/unit_tests/asyncio_/test_substrate_interface_async_unit.py @@ -83,6 +83,98 @@ async def test_runtime_call(monkeypatch): print("test_runtime_call succeeded") +@pytest.mark.asyncio +async def test_runtime_calls(): + """Multiple runtime calls are encoded, sent as one batch, and decoded in order.""" + print("Testing test_runtime_calls") + substrate = AsyncSubstrateInterface("ws://localhost", _mock=True) + + fake_runtime = MagicMock() + fake_runtime.metadata_v15 = MagicMock() # non-None so the V15 path is taken + fake_runtime.runtime_api_map = { + "Api": { + "m1": {"inputs": [{"name": "a", "ty": "3"}], "output": "1"}, + "m2": {"inputs": [{"name": "b", "ty": "4"}], "output": "2"}, + } + } + fake_runtime.type_id_to_name = {} # no Vec outputs → modern path for both + substrate.init_runtime = AsyncMock(return_value=fake_runtime) + + # block_hash=None is pinned to the chain head so the batch is a consistent snapshot. + substrate.get_chain_head = AsyncMock(return_value="0xBLOCK") + + # Each input encodes to a single byte 0xab → hex "ab". + substrate.encode_scale = AsyncMock(return_value=b"\xab") + + decoded_1, decoded_2 = MagicMock(), MagicMock() + decoded_1.value, decoded_2.value = "result_1", "result_2" + substrate.decode_scale = AsyncMock(side_effect=[decoded_1, decoded_2]) + + # Mock the websocket: send_batch hands back ids, retrieve resolves each by id. + ws_responses = {"id0": {"result": "0x00"}, "id1": {"result": "0x01"}} + ws_mock = MagicMock() + ws_mock.mark_waiting_for_response = AsyncMock() + ws_mock.mark_response_received = AsyncMock() + ws_mock.send_batch = AsyncMock(return_value=["id0", "id1"]) + ws_mock.retrieve = AsyncMock(side_effect=lambda item_id: ws_responses[item_id]) + substrate.ws = MagicMock() + substrate.ws.__aenter__ = AsyncMock(return_value=ws_mock) + substrate.ws.__aexit__ = AsyncMock(return_value=False) + + results = await substrate.runtime_calls( + [ + ("Api", "m1", ["foo"]), + ("Api", "m2", {"b": "bar"}), + ] + ) + + assert results == ["result_1", "result_2"] + + # One batch frame carrying both state_call payloads, pinned to the same block. + ws_mock.send_batch.assert_awaited_once_with( + [ + { + "jsonrpc": "2.0", + "method": "state_call", + "params": ["Api_m1", "ab", "0xBLOCK"], + }, + { + "jsonrpc": "2.0", + "method": "state_call", + "params": ["Api_m2", "ab", "0xBLOCK"], + }, + ] + ) + + # Results decoded against each call's own output type, in input order. + substrate.decode_scale.assert_any_call("scale_info::1", b"\x00", runtime=ANY) + substrate.decode_scale.assert_any_call("scale_info::2", b"\x01", runtime=ANY) + print("test_runtime_calls succeeded") + + +@pytest.mark.asyncio +async def test_runtime_calls_unknown_method_raises(): + """An unknown api.method surfaces a ValueError before anything is sent.""" + substrate = AsyncSubstrateInterface("ws://localhost", _mock=True) + fake_runtime = MagicMock() + fake_runtime.metadata_v15 = MagicMock() + fake_runtime.runtime_api_map = {"Api": {}} + fake_runtime.type_id_to_name = {} + substrate.init_runtime = AsyncMock(return_value=fake_runtime) + + with pytest.raises(ValueError, match="not found in registry"): + await substrate.runtime_calls([("Api", "missing", None)]) + + +@pytest.mark.asyncio +async def test_runtime_calls_empty_returns_empty(): + """No calls means no request and an empty result list.""" + substrate = AsyncSubstrateInterface("ws://localhost", _mock=True) + substrate.init_runtime = AsyncMock() + assert await substrate.runtime_calls([]) == [] + substrate.init_runtime.assert_not_awaited() + + @pytest.mark.asyncio async def test_async_query_map_result_retrieve_all_records(): """Test that retrieve_all_records fetches all pages and returns the full record list.""" diff --git a/tests/unit_tests/sync/test_substrate_interface_sync_unit.py b/tests/unit_tests/sync/test_substrate_interface_sync_unit.py index 9efc1cc..4a82516 100644 --- a/tests/unit_tests/sync/test_substrate_interface_sync_unit.py +++ b/tests/unit_tests/sync/test_substrate_interface_sync_unit.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock +import pytest from scalecodec.types import GenericCall from async_substrate_interface.sync_substrate import ( @@ -62,6 +63,93 @@ def test_runtime_call(monkeypatch): print("test_runtime_call succeeded") +def test_runtime_calls(): + """Multiple runtime calls are encoded, sent as one batch, and decoded in order.""" + print("Testing test_runtime_calls") + substrate = SubstrateInterface("ws://localhost", _mock=True) + + fake_runtime = MagicMock() + fake_runtime.metadata_v15 = MagicMock() # non-None so the V15 path is taken + fake_runtime.runtime_api_map = { + "Api": { + "m1": {"inputs": [{"name": "a", "ty": "3"}], "output": "1"}, + "m2": {"inputs": [{"name": "b", "ty": "4"}], "output": "2"}, + } + } + fake_runtime.type_id_to_name = {} # no Vec outputs → modern path for both + substrate.init_runtime = MagicMock(return_value=fake_runtime) + + # block_hash=None is pinned to the chain head so the batch is a consistent snapshot. + substrate.get_chain_head = MagicMock(return_value="0xBLOCK") + + # Each input encodes to a single byte 0xab → hex "ab". + substrate.encode_scale = MagicMock(return_value=b"\xab") + + decoded_1, decoded_2 = MagicMock(), MagicMock() + decoded_1.value, decoded_2.value = "result_1", "result_2" + substrate.decode_scale = MagicMock(side_effect=[decoded_1, decoded_2]) + + # The batch transport is mocked: it returns the raw responses in payload order. + substrate._make_batch_rpc_request = MagicMock( + return_value=[{"result": "0x00"}, {"result": "0x01"}] + ) + + results = substrate.runtime_calls( + [ + ("Api", "m1", ["foo"]), + ("Api", "m2", {"b": "bar"}), + ] + ) + + assert results == ["result_1", "result_2"] + + # One batch carrying both state_call payloads, pinned to the same block. + substrate._make_batch_rpc_request.assert_called_once_with( + [ + { + "jsonrpc": "2.0", + "method": "state_call", + "params": ["Api_m1", "ab", "0xBLOCK"], + }, + { + "jsonrpc": "2.0", + "method": "state_call", + "params": ["Api_m2", "ab", "0xBLOCK"], + }, + ] + ) + + # Results decoded against each call's own output type, in input order. + substrate.decode_scale.assert_any_call("scale_info::1", b"\x00") + substrate.decode_scale.assert_any_call("scale_info::2", b"\x01") + substrate.close() + print("test_runtime_calls succeeded") + + +def test_runtime_calls_unknown_method_raises(): + """An unknown api.method surfaces a ValueError before anything is sent.""" + substrate = SubstrateInterface("ws://localhost", _mock=True) + fake_runtime = MagicMock() + fake_runtime.metadata_v15 = MagicMock() + fake_runtime.runtime_api_map = {"Api": {}} + fake_runtime.type_id_to_name = {} + substrate.init_runtime = MagicMock(return_value=fake_runtime) + substrate.get_chain_head = MagicMock(return_value="0xBLOCK") + + with pytest.raises(ValueError, match="not found in registry"): + substrate.runtime_calls([("Api", "missing", None)]) + substrate.close() + + +def test_runtime_calls_empty_returns_empty(): + """No calls means no request and an empty result list.""" + substrate = SubstrateInterface("ws://localhost", _mock=True) + substrate.init_runtime = MagicMock() + assert substrate.runtime_calls([]) == [] + substrate.init_runtime.assert_not_called() + substrate.close() + + def test_async_query_map_result_retrieve_all_records(): """Test that retrieve_all_records fetches all pages and returns the full record list.""" page1 = [("key1", "val1"), ("key2", "val2")] From 3f685da1dd0d2acbfa6d0dc926df4cedf2590288 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Mon, 8 Jun 2026 20:02:17 +0200 Subject: [PATCH 2/8] Changes `asyncio.iscoroutinefunction` to `inspect.iscoroutinefunction`, as `asyncio.iscoroutinefunction` is now deprecated. --- async_substrate_interface/async_substrate.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 9a923d8..f5916d6 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -2579,7 +2579,7 @@ async def _process_response( q = query_value decoded = await self.decode_scale(value_scale_type, q, runtime=runtime) result = decoded - if asyncio.iscoroutinefunction(result_handler): + if inspect.iscoroutinefunction(result_handler): # For multipart responses as a result of subscriptions. message, bool_result = await result_handler(result, subscription_id) # type: ignore[arg-type] return message, bool_result @@ -2637,11 +2637,11 @@ async def _make_rpc_request( for item_id in request_manager.unresponded(): if ( item_id not in request_manager.responses - or asyncio.iscoroutinefunction(result_handler) + or inspect.iscoroutinefunction(result_handler) ): if response := await ws.retrieve(item_id): if ( - asyncio.iscoroutinefunction(result_handler) + inspect.iscoroutinefunction(result_handler) and not subscription_added ): # handles subscriptions, overwrites the previous mapping of {item_id : payload_id} @@ -2670,7 +2670,7 @@ async def _make_rpc_request( ) if ( result_processor is not None - and not asyncio.iscoroutinefunction(result_handler) + and not inspect.iscoroutinefunction(result_handler) ): decoded_response = result_processor( decoded_response, item_id From 62666f84231a7277b927ba501f6b3ffaac6f5be0 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Tue, 9 Jun 2026 10:39:22 +0200 Subject: [PATCH 3/8] Add `StorageKey.create_from_storage_function_batch` method to batch create storage keys --- async_substrate_interface/utils/storage.py | 170 +++++++++++++++++---- pyproject.toml | 1 + uv.lock | 4 +- 3 files changed, 146 insertions(+), 29 deletions(-) diff --git a/async_substrate_interface/utils/storage.py b/async_substrate_interface/utils/storage.py index bc689c5..4fd1ef9 100644 --- a/async_substrate_interface/utils/storage.py +++ b/async_substrate_interface/utils/storage.py @@ -14,6 +14,23 @@ identity, ) +try: + from typing import Self +except ImportError: + # fallback to typing_extensions if Python < 3.11 + from typing_extensions import Self + +# Single source of truth mapping a metadata hasher name to its implementation. +# `None`/empty hasher defaults to "Twox128" (matches substrate behaviour). +PARAM_HASHERS = { + "Blake2_256": blake2_256, + "Blake2_128": blake2_128, + "Blake2_128Concat": blake2_128_concat, + "Twox128": xxh128, + "Twox64Concat": two_x64_concat, + "Identity": identity, +} + class StorageKey: """ @@ -52,7 +69,7 @@ def create_from_data( value_scale_type: Optional[str] = None, pallet: Optional[str] = None, storage_function: Optional[str] = None, - ) -> "StorageKey": + ) -> Self: """ Create a StorageKey instance providing raw storage key bytes @@ -101,7 +118,7 @@ def create_from_storage_function( params: list, runtime_config: RuntimeConfigurationObject, metadata: GenericMetadataVersioned, - ) -> "StorageKey": + ) -> Self: """ Create a StorageKey instance providing storage function details @@ -129,18 +146,132 @@ def create_from_storage_function( return storage_key_obj - def convert_storage_parameter(self, scale_type: str, value: Any): + @classmethod + def create_from_storage_function_batch( + cls, + pallet: str, + storage_function: str, + params_list: list[list], + runtime_config: RuntimeConfigurationObject, + metadata: GenericMetadataVersioned, + ) -> list[Self]: + """ + Create many StorageKey instances for the same pallet/storage_function in + one pass, one per entry in ``params_list``. + + This is much faster than calling :meth:`create_from_storage_function` + in a loop: everything that is constant across the keys (metadata + resolution, the pallet/storage-function prefix hash, and the scale + objects used to encode params) is computed once and reused. For large + batches (e.g. 100k keys) this is ~30x faster while producing + byte-identical keys. + + Args: + pallet: name of pallet + storage_function: name of storage function + params_list: list of parameter lists, one per storage key to create + runtime_config: RuntimeConfigurationObject + metadata: GenericMetadataVersioned + + Returns: + list of StorageKey, in the same order as ``params_list`` + """ + # --- Resolve everything that is constant across the batch, once. --- + metadata_pallet = metadata.get_metadata_pallet(pallet) + if not metadata_pallet: + raise StorageFunctionNotFound(f'Pallet "{pallet}" not found') + + metadata_storage_function = metadata_pallet.get_storage_function( + storage_function + ) + if not metadata_storage_function: + raise StorageFunctionNotFound( + f'Storage function "{pallet}.{storage_function}" not found' + ) + + value_scale_type = metadata_storage_function.get_value_type_string() + param_types = metadata_storage_function.get_params_type_string() + hashers = metadata_storage_function.get_param_hashers() + + # Immutable bytes: each key does `storage_hash = prefix` then `+=`, which + # must allocate a new object rather than mutate this shared prefix. xxh128 + # returns a bytearray, so wrap it to prevent in-place accumulation. + prefix = bytes( + xxh128(metadata_pallet.value["storage"]["prefix"].encode()) + + xxh128(storage_function.encode()) + ) + + n_params = len(param_types) + + # One reusable scale object and one resolved hasher fn per param position. + scale_objects = [ + runtime_config.create_scale_object(type_string=param_types[idx]) + for idx in range(n_params) + ] + hasher_fns = [] + for idx in range(n_params): + param_hasher = hashers[idx] if idx < len(hashers) else None + try: + hasher_fns.append(PARAM_HASHERS[param_hasher or "Twox128"]) + except KeyError: + raise ValueError('Unknown storage hasher "{}"'.format(param_hasher)) + + ss58_format = runtime_config.ss58_format + + # --- Per-key work only. --- + storage_keys: list[Self] = [] + for params in params_list: + storage_hash = prefix + params_encoded: list[Any] = [] + for idx, param in enumerate(params): + if type(param) is ScaleBytes: + # Already encoded + encoded = param + params_key = param.data + else: + param = cls._convert_storage_parameter( + param_types[idx], param, ss58_format + ) + encoded = scale_objects[idx].encode(param) + params_key = encoded.data + params_encoded.append(encoded) + storage_hash += hasher_fns[idx](params_key) + + storage_key_obj = cls( + pallet=pallet, + storage_function=storage_function, + params=params, + data=None, + runtime_config=runtime_config, + metadata=metadata, + value_scale_type=value_scale_type, + ) + # Mirror generate(): the hash is assigned onto self.data directly. + storage_key_obj.data = storage_hash + storage_key_obj.metadata_storage_function = metadata_storage_function + storage_key_obj.params_encoded = params_encoded + storage_keys.append(storage_key_obj) + + return storage_keys + + @staticmethod + def _convert_storage_parameter( + scale_type: str, value: Any, ss58_format: Optional[int] + ): if type(value) is bytes: value = f"0x{value.hex()}" if scale_type == "AccountId": if value[0:2] != "0x": - return "0x{}".format( - ss58_decode(value, self.runtime_config.ss58_format) - ) + return "0x{}".format(ss58_decode(value, ss58_format)) return value + def convert_storage_parameter(self, scale_type: str, value: Any): + return self._convert_storage_parameter( + scale_type, value, self.runtime_config.ss58_format + ) + def to_hex(self) -> Optional[str]: """ Returns a Hex-string representation of current StorageKey data @@ -216,30 +347,13 @@ def generate(self) -> bytes: assert param.data is not None params_key += param.data.data - if not param_hasher: - param_hasher = "Twox128" - - if param_hasher == "Blake2_256": - storage_hash += blake2_256(params_key) - - elif param_hasher == "Blake2_128": - storage_hash += blake2_128(params_key) - - elif param_hasher == "Blake2_128Concat": - storage_hash += blake2_128_concat(params_key) - - elif param_hasher == "Twox128": - storage_hash += xxh128(params_key) - - elif param_hasher == "Twox64Concat": - storage_hash += two_x64_concat(params_key) - - elif param_hasher == "Identity": - storage_hash += identity(params_key) - - else: + try: + hasher_fn = PARAM_HASHERS[param_hasher or "Twox128"] + except KeyError: raise ValueError('Unknown storage hasher "{}"'.format(param_hasher)) + storage_hash += hasher_fn(params_key) + self.data = storage_hash return self.data diff --git a/pyproject.toml b/pyproject.toml index 395c34d..f2b6713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "wheel>0.46.1", "aiosqlite>=0.21.0,<1.0.0", "cyscale>=0.3.3,<1.0.0", + "typing_extensions>= 4.0.0; python_version<'3.11'", "websockets>=14.1", "xxhash", ] diff --git a/uv.lock b/uv.lock index 0e9b040..20c0661 100644 --- a/uv.lock +++ b/uv.lock @@ -17,11 +17,12 @@ wheels = [ [[package]] name = "async-substrate-interface" -version = "2.0.3" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "cyscale" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "websockets" }, { name = "wheel" }, { name = "xxhash" }, @@ -55,6 +56,7 @@ requires-dist = [ { name = "pytest-split", marker = "extra == 'dev'", specifier = "==0.11.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0.0" }, { name = "websockets", specifier = ">=14.1" }, { name = "wheel", specifier = ">0.46.1" }, { name = "xxhash" }, From 4d6fb743f6c60b4968e56e7e8ed4fd339db97ad5 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Tue, 9 Jun 2026 10:39:40 +0200 Subject: [PATCH 4/8] Add benchmark --- benchmarks/bench_storage_keys.py | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 benchmarks/bench_storage_keys.py diff --git a/benchmarks/bench_storage_keys.py b/benchmarks/bench_storage_keys.py new file mode 100644 index 0000000..d96d15c --- /dev/null +++ b/benchmarks/bench_storage_keys.py @@ -0,0 +1,67 @@ +""" +Benchmark: current per-key StorageKey.create_from_storage_function vs the new +StorageKey.create_from_storage_function_batch, building 100k keys. + +System.Account: AccountId -> AccountInfo (Blake2_128Concat), the common +real-world bulk-key case. Verifies full-batch byte-for-byte parity, then times. +""" + +import time + +from async_substrate_interface.sync_substrate import SubstrateInterface +from async_substrate_interface.utils.storage import StorageKey +from tests.helpers.settings import LATENT_LITE_ENTRYPOINT + +N = 100_000 + + +def main(): + sub = SubstrateInterface(LATENT_LITE_ENTRYPOINT, ss58_format=42) + sub.initialize() + runtime = sub.init_runtime() + rc = runtime.runtime_config + md = runtime.metadata + + pallet, storage_function = "System", "Account" + base = int.from_bytes( + bytes.fromhex( + "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" + ), + "big", + ) + params_list = [ + ["0x" + (base ^ i).to_bytes(32, "big").hex()] for i in range(N) + ] + + # Full-batch correctness: every batch key must equal its per-key counterpart. + batch = StorageKey.create_from_storage_function_batch( + pallet, storage_function, params_list, runtime_config=rc, metadata=md + ) + for i in (0, 1, 7, N // 2, N - 1): + ref = StorageKey.create_from_storage_function( + pallet, storage_function, params_list[i], runtime_config=rc, metadata=md + ) + assert batch[i].to_hex() == ref.to_hex(), f"mismatch at {i}" + print(f"correctness OK — sampled {5} of {N} batch keys match per-key\n") + + t0 = time.perf_counter() + for p in params_list: + StorageKey.create_from_storage_function( + pallet, storage_function, p, runtime_config=rc, metadata=md + ) + dt_cur = time.perf_counter() - t0 + print(f"current per-key: {dt_cur:6.2f}s ({N / dt_cur:>9,.0f} keys/s)") + + t0 = time.perf_counter() + StorageKey.create_from_storage_function_batch( + pallet, storage_function, params_list, runtime_config=rc, metadata=md + ) + dt_batch = time.perf_counter() - t0 + print(f"batch method: {dt_batch:6.2f}s ({N / dt_batch:>9,.0f} keys/s)") + + print(f"\nspeedup: {dt_cur / dt_batch:.1f}x") + sub.close() + + +if __name__ == "__main__": + main() From 7dee502c15183c1056e0ad0a03e9c5a30d992b08 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Tue, 9 Jun 2026 10:39:47 +0200 Subject: [PATCH 5/8] Add test --- tests/unit_tests/test_storage_key_batch.py | 133 +++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/unit_tests/test_storage_key_batch.py diff --git a/tests/unit_tests/test_storage_key_batch.py b/tests/unit_tests/test_storage_key_batch.py new file mode 100644 index 0000000..8aa1c79 --- /dev/null +++ b/tests/unit_tests/test_storage_key_batch.py @@ -0,0 +1,133 @@ +""" +Parity tests for StorageKey.create_from_storage_function_batch. + +The batch builder must produce byte-identical storage keys to calling +create_from_storage_function one-by-one, while resolving metadata and the +prefix hash only once. Metadata is loaded offline from the node-template +fixture, so these tests need no network. +""" + +import unittest + +from scalecodec import ScaleBytes + +from async_substrate_interface.errors import StorageFunctionNotFound +from async_substrate_interface.sync_substrate import SubstrateInterface +from async_substrate_interface.utils.storage import StorageKey +from tests.helpers.fixtures import metadata_node_template_hex + + +class StorageKeyBatchTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.substrate = SubstrateInterface( + url="dummy", + ss58_format=42, + type_registry_preset="substrate-node-template", + _mock=True, + ) + cls.runtime_config = cls.substrate.runtime_config + metadata = cls.substrate.runtime_config.create_scale_object( + "MetadataVersioned", ScaleBytes(metadata_node_template_hex) + ) + metadata.decode() + cls.metadata = metadata + + def _per_key(self, pallet, sf, params): + return StorageKey.create_from_storage_function( + pallet, sf, params, + runtime_config=self.runtime_config, + metadata=self.metadata, + ) + + def _batch(self, pallet, sf, params_list): + return StorageKey.create_from_storage_function_batch( + pallet, sf, params_list, + runtime_config=self.runtime_config, + metadata=self.metadata, + ) + + def _assert_parity(self, pallet, sf, params_list): + batched = self._batch(pallet, sf, params_list) + self.assertEqual(len(batched), len(params_list)) + for params, batch_key in zip(params_list, batched): + ref = self._per_key(pallet, sf, params) + self.assertEqual( + batch_key.to_hex(), + ref.to_hex(), + msg=f"{pallet}.{sf} params={params}", + ) + # The batch object must carry the same derived attributes the + # per-key path sets, so downstream decoding behaves identically. + self.assertEqual(batch_key.value_scale_type, ref.value_scale_type) + self.assertIsNotNone(batch_key.metadata_storage_function) + assert batch_key.metadata_storage_function is not None + assert ref.metadata_storage_function is not None + self.assertEqual( + batch_key.metadata_storage_function.value, + ref.metadata_storage_function.value, + ) + + # --- AccountId key: exercises ss58 -> 0x conversion (Blake2_128Concat) --- + def test_account_id_ss58_params(self): + addrs = [ + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy", + "5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY", + ] + self._assert_parity("System", "Account", [[a] for a in addrs]) + + # --- AccountId key supplied as raw 0x hex (no ss58 decode) --- + def test_account_id_hex_params(self): + hexes = [ + "0x" + "11" * 32, + "0x" + "ab" * 32, + "0x" + "00" * 32, + ] + self._assert_parity("Balances", "Account", [[h] for h in hexes]) + + # --- Integer key with a different hasher (Twox64Concat) --- + def test_integer_key_twox64(self): + self._assert_parity( + "System", "BlockHash", [[n] for n in (0, 1, 42, 999, 2**31)] + ) + + # --- Storage function with no params (plain value) --- + def test_no_param_storage_function(self): + self._assert_parity("Timestamp", "Now", [[]]) + + # --- Pre-encoded ScaleBytes param passes through unchanged --- + def test_scalebytes_param_passthrough(self): + obj = self.runtime_config.create_scale_object(type_string="BlockNumber") + encoded = obj.encode(7) + self._assert_parity("System", "BlockHash", [[encoded]]) + + # --- Large batch stays correct (and is the perf path) --- + def test_large_batch_parity_sample(self): + params_list = [[n] for n in range(5000)] + batched = self._batch("System", "BlockHash", params_list) + self.assertEqual(len(batched), 5000) + # Spot-check a sample against the per-key reference. + for i in (0, 1, 2500, 4999): + self.assertEqual( + batched[i].to_hex(), + self._per_key("System", "BlockHash", [i]).to_hex(), + ) + + # --- Empty input yields empty output --- + def test_empty_params_list(self): + self.assertEqual(self._batch("System", "BlockHash", []), []) + + # --- Error handling matches the per-key path --- + def test_unknown_pallet_raises(self): + with self.assertRaises(StorageFunctionNotFound): + self._batch("NotAPallet", "Whatever", [[0]]) + + def test_unknown_storage_function_raises(self): + with self.assertRaises(StorageFunctionNotFound): + self._batch("System", "NotAStorageFunction", [[0]]) + + +if __name__ == "__main__": + unittest.main() From d842ff2c056bed2bea546f885101cd055af97e5e Mon Sep 17 00:00:00 2001 From: BD Himes Date: Tue, 9 Jun 2026 10:54:54 +0200 Subject: [PATCH 6/8] Add methods to (Async)SubstrateInterface --- async_substrate_interface/async_substrate.py | 42 +++++++++++++++++++ async_substrate_interface/sync_substrate.py | 44 +++++++++++++++++++- benchmarks/bench_storage_keys.py | 4 +- tests/unit_tests/test_storage_key_batch.py | 8 +++- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index 60aa826..152b039 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -1702,6 +1702,48 @@ async def create_storage_key( metadata=runtime.metadata, ) + async def create_storage_keys( + self, + pallet: str, + storage_function: str, + params: list[list], + block_hash: Optional[str] = None, + ) -> list[StorageKey]: + """ + Creates a batch of storage keys with the same pallet/storage function, but with differing params. + + Args: + pallet: name of pallet + storage_function: name of storage function + params: list of lists of parameters in case of a Mapped storage function + block_hash: the hash of the blockchain block whose runtime to use + + Example: + + ``` + storage_keys = await substrate.create_storage_keys( + pallet="Balances", + storage_function="Account", + params=[ + ["5gkods..."], + ["5jkgji..."], + ["5kdfni..."], + ], + block_hash="0xj9d3...", + ``` + + Returns: + list of StorageKeys + """ + runtime = await self.init_runtime(block_hash=block_hash) + return StorageKey.create_from_storage_function_batch( + pallet, + storage_function, + params, + runtime_config=runtime.runtime_config, + metadata=runtime.metadata, + ) + async def subscribe_storage( self, storage_keys: list[StorageKey], diff --git a/async_substrate_interface/sync_substrate.py b/async_substrate_interface/sync_substrate.py index 72a03d0..5a464bf 100644 --- a/async_substrate_interface/sync_substrate.py +++ b/async_substrate_interface/sync_substrate.py @@ -884,7 +884,49 @@ def create_storage_key( pallet, storage_function, params or [], - runtime_config=self.runtime_config, + runtime_config=runtime.runtime_config, + metadata=runtime.metadata, + ) + + def create_storage_keys( + self, + pallet: str, + storage_function: str, + params: list[list], + block_hash: Optional[str] = None, + ) -> list[StorageKey]: + """ + Creates a batch of storage keys with the same pallet/storage function, but with differing params. + + Args: + pallet: name of pallet + storage_function: name of storage function + params: list of lists of parameters in case of a Mapped storage function + block_hash: the hash of the blockchain block whose runtime to use + + Example: + + ``` + storage_keys = substrate.create_storage_keys( + pallet="Balances", + storage_function="Account", + params=[ + ["5gkods..."], + ["5jkgji..."], + ["5kdfni..."], + ], + block_hash="0xj9d3...", + ``` + + Returns: + list of StorageKeys + """ + runtime = self.init_runtime(block_hash=block_hash) + return StorageKey.create_from_storage_function_batch( + pallet, + storage_function, + params, + runtime_config=runtime.runtime_config, metadata=runtime.metadata, ) diff --git a/benchmarks/bench_storage_keys.py b/benchmarks/bench_storage_keys.py index d96d15c..809e5e5 100644 --- a/benchmarks/bench_storage_keys.py +++ b/benchmarks/bench_storage_keys.py @@ -29,9 +29,7 @@ def main(): ), "big", ) - params_list = [ - ["0x" + (base ^ i).to_bytes(32, "big").hex()] for i in range(N) - ] + params_list = [["0x" + (base ^ i).to_bytes(32, "big").hex()] for i in range(N)] # Full-batch correctness: every batch key must equal its per-key counterpart. batch = StorageKey.create_from_storage_function_batch( diff --git a/tests/unit_tests/test_storage_key_batch.py b/tests/unit_tests/test_storage_key_batch.py index 8aa1c79..9b64fa0 100644 --- a/tests/unit_tests/test_storage_key_batch.py +++ b/tests/unit_tests/test_storage_key_batch.py @@ -35,14 +35,18 @@ def setUpClass(cls): def _per_key(self, pallet, sf, params): return StorageKey.create_from_storage_function( - pallet, sf, params, + pallet, + sf, + params, runtime_config=self.runtime_config, metadata=self.metadata, ) def _batch(self, pallet, sf, params_list): return StorageKey.create_from_storage_function_batch( - pallet, sf, params_list, + pallet, + sf, + params_list, runtime_config=self.runtime_config, metadata=self.metadata, ) From 716d72f9759cddc562a43dfa1239e353b2174b91 Mon Sep 17 00:00:00 2001 From: Nikolas Kilian Date: Thu, 11 Jun 2026 10:57:14 +0200 Subject: [PATCH 7/8] Merge pull request #364 from kilyanni/pyproj-fix fix(pyproject): drop unused wheel dep, update setuptools --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f2b6713..5d4ba15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ license = { file = "LICENSE" } keywords = ["substrate", "development", "bittensor"] dependencies = [ - "wheel>0.46.1", "aiosqlite>=0.21.0,<1.0.0", "cyscale>=0.3.3,<1.0.0", "typing_extensions>= 4.0.0; python_version<'3.11'", @@ -59,7 +58,7 @@ asyncio_default_fixture_loop_scope = "module" ignore_missing_imports = true [build-system] -requires = ["setuptools>=70.0", "wheel"] +requires = ["setuptools>=78.1.1", "wheel"] build-backend = "setuptools.build_meta" [project.optional-dependencies] From 8af3ab44bedbd539e323b0f4acfd952ed390ef16 Mon Sep 17 00:00:00 2001 From: BD Himes <37844818+thewhaleking@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:03:10 +0200 Subject: [PATCH 8/8] Changelog + version (#365) --- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b76296..01a1fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2.2.0 /2026-06-11 + +## What's Changed + +* iscoroutinefunction deprecation by @thewhaleking in https://github.com/latent-to/async-substrate-interface/pull/362 +* New Method: `runtime_calls` by @thewhaleking in https://github.com/latent-to/async-substrate-interface/pull/361 +* Batch StorageKey creation by @thewhaleking in https://github.com/latent-to/async-substrate-interface/pull/363 +* fix(pyproject): drop unused wheel dep, update setuptools by @kilyanni + in https://github.com/latent-to/async-substrate-interface/pull/364 + +## New Contributors + +* @kilyanni made their first contribution in https://github.com/latent-to/async-substrate-interface/pull/364 + +**Full Changelog**: https://github.com/latent-to/async-substrate-interface/compare/v2.1.0...v2.2.0 + ## 2.1.0 /2026-06-01 ## What's Changed diff --git a/pyproject.toml b/pyproject.toml index 5d4ba15..84c1747 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "async-substrate-interface" -version = "2.1.0" +version = "2.2.0" description = "Asyncio library for interacting with substrate. Mostly API-compatible with py-substrate-interface" readme = "README.md" license = { file = "LICENSE" }