Skip to content

Commit acb24dd

Browse files
authoredFeb 20, 2025
Merge pull request #55 from opentensor/release/1.0.2
Release/1.0.2
2 parents 84afbda + c030314 commit acb24dd

File tree

7 files changed

+197
-34
lines changed

7 files changed

+197
-34
lines changed
 

‎CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Changelog
22

3+
## 1.0.2 /2025-02-19
4+
5+
## What's Changed
6+
* Closes the connection on the object being garbage-collected by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/51
7+
* Generate UIDs for websockets by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/50
8+
* Dynamically pulls the info for Vec<AccountId> from the metadata by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/47
9+
* Fix readme by @igorsyl in https://github.com/opentensor/async-substrate-interface/pull/46
10+
* Handle options with bt-decode by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/52
11+
* Backmerge main to staging 101 by @ibraheem-opentensor in https://github.com/opentensor/async-substrate-interface/pull/53
12+
* Handles None change_data by @ibraheem-opentensor in https://github.com/opentensor/async-substrate-interface/pull/54
13+
14+
## New Contributors
15+
* @igorsyl made their first contribution in https://github.com/opentensor/async-substrate-interface/pull/46
16+
17+
**Full Changelog**: https://github.com/opentensor/async-substrate-interface/compare/v1.0.1...v1.0.2
18+
19+
## 1.0.1 /2025-02-17
20+
321
## What's Changed
422
* Updates type for vec acc id by @ibraheem-opentensor in https://github.com/opentensor/async-substrate-interface/pull/45
523
* Backmerge main staging 101 by @ibraheem-opentensor in https://github.com/opentensor/async-substrate-interface/pull/48

‎README.md

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,67 @@
1-
This is a modernised version of the py-substrate-interface library, with the ability to use it asynchronously (as well as synchronously). It aims to be almost fully API-compatible with the original library.
1+
# Async Substrate Interface
2+
This project provides an asynchronous interface for interacting with [Substrate](https://substrate.io/)-based blockchains. It is based on the [py-substrate-interface](https://github.com/polkascan/py-substrate-interface) project.
23

3-
In addition to it's async nature, it is additionally improved with using bt-decode rather than py-scale-codec for significantly faster SCALE decoding.
4+
Additionally, this project uses [bt-decode](https://github.com/opentensor/bt-decode) instead of [py-scale-codec](https://github.com/polkascan/py-scale-codec) for faster [SCALE](https://docs.substrate.io/reference/scale-codec/) decoding.
5+
6+
## Installation
7+
8+
To install the package, use the following command:
9+
10+
```bash
11+
pip install async-substrate-interface
12+
```
13+
14+
## Usage
15+
16+
Here are examples of how to use the sync and async inferfaces:
17+
18+
```python
19+
from async_substrate_interface import SubstrateInterface
20+
21+
def main():
22+
substrate = SubstrateInterface(
23+
url="wss://rpc.polkadot.io"
24+
)
25+
with substrate:
26+
result = substrate.query(
27+
module='System',
28+
storage_function='Account',
29+
params=['5CZs3T15Ky4jch1sUpSFwkUbYEnsCfe1WCY51fH3SPV6NFnf']
30+
)
31+
32+
print(result)
33+
34+
main()
35+
```
36+
37+
```python
38+
import asyncio
39+
from async_substrate_interface import AsyncSubstrateInterface
40+
41+
async def main():
42+
substrate = AsyncSubstrateInterface(
43+
url="wss://rpc.polkadot.io"
44+
)
45+
async with substrate:
46+
result = await substrate.query(
47+
module='System',
48+
storage_function='Account',
49+
params=['5CZs3T15Ky4jch1sUpSFwkUbYEnsCfe1WCY51fH3SPV6NFnf']
50+
)
51+
52+
print(result)
53+
54+
asyncio.run(main())
55+
```
56+
57+
## Contributing
58+
59+
Contributions are welcome! Please open an issue or submit a pull request to the `staging` branch.
60+
61+
## License
62+
63+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
64+
65+
## Contact
66+
67+
For any questions or inquiries, please join the Bittensor Development Discord server: [Church of Rao](https://discord.gg/XC7ucQmq2Q).

‎async_substrate_interface/async_substrate.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
SubstrateMixin,
4949
Preprocessed,
5050
)
51-
from async_substrate_interface.utils import hex_to_bytes, json
51+
from async_substrate_interface.utils import hex_to_bytes, json, generate_unique_id
5252
from async_substrate_interface.utils.decoding import (
5353
_determine_if_old_runtime_call,
5454
_bt_decode_to_dict_or_list,
@@ -507,7 +507,6 @@ def __init__(
507507
# TODO reconnection logic
508508
self.ws_url = ws_url
509509
self.ws: Optional["ClientConnection"] = None
510-
self.id = 0
511510
self.max_subscriptions = max_subscriptions
512511
self.max_connections = max_connections
513512
self.shutdown_timer = shutdown_timer
@@ -543,8 +542,6 @@ async def connect(self, force=False):
543542
connect(self.ws_url, **self._options), timeout=10
544543
)
545544
self._receiving_task = asyncio.create_task(self._start_receiving())
546-
if force:
547-
self.id = 100
548545

549546
async def __aexit__(self, exc_type, exc_val, exc_tb):
550547
async with self._lock: # TODO is this actually what I want to happen?
@@ -556,7 +553,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
556553
except asyncio.CancelledError:
557554
pass
558555
if self._in_use == 0 and self.ws is not None:
559-
self.id = 0
560556
self._open_subscriptions = 0
561557
self._exit_task = asyncio.create_task(self._exit_with_timer())
562558

@@ -582,7 +578,6 @@ async def shutdown(self):
582578
self.ws = None
583579
self._initialized = False
584580
self._receiving_task = None
585-
self.id = 0
586581

587582
async def _recv(self) -> None:
588583
try:
@@ -625,8 +620,7 @@ async def send(self, payload: dict) -> int:
625620
id: the internal ID of the request (incremented int)
626621
"""
627622
# async with self._lock:
628-
original_id = self.id
629-
self.id += 1
623+
original_id = generate_unique_id(json.dumps(payload))
630624
# self._open_subscriptions += 1
631625
try:
632626
await self.ws.send(json.dumps({**payload, **{"id": original_id}}))
@@ -719,6 +713,8 @@ def __init__(
719713
self.metadata_version_hex = "0x0f000000" # v15
720714
self.reload_type_registry()
721715
self._initializing = False
716+
self.registry_type_map = {}
717+
self.type_id_to_name = {}
722718

723719
async def __aenter__(self):
724720
await self.initialize()
@@ -735,8 +731,9 @@ async def initialize(self):
735731
chain = await self.rpc_request("system_chain", [])
736732
self._chain = chain.get("result")
737733
init_load = await asyncio.gather(
738-
self.load_registry(), self._first_initialize_runtime(),
739-
return_exceptions=True
734+
self.load_registry(),
735+
self._first_initialize_runtime(),
736+
return_exceptions=True,
740737
)
741738
for potential_exception in init_load:
742739
if isinstance(potential_exception, Exception):
@@ -812,6 +809,7 @@ async def load_registry(self):
812809
metadata_option_bytes
813810
)
814811
self.registry = PortableRegistry.from_metadata_v15(self.metadata_v15)
812+
self._load_registry_type_map()
815813

816814
async def _load_registry_at_block(self, block_hash: str) -> MetadataV15:
817815
# Should be called for any block that fails decoding.
@@ -894,15 +892,14 @@ async def decode_scale(
894892
Returns:
895893
Decoded object
896894
"""
897-
if scale_bytes == b"\x00":
898-
obj = None
895+
if scale_bytes == b"":
896+
return None
897+
if type_string == "scale_info::0": # Is an AccountId
898+
# Decode AccountId bytes to SS58 address
899+
return ss58_encode(scale_bytes, SS58_FORMAT)
899900
else:
900-
if type_string == "scale_info::0": # Is an AccountId
901-
# Decode AccountId bytes to SS58 address
902-
return ss58_encode(scale_bytes, SS58_FORMAT)
903-
else:
904-
await self._wait_for_registry(_attempt, _retries)
905-
obj = decode_by_type_string(type_string, self.registry, scale_bytes)
901+
await self._wait_for_registry(_attempt, _retries)
902+
obj = decode_by_type_string(type_string, self.registry, scale_bytes)
906903
if return_scale_obj:
907904
return ScaleObj(obj)
908905
else:
@@ -2235,7 +2232,7 @@ async def query_multi(
22352232
# Decode result for specified storage_key
22362233
storage_key = storage_key_map[change_storage_key]
22372234
if change_data is None:
2238-
change_data = b"\x00"
2235+
change_data = b""
22392236
else:
22402237
change_data = bytes.fromhex(change_data[2:])
22412238
result.append(

‎async_substrate_interface/sync_substrate.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
Preprocessed,
3131
ScaleObj,
3232
)
33-
from async_substrate_interface.utils import hex_to_bytes, json
33+
from async_substrate_interface.utils import hex_to_bytes, json, generate_unique_id
3434
from async_substrate_interface.utils.decoding import (
3535
_determine_if_old_runtime_call,
3636
_bt_decode_to_dict_or_list,
@@ -518,13 +518,18 @@ def __init__(
518518
self.metadata_version_hex = "0x0f000000" # v15
519519
self.reload_type_registry()
520520
self.ws = self.connect(init=True)
521+
self.registry_type_map = {}
522+
self.type_id_to_name = {}
521523
if not _mock:
522524
self.initialize()
523525

524526
def __enter__(self):
525527
self.initialize()
526528
return self
527529

530+
def __del__(self):
531+
self.close()
532+
528533
def initialize(self):
529534
"""
530535
Initialize the connection to the chain.
@@ -612,6 +617,7 @@ def load_registry(self):
612617
metadata_option_bytes
613618
)
614619
self.registry = PortableRegistry.from_metadata_v15(self.metadata_v15)
620+
self._load_registry_type_map()
615621

616622
def _load_registry_at_block(self, block_hash: str) -> MetadataV15:
617623
# Should be called for any block that fails decoding.
@@ -646,15 +652,11 @@ def decode_scale(
646652
Returns:
647653
Decoded object
648654
"""
649-
650-
if scale_bytes == b"\x00":
651-
obj = None
655+
if type_string == "scale_info::0": # Is an AccountId
656+
# Decode AccountId bytes to SS58 address
657+
return ss58_encode(scale_bytes, SS58_FORMAT)
652658
else:
653-
if type_string == "scale_info::0": # Is an AccountId
654-
# Decode AccountId bytes to SS58 address
655-
return ss58_encode(scale_bytes, SS58_FORMAT)
656-
else:
657-
obj = decode_by_type_string(type_string, self.registry, scale_bytes)
659+
obj = decode_by_type_string(type_string, self.registry, scale_bytes)
658660
if return_scale_obj:
659661
return ScaleObj(obj)
660662
else:
@@ -1681,9 +1683,9 @@ def _make_rpc_request(
16811683
subscription_added = False
16821684

16831685
ws = self.connect(init=False if attempt == 1 else True)
1684-
item_id = 0
16851686
for payload in payloads:
1686-
item_id += 1
1687+
payload_str = json.dumps(payload["payload"])
1688+
item_id = generate_unique_id(payload_str)
16871689
ws.send(json.dumps({**payload["payload"], **{"id": item_id}}))
16881690
request_manager.add_request(item_id, payload["id"])
16891691

‎async_substrate_interface/types.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from scalecodec.type_registry import load_type_registry_preset
1414
from scalecodec.types import GenericCall, ScaleType
1515

16+
from .utils import json
17+
1618

1719
logger = logging.getLogger("async_substrate_interface")
1820

@@ -349,6 +351,9 @@ class SubstrateMixin(ABC):
349351
type_registry: Optional[dict]
350352
ss58_format: Optional[int]
351353
ws_max_size = 2**32
354+
registry_type_map: dict[str, int]
355+
type_id_to_name: dict[int, str]
356+
metadata_v15 = None
352357

353358
@property
354359
def chain(self):
@@ -604,6 +609,70 @@ def serialize_module_error(module, error, spec_version) -> dict:
604609
"spec_version": spec_version,
605610
}
606611

612+
def _load_registry_type_map(self):
613+
registry_type_map = {}
614+
type_id_to_name = {}
615+
types = json.loads(self.registry.registry)["types"]
616+
for type_entry in types:
617+
type_type = type_entry["type"]
618+
type_id = type_entry["id"]
619+
type_def = type_type["def"]
620+
type_path = type_type.get("path")
621+
if type_entry.get("params") or type_def.get("variant"):
622+
continue # has generics or is Enum
623+
if type_path:
624+
type_name = type_path[-1]
625+
registry_type_map[type_name] = type_id
626+
type_id_to_name[type_id] = type_name
627+
else:
628+
# probably primitive
629+
if type_def.get("primitive"):
630+
type_name = type_def["primitive"]
631+
registry_type_map[type_name] = type_id
632+
type_id_to_name[type_id] = type_name
633+
for type_entry in types:
634+
type_type = type_entry["type"]
635+
type_id = type_entry["id"]
636+
type_def = type_type["def"]
637+
if type_def.get("sequence"):
638+
sequence_type_id = type_def["sequence"]["type"]
639+
inner_type = type_id_to_name.get(sequence_type_id)
640+
if inner_type:
641+
type_name = f"Vec<{inner_type}>"
642+
type_id_to_name[type_id] = type_name
643+
registry_type_map[type_name] = type_id
644+
elif type_def.get("array"):
645+
array_type_id = type_def["array"]["type"]
646+
inner_type = type_id_to_name.get(array_type_id)
647+
maybe_len = type_def["array"].get("len")
648+
if inner_type:
649+
if maybe_len:
650+
type_name = f"[{inner_type}; {maybe_len}]"
651+
else:
652+
type_name = f"[{inner_type}]"
653+
type_id_to_name[type_id] = type_name
654+
registry_type_map[type_name] = type_id
655+
elif type_def.get("compact"):
656+
compact_type_id = type_def["compact"]["type"]
657+
inner_type = type_id_to_name.get(compact_type_id)
658+
if inner_type:
659+
type_name = f"Compact<{inner_type}>"
660+
type_id_to_name[type_id] = type_name
661+
registry_type_map[type_name] = type_id
662+
elif type_def.get("tuple"):
663+
tuple_type_ids = type_def["tuple"]
664+
type_names = []
665+
for inner_type_id in tuple_type_ids:
666+
inner_type = type_id_to_name.get(inner_type_id)
667+
if inner_type:
668+
type_names.append(inner_type)
669+
type_name = ", ".join(type_names)
670+
type_name = f"({type_name})"
671+
type_id_to_name[type_id] = type_name
672+
registry_type_map[type_name] = type_id
673+
self.registry_type_map = registry_type_map
674+
self.type_id_to_name = type_id_to_name
675+
607676
def reload_type_registry(
608677
self, use_remote_preset: bool = True, auto_discover: bool = True
609678
):
@@ -726,12 +795,19 @@ def _encode_scale(self, type_string, value: Any) -> bytes:
726795
if value is None:
727796
result = b"\x00"
728797
else:
798+
try:
799+
vec_acct_id = (
800+
f"scale_info::{self.registry_type_map['Vec<AccountId32>']}"
801+
)
802+
except KeyError:
803+
vec_acct_id = "scale_info::152"
804+
729805
if type_string == "scale_info::0": # Is an AccountId
730806
# encode string into AccountId
731807
## AccountId is a composite type with one, unnamed field
732808
return bytes.fromhex(ss58_decode(value, SS58_FORMAT))
733809

734-
elif type_string == "scale_info::152": # Vec<AccountId>
810+
elif type_string == vec_acct_id: # Vec<AccountId>
735811
if not isinstance(value, (list, tuple)):
736812
value = [value]
737813

‎async_substrate_interface/utils/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import importlib
2+
import hashlib
3+
4+
5+
def generate_unique_id(item: str, length=10):
6+
hashed_value = hashlib.sha256(item.encode()).hexdigest()
7+
return hashed_value[:length]
28

39

410
def hex_to_bytes(hex_str: str) -> bytes:

0 commit comments

Comments
 (0)