Skip to content

Commit 9bf206c

Browse files
authored
Merge pull request #143 from opentensor/feat/thewhaleking/python-ss58-conversion
python ss58 conversion
2 parents f254c7e + 9b6cd53 commit 9bf206c

File tree

7 files changed

+219
-10
lines changed

7 files changed

+219
-10
lines changed

async_substrate_interface/async_substrate.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
_determine_if_old_runtime_call,
6565
_bt_decode_to_dict_or_list,
6666
legacy_scale_decode,
67+
convert_account_ids,
6768
)
6869
from async_substrate_interface.utils.storage import StorageKey
6970
from async_substrate_interface.type_registry import _TYPE_REGISTRY
@@ -733,6 +734,7 @@ def __init__(
733734
_mock: bool = False,
734735
_log_raw_websockets: bool = False,
735736
ws_shutdown_timer: float = 5.0,
737+
decode_ss58: bool = False,
736738
):
737739
"""
738740
The asyncio-compatible version of the subtensor interface commands we use in bittensor. It is important to
@@ -752,10 +754,15 @@ def __init__(
752754
_mock: whether to use mock version of the subtensor interface
753755
_log_raw_websockets: whether to log raw websocket requests during RPC requests
754756
ws_shutdown_timer: how long after the last connection your websocket should close
757+
decode_ss58: Whether to decode AccountIds to SS58 or leave them in raw bytes tuples.
755758
756759
"""
757760
super().__init__(
758-
type_registry, type_registry_preset, use_remote_preset, ss58_format
761+
type_registry,
762+
type_registry_preset,
763+
use_remote_preset,
764+
ss58_format,
765+
decode_ss58,
759766
)
760767
self.max_retries = max_retries
761768
self.retry_timeout = retry_timeout
@@ -816,6 +823,7 @@ async def initialize(self):
816823

817824
if ss58_prefix_constant:
818825
self.ss58_format = ss58_prefix_constant.value
826+
runtime.ss58_format = ss58_prefix_constant.value
819827
self.initialized = True
820828
self._initializing = False
821829

@@ -994,6 +1002,15 @@ async def decode_scale(
9941002
runtime = await self.init_runtime(block_hash=block_hash)
9951003
if runtime.metadata_v15 is not None or force_legacy is True:
9961004
obj = decode_by_type_string(type_string, runtime.registry, scale_bytes)
1005+
if self.decode_ss58:
1006+
try:
1007+
type_str_int = int(type_string.split("::")[1])
1008+
decoded_type_str = runtime.type_id_to_name[type_str_int]
1009+
obj = convert_account_ids(
1010+
obj, decoded_type_str, runtime.ss58_format
1011+
)
1012+
except (ValueError, KeyError):
1013+
pass
9971014
else:
9981015
obj = legacy_scale_decode(type_string, scale_bytes, runtime)
9991016
if return_scale_obj:
@@ -1105,6 +1122,7 @@ async def _get_runtime_for_version(
11051122
metadata_v15=metadata_v15,
11061123
runtime_info=runtime_info,
11071124
registry=registry,
1125+
ss58_format=self.ss58_format,
11081126
)
11091127
self.runtime_cache.add_item(
11101128
block=block_number,
@@ -3471,6 +3489,7 @@ async def query_map(
34713489
value_type,
34723490
key_hashers,
34733491
ignore_decoding_errors,
3492+
self.decode_ss58,
34743493
)
34753494
return AsyncQueryMapResult(
34763495
records=result,

async_substrate_interface/sync_substrate.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
_bt_decode_to_dict_or_list,
4545
decode_query_map,
4646
legacy_scale_decode,
47+
convert_account_ids,
4748
)
4849
from async_substrate_interface.utils.storage import StorageKey
4950
from async_substrate_interface.type_registry import _TYPE_REGISTRY
@@ -486,6 +487,7 @@ def __init__(
486487
retry_timeout: float = 60.0,
487488
_mock: bool = False,
488489
_log_raw_websockets: bool = False,
490+
decode_ss58: bool = False,
489491
):
490492
"""
491493
The sync compatible version of the subtensor interface commands we use in bittensor. Use this instance only
@@ -503,10 +505,15 @@ def __init__(
503505
retry_timeout: how to long wait since the last ping to retry the RPC request
504506
_mock: whether to use mock version of the subtensor interface
505507
_log_raw_websockets: whether to log raw websocket requests during RPC requests
508+
decode_ss58: Whether to decode AccountIds to SS58 or leave them in raw bytes tuples.
506509
507510
"""
508511
super().__init__(
509-
type_registry, type_registry_preset, use_remote_preset, ss58_format
512+
type_registry,
513+
type_registry_preset,
514+
use_remote_preset,
515+
ss58_format,
516+
decode_ss58,
510517
)
511518
self.max_retries = max_retries
512519
self.retry_timeout = retry_timeout
@@ -560,6 +567,7 @@ def initialize(self):
560567
)
561568
if ss58_prefix_constant:
562569
self.ss58_format = ss58_prefix_constant.value
570+
self.runtime.ss58_format = ss58_prefix_constant.value
563571
self.initialized = True
564572

565573
def __exit__(self, exc_type, exc_val, exc_tb):
@@ -693,6 +701,15 @@ def decode_scale(
693701
obj = decode_by_type_string(
694702
type_string, self.runtime.registry, scale_bytes
695703
)
704+
if self.decode_ss58:
705+
try:
706+
type_str_int = int(type_string.split("::")[1])
707+
decoded_type_str = self.runtime.type_id_to_name[type_str_int]
708+
obj = convert_account_ids(
709+
obj, decoded_type_str, self.ss58_format
710+
)
711+
except (ValueError, KeyError):
712+
pass
696713
else:
697714
obj = legacy_scale_decode(type_string, scale_bytes, self.runtime)
698715
if return_scale_obj:
@@ -834,6 +851,7 @@ def get_runtime_for_version(
834851
metadata_v15=metadata_v15,
835852
runtime_info=runtime_info,
836853
registry=registry,
854+
ss58_format=self.ss58_format,
837855
)
838856
self.runtime_cache.add_item(
839857
block=block_number,
@@ -3009,6 +3027,7 @@ def query_map(
30093027
value_type,
30103028
key_hashers,
30113029
ignore_decoding_errors,
3030+
self.decode_ss58,
30123031
)
30133032
return QueryMapResult(
30143033
records=result,

async_substrate_interface/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ def __init__(
116116
metadata_v15=None,
117117
runtime_info=None,
118118
registry=None,
119+
ss58_format=SS58_FORMAT,
119120
):
121+
self.ss58_format = ss58_format
120122
self.config = {}
121123
self.chain = chain
122124
self.type_registry = type_registry
@@ -551,8 +553,10 @@ def __init__(
551553
type_registry_preset: Optional[str] = None,
552554
use_remote_preset: bool = False,
553555
ss58_format: Optional[int] = None,
556+
decode_ss58: bool = False,
554557
):
555558
# We load a very basic RuntimeConfigurationObject that is only used for the initial metadata decoding
559+
self.decode_ss58 = decode_ss58
556560
self.runtime_config = RuntimeConfigurationObject(ss58_format=ss58_format)
557561
self.ss58_format = ss58_format
558562
self.runtime_config.update_type_registry(load_type_registry_preset(name="core"))

async_substrate_interface/utils/decoding.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from typing import Union, TYPE_CHECKING
1+
from typing import Union, TYPE_CHECKING, Any
22

33
from bt_decode import AxonInfo, PrometheusInfo, decode_list
4-
from scalecodec import ScaleBytes
4+
from scalecodec import ScaleBytes, ss58_encode
55

66
from async_substrate_interface.utils import hex_to_bytes
77
from async_substrate_interface.types import ScaleObj
@@ -81,6 +81,7 @@ def decode_query_map(
8181
value_type,
8282
key_hashers,
8383
ignore_decoding_errors,
84+
decode_ss58: bool = False,
8485
):
8586
def concat_hash_len(key_hasher: str) -> int:
8687
"""
@@ -120,12 +121,21 @@ def concat_hash_len(key_hasher: str) -> int:
120121
)
121122
middl_index = len(all_decoded) // 2
122123
decoded_keys = all_decoded[:middl_index]
123-
decoded_values = [ScaleObj(x) for x in all_decoded[middl_index:]]
124-
for dk, dv in zip(decoded_keys, decoded_values):
124+
decoded_values = all_decoded[middl_index:]
125+
for kts, vts, dk, dv in zip(
126+
pre_decoded_key_types,
127+
pre_decoded_value_types,
128+
decoded_keys,
129+
decoded_values,
130+
):
125131
try:
126132
# strip key_hashers to use as item key
127133
if len(param_types) - len(params) == 1:
128134
item_key = dk[1]
135+
if decode_ss58:
136+
if kts[kts.index(", ") + 2 : kts.index(")")] == "scale_info::0":
137+
item_key = ss58_encode(bytes(item_key[0]), runtime.ss58_format)
138+
129139
else:
130140
item_key = tuple(
131141
dk[key + 1] for key in range(len(params), len(param_types) + 1, 2)
@@ -135,9 +145,17 @@ def concat_hash_len(key_hasher: str) -> int:
135145
if not ignore_decoding_errors:
136146
raise
137147
item_key = None
138-
139148
item_value = dv
140-
result.append([item_key, item_value])
149+
if decode_ss58:
150+
try:
151+
value_type_str_int = int(vts.split("::")[1])
152+
decoded_type_str = runtime.type_id_to_name[value_type_str_int]
153+
item_value = convert_account_ids(
154+
dv, decoded_type_str, runtime.ss58_format
155+
)
156+
except (ValueError, KeyError):
157+
pass
158+
result.append([item_key, ScaleObj(item_value)])
141159
return result
142160

143161

@@ -154,3 +172,68 @@ def legacy_scale_decode(
154172
obj.decode(check_remaining=runtime.config.get("strict_scale_decode"))
155173

156174
return obj.value
175+
176+
177+
def is_accountid32(value: Any) -> bool:
178+
return (
179+
isinstance(value, tuple)
180+
and len(value) == 32
181+
and all(isinstance(b, int) and 0 <= b <= 255 for b in value)
182+
)
183+
184+
185+
def convert_account_ids(value: Any, type_str: str, ss58_format=42) -> Any:
186+
if "AccountId32" not in type_str:
187+
return value
188+
189+
# Option<T>
190+
if type_str.startswith("Option<") and value is not None:
191+
inner_type = type_str[7:-1]
192+
return convert_account_ids(value, inner_type)
193+
# Vec<T>
194+
if type_str.startswith("Vec<") and isinstance(value, (list, tuple)):
195+
inner_type = type_str[4:-1]
196+
return tuple(convert_account_ids(v, inner_type) for v in value)
197+
198+
# Vec<Vec<T>>
199+
if type_str.startswith("Vec<Vec<") and isinstance(value, (list, tuple)):
200+
inner_type = type_str[8:-2]
201+
return tuple(
202+
tuple(convert_account_ids(v2, inner_type) for v2 in v1) for v1 in value
203+
)
204+
205+
# Tuple
206+
if type_str.startswith("(") and isinstance(value, (list, tuple)):
207+
inner_parts = split_tuple_type(type_str)
208+
return tuple(convert_account_ids(v, t) for v, t in zip(value, inner_parts))
209+
210+
# AccountId32
211+
if type_str == "AccountId32" and is_accountid32(value[0]):
212+
return ss58_encode(bytes(value[0]), ss58_format=ss58_format)
213+
214+
# Fallback
215+
return value
216+
217+
218+
def split_tuple_type(type_str: str) -> list[str]:
219+
"""
220+
Splits a type string like '(AccountId32, Vec<StakeInfo>)' into ['AccountId32', 'Vec<StakeInfo>']
221+
Handles nested generics.
222+
"""
223+
s = type_str[1:-1]
224+
parts = []
225+
depth = 0
226+
current = ""
227+
for char in s:
228+
if char == "," and depth == 0:
229+
parts.append(current.strip())
230+
current = ""
231+
else:
232+
if char == "<":
233+
depth += 1
234+
elif char == ">":
235+
depth -= 1
236+
current += char
237+
if current:
238+
parts.append(current.strip())
239+
return parts

tests/helpers/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@
3434
)
3535

3636
ARCHIVE_ENTRYPOINT = "wss://archive.chain.opentensor.ai:443"
37+
38+
LATENT_LITE_ENTRYPOINT = "wss://lite.sub.latent.to:443"

tests/integration_tests/test_async_substrate_interface.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import pytest
2+
from scalecodec import ss58_encode
23

34
from async_substrate_interface.async_substrate import AsyncSubstrateInterface
45
from async_substrate_interface.types import ScaleObj
5-
from tests.helpers.settings import ARCHIVE_ENTRYPOINT
6+
from tests.helpers.settings import ARCHIVE_ENTRYPOINT, LATENT_LITE_ENTRYPOINT
67

78

89
@pytest.mark.asyncio
@@ -30,3 +31,43 @@ async def test_legacy_decoding():
3031
block_hash=block_hash,
3132
)
3233
assert timestamp.value == 1716358476004
34+
35+
36+
@pytest.mark.asyncio
37+
async def test_ss58_conversion():
38+
async with AsyncSubstrateInterface(
39+
LATENT_LITE_ENTRYPOINT, ss58_format=42, decode_ss58=False
40+
) as substrate:
41+
block_hash = await substrate.get_chain_finalised_head()
42+
qm = await substrate.query_map(
43+
"SubtensorModule",
44+
"OwnedHotkeys",
45+
block_hash=block_hash,
46+
)
47+
# only do the first page, bc otherwise this will be massive
48+
for key, value in qm.records:
49+
assert isinstance(key, tuple)
50+
assert isinstance(value, ScaleObj)
51+
assert isinstance(value.value, list)
52+
assert len(key) == 1
53+
for key_tuple in value.value:
54+
assert len(key_tuple[0]) == 32
55+
random_key = key_tuple[0]
56+
57+
ss58_of_key = ss58_encode(bytes(random_key), substrate.ss58_format)
58+
assert isinstance(ss58_of_key, str)
59+
60+
substrate.decode_ss58 = True # change to decoding True
61+
62+
qm = await substrate.query_map(
63+
"SubtensorModule",
64+
"OwnedHotkeys",
65+
block_hash=block_hash,
66+
)
67+
for key, value in qm.records:
68+
assert isinstance(key, str)
69+
assert isinstance(value, ScaleObj)
70+
assert isinstance(value.value, list)
71+
if len(value.value) > 0:
72+
for decoded_key in value.value:
73+
assert isinstance(decoded_key, str)

0 commit comments

Comments
 (0)