Skip to content

Commit 1f96017

Browse files
authored
feat: Checksum support for TokenId.from_string() (#380)
* feat: added ledger_id to network.py Signed-off-by: Manish Dait <daitmanish88@gmail.com> * feat: added entity_id_helper for checksum validation Signed-off-by: Manish Dait <daitmanish88@gmail.com> * test: added unit test for entity_id_helper Signed-off-by: Manish Dait <daitmanish88@gmail.com> * feat: added checksum sum field in token_id Signed-off-by: Manish Dait <daitmanish88@gmail.com> * fix: missing filed in nft_id_test Signed-off-by: Manish Dait <daitmanish88@gmail.com> * test: updated entity_id_helper unit test Signed-off-by: Manish Dait <daitmanish88@gmail.com> * chore: updated CHANGELOG.md Signed-off-by: Manish Dait <daitmanish88@gmail.com> * chore: updated method to get string representation Signed-off-by: Manish Dait <daitmanish88@gmail.com> * chore: updated str() to use format_to_string() in token_id Signed-off-by: Manish Dait <daitmanish88@gmail.com> * feat: added method to get string representation of nftid with checksum Signed-off-by: Manish Dait <daitmanish88@gmail.com> * fix: update checksum to be use by from_string() method only Signed-off-by: Manish Dait <daitmanish88@gmail.com> * test:added test for token id Signed-off-by: Manish Dait <daitmanish88@gmail.com> * test:updated nft_id test Signed-off-by: Manish Dait <daitmanish88@gmail.com> * chore: fix pylint issues Signed-off-by: Manish Dait <daitmanish88@gmail.com> * chore: updated token_id from_string() Signed-off-by: Manish Dait <daitmanish88@gmail.com> * chore: fix spelling mistake Signed-off-by: Manish Dait <daitmanish88@gmail.com> * fix rebase issue Signed-off-by: Manish Dait <daitmanish88@gmail.com> * fix: added check for null checksum in validate() Signed-off-by: Manish Dait <daitmanish88@gmail.com> --------- Signed-off-by: Manish Dait <daitmanish88@gmail.com>
1 parent e6ce2ce commit 1f96017

File tree

8 files changed

+370
-16
lines changed

8 files changed

+370
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
1212
- add revenue generating topic tests/example
1313
- add fee_schedule_key, fee_exempt_keys, custom_fees fields in TopicCreateTransaction, TopicUpdateTransaction, TopicInfo classes
1414
- add CustomFeeLimit class
15+
- Added checksum validation for TokenId
1516

1617
### Fixed
1718
- Incompatible Types assignment in token_transfer_list.py

src/hiero_sdk_python/client/network.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,19 @@ class Network:
6161
],
6262
}
6363

64+
LEDGER_ID: Dict[str, bytes] = {
65+
'mainnet': bytes.fromhex('00'),
66+
'testnet': bytes.fromhex('01'),
67+
'previewnet': bytes.fromhex('02'),
68+
'solo': bytes.fromhex('03')
69+
}
70+
6471
def __init__(
6572
self,
6673
network: str = 'testnet',
6774
nodes: Optional[List[_Node]] = None,
6875
mirror_address: Optional[str] = None,
76+
ledger_id: bytes | None = None
6977
) -> None:
7078
"""
7179
Initializes the Network with the specified network name or custom config.
@@ -84,6 +92,8 @@ def __init__(
8492
network, 'localhost:5600'
8593
)
8694

95+
self.ledger_id = ledger_id or self.LEDGER_ID.get(network, bytes.fromhex('03'))
96+
8797
if nodes is not None:
8898
final_nodes = nodes
8999
elif self.network in ('solo', 'localhost', 'local'):

src/hiero_sdk_python/tokens/nft_id.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import warnings
1212
from dataclasses import dataclass, field
1313
from typing import Optional
14+
from hiero_sdk_python.client.client import Client
1415
from hiero_sdk_python.hapi.services import basic_types_pb2
1516
from hiero_sdk_python.tokens.token_id import TokenId
1617
from hiero_sdk_python._deprecated import _DeprecatedAliasesMixin
@@ -119,6 +120,13 @@ def from_string(cls, nft_id_str: str) -> "NftId":
119120
token_id=TokenId.from_string(token_part),
120121
serial_number=int(serial_part),
121122
)
123+
124+
def to_string_with_checksum(self, client:Client) -> str:
125+
"""
126+
Returns the string representation of the NftId with
127+
checksum in the format 'shard.realm.num-checksum/serial'
128+
"""
129+
return f"{self.token_id.to_string_with_checksum(client)}/{self.serial_number}"
122130

123131
def __str__(self) -> str:
124132
"""

src/hiero_sdk_python/tokens/token_id.py

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,25 @@
55
Defines TokenId, a frozen dataclass for representing Hedera token identifiers
66
(shard, realm, num) with validation and protobuf conversion utilities.
77
"""
8-
from dataclasses import dataclass
9-
from typing import List, Optional
8+
from dataclasses import dataclass, field
9+
from typing import Optional
1010

1111
from hiero_sdk_python.hapi.services import basic_types_pb2
12+
from hiero_sdk_python.client.client import Client
13+
from hiero_sdk_python.utils.entity_id_helper import (
14+
parse_from_string,
15+
validate_checksum,
16+
format_to_string,
17+
format_to_string_with_checksum
18+
)
1219

1320
@dataclass(frozen=True, eq=True, init=True, repr=True)
1421
class TokenId:
1522
"""Immutable token identifier (shard, realm, num) with validation and protobuf conversion."""
1623
shard: int
1724
realm: int
1825
num: int
26+
checksum: str | None = field(default=None, init=False)
1927

2028
def __post_init__(self) -> None:
2129
if self.shard < 0:
@@ -54,28 +62,40 @@ def from_string(cls, token_id_str: Optional[str] = None) -> "TokenId":
5462
"""
5563
Parses a string in the format 'shard.realm.num' to create a TokenId instance.
5664
"""
57-
if token_id_str is None:
58-
raise ValueError("TokenId string must be provided")
65+
shard, realm, num, checksum = parse_from_string(token_id_str)
5966

60-
token_id_str = token_id_str.strip()
61-
if not token_id_str:
62-
raise ValueError("TokenId cannot be empty or whitespace")
67+
token_id = cls(int(shard), int(realm), int(num))
68+
object.__setattr__(token_id, 'checksum', checksum)
6369

64-
parts: List[str] = token_id_str.split(".")
65-
if len(parts) != 3:
66-
raise ValueError("Invalid TokenId format. Expected 'shard.realm.num'")
70+
return token_id
6771

68-
return cls(
69-
shard=int(parts[0]),
70-
realm=int(parts[1]),
71-
num=int(parts[2])
72+
def validate_checksum(self, client: Client) -> None:
73+
"""Validate the checksum for the TokenId instance"""
74+
validate_checksum(
75+
shard=self.shard,
76+
realm=self.realm,
77+
num=self.num,
78+
checksum=self.checksum,
79+
client=client
80+
)
81+
82+
def to_string_with_checksum(self, client:Client) -> str:
83+
"""
84+
Returns the string representation of the TokenId with checksum
85+
in the format 'shard.realm.num-checksum'
86+
"""
87+
return format_to_string_with_checksum(
88+
shard=self.shard,
89+
realm=self.realm,
90+
num=self.num,
91+
client=client
7292
)
7393

7494
def __str__(self) -> str:
7595
"""
7696
Returns the string representation of the TokenId in the format 'shard.realm.num'.
7797
"""
78-
return f"{self.shard}.{self.realm}.{self.num}"
98+
return format_to_string(self.shard, self.realm, self.num)
7999

80100
def __hash__(self) -> int:
81101
""" Returns a hash of the TokenId instance. """
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import re
2+
3+
from hiero_sdk_python.client.client import Client
4+
5+
ID_REGEX = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([a-z]{5}))?$")
6+
7+
MULTIPLIER = 1000003
8+
P3 = 26**3
9+
P5 = 26**5
10+
11+
def parse_from_string(address: str):
12+
"""
13+
Parse an address string of the form: <shard>.<realm>.<num>[-<checksum>]
14+
Examples:
15+
"0.0.123"
16+
"0.0.123-abcde"
17+
18+
Returns:
19+
An instance of cls with shard, realm, num, and optional checksum.
20+
"""
21+
match = ID_REGEX.match(address)
22+
if not match:
23+
raise ValueError("Invalid address format")
24+
25+
shard, realm, num, checksum = match.groups()
26+
27+
return shard, realm, num, checksum
28+
29+
def generate_checksum(ledger_id: bytes, address: str) -> str:
30+
"""
31+
Compute the 5-character checksum for a Hiero entity ID string (HIP-15).
32+
33+
Args:
34+
ledger_id: The ledger identifier as raw bytes (e.g., b"\x00" for mainnet).
35+
address: A string of the form "shard.realm.num" (e.g., "0.0.123").
36+
37+
Returns:
38+
A 5-letter checksum string (e.g., "kfmza").
39+
"""
40+
# Convert "0.0.123" into a digit list with '.' as 10
41+
d = []
42+
for ch in address:
43+
if ch == '.':
44+
d.append(10)
45+
else:
46+
d.append(int(ch))
47+
48+
# Initialize running sums
49+
sd0 = 0 # sum of digits at even indices mod 11
50+
sd1 = 0 # sum of digits at odd indices mod 11
51+
sd = 0 # weight sum of all position mod P3
52+
53+
for i in range(len(d)):
54+
sd = (sd * 31 + d[i]) % P3
55+
if i % 2 == 0:
56+
sd0 = (sd0 + d[i]) % 11
57+
else:
58+
sd1 = (sd1 + d[i]) % 11
59+
60+
# Compute hash of ledger ID bytes (padded with six zeros)
61+
sh = 0
62+
h = list(ledger_id or b"")
63+
h += [0] * 6
64+
65+
for i in range(len(h)):
66+
sh = (sh * 31 + h[i]) % P5
67+
68+
cp = ((((len(address) % 5) * 11 + sd0) * 11 + sd1) * P3 + sd + sh) % P5
69+
cp = (cp * MULTIPLIER) % P5
70+
71+
letter = []
72+
73+
for _ in range(5):
74+
letter.append(chr(ord('a') + (cp % 26)))
75+
cp //= 26
76+
77+
return "".join(reversed(letter))
78+
79+
def validate_checksum(shard: int, realm: int, num: int, checksum: str | None, client: Client) -> None:
80+
"""
81+
Validate a Hiero entity ID checksum against the current client's ledger.
82+
83+
Args:
84+
shard: Shard number of the entity ID.
85+
realm: Realm number of the entity ID.
86+
num: Entity number (account, token, topic, etc.).
87+
checksum: The 5-letter checksum string to validate.
88+
client: The Hiero client, which holds the target ledger_id.
89+
90+
Raises:
91+
ValueError: If the ledger ID is missing or if the checksum is invalid.
92+
"""
93+
# If no checksum present then return.
94+
if (checksum is None):
95+
return
96+
97+
ledger_id = client.network.ledger_id
98+
if not ledger_id:
99+
raise ValueError("Missing ledger ID in client")
100+
101+
address = format_to_string(shard, realm, num)
102+
expected_checksum = generate_checksum(ledger_id, address)
103+
104+
if expected_checksum != checksum:
105+
raise ValueError(f"Checksum mismatch for {address}")
106+
107+
def format_to_string(shard: int, realm: int, num: int) -> str:
108+
"""
109+
Convert an entity ID into its standard string representation.
110+
"""
111+
return f"{shard}.{realm}.{num}"
112+
113+
def format_to_string_with_checksum(shard: int, realm: int, num: int,client: Client) -> str:
114+
"""
115+
Convert an entity ID into its string representation with checksum.
116+
"""
117+
ledger_id = client.network.ledger_id
118+
if not ledger_id:
119+
raise ValueError("Missing ledger ID in client")
120+
121+
base_str = format_to_string(shard, realm, num)
122+
return f"{base_str}-{generate_checksum(ledger_id, format_to_string(shard, realm, num))}"

tests/unit/nft_id_test.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def test_nft_id():
1111
nftid_constructor_test = NftId(token_id=nftid_constructor_tokenid, serial_number=1234)
1212

1313
assert str(nftid_constructor_test) == "0.1.2/1234"
14-
assert repr(nftid_constructor_test) == "NftId(token_id=TokenId(shard=0, realm=1, num=2), serial_number=1234)"
14+
assert repr(nftid_constructor_test) == "NftId(token_id=TokenId(shard=0, realm=1, num=2, checksum=None), serial_number=1234)"
1515
assert nftid_constructor_test._to_proto().__eq__(
1616
basic_types_pb2.NftID(
1717
token_ID=basic_types_pb2.TokenID(shardNum=0, realmNum=1, tokenNum=2),
@@ -65,3 +65,13 @@ def test_nft_id():
6565
fail_str = "a.3.3/19"
6666
with pytest.raises(ValueError):
6767
NftId.from_string(fail_str)
68+
69+
def test_get_nft_id_with_checksum(mock_client):
70+
"""Should return string with checksum when ledger id is provided."""
71+
client = mock_client
72+
client.network.ledger_id = bytes.fromhex("00")
73+
74+
token_id = TokenId.from_string("0.0.1")
75+
nft_id = NftId(token_id, 1)
76+
77+
assert nft_id.to_string_with_checksum(client) == "0.0.1-dfkxr/1"

0 commit comments

Comments
 (0)