Skip to content

Commit 289ce45

Browse files
feat: add token metadata support to TokenCreateTransaction (#866)
Signed-off-by: Antonio Ceppellini <[email protected]>
1 parent 5b3635f commit 289ce45

File tree

5 files changed

+342
-1
lines changed

5 files changed

+342
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
2626
- `staked_account_id`, `staked_node_id` and `decline_staking_reward` fields to AccountInfo
2727
- Added `examples/token_create_transaction_supply_key.py` to demonstrate token creation with and without a supply key.
2828
- Added BatchTransaction class
29+
- Add support for token metadata (bytes, max 100 bytes) in `TokenCreateTransaction`, including a new `set_metadata` setter, example, and tests. [#799]
2930

3031

3132
### Changed
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""
2+
This example creates a fungible token with on-ledger metadata using Hiero SDK Python.
3+
4+
It demonstrates:
5+
1. Creating a token with on-ledger metadata.
6+
2. Attempting to update metadata WITHOUT a metadata_key: expected to fail.
7+
3. Attempting to update metadata WITH a metadata_key: expected to succed.
8+
4. Demonstrates that metadata longer than 100 bytes is rejected.
9+
10+
Required environment variables:
11+
- OPERATOR_ID, OPERATOR_KEY
12+
13+
Usage:
14+
uv run examples/token_create_transaction_token_metadata.py
15+
python examples/token_create_transaction_token_metadata.py
16+
"""
17+
18+
import os
19+
import sys
20+
from dotenv import load_dotenv
21+
from hiero_sdk_python import (
22+
Client,
23+
AccountId,
24+
PrivateKey,
25+
TokenCreateTransaction,
26+
TokenUpdateTransaction,
27+
Network,
28+
TokenType,
29+
SupplyType,
30+
)
31+
from hiero_sdk_python.query.token_info_query import TokenInfoQuery
32+
from hiero_sdk_python.response_code import ResponseCode
33+
34+
load_dotenv()
35+
network_name = os.getenv("NETWORK", "testnet").lower()
36+
37+
38+
def setup_client():
39+
"""Initialize and set up the client with operator account"""
40+
network = Network(network_name)
41+
print(f"Connecting to Hedera {network_name} network!")
42+
client = Client(network)
43+
44+
try:
45+
operator_id = AccountId.from_string(os.getenv("OPERATOR_ID", ""))
46+
operator_key = PrivateKey.from_string(os.getenv("OPERATOR_KEY", ""))
47+
client.set_operator(operator_id, operator_key)
48+
print(f"Client set up with operator id {client.operator_account_id}")
49+
return client, operator_id, operator_key
50+
51+
except (TypeError, ValueError):
52+
print("Error: Please check OPERATOR_ID and OPERATOR_KEY in your .env file.")
53+
sys.exit(1)
54+
55+
56+
def generate_metadata_key():
57+
"""Generate a new metadata key for the token."""
58+
print("\nGenerating a new metadata key for the token...")
59+
metadata_key = PrivateKey.generate_ed25519()
60+
print("✅ Metadata key generated successfully.")
61+
return metadata_key
62+
63+
64+
def create_token_without_metadata_key(client, operator_key, operator_id):
65+
"""
66+
Creating token with on-ledger metadata WITHOUT metadata_key (max 100 bytes)
67+
"""
68+
print("\nCreating token WITHOUT metadata_key")
69+
70+
metadata = b"Initial on-ledger metadata" # < 100 bytes
71+
72+
try:
73+
transaction = (
74+
TokenCreateTransaction()
75+
.set_token_name("MetadataToken_NoKey")
76+
.set_token_symbol("MDN")
77+
.set_treasury_account_id(operator_id)
78+
.set_token_type(TokenType.FUNGIBLE_COMMON)
79+
.set_supply_type(SupplyType.INFINITE)
80+
.set_initial_supply(10)
81+
.set_metadata(metadata)
82+
.freeze_with(client)
83+
)
84+
85+
# Sign + execute
86+
transaction.sign(operator_key)
87+
receipt = transaction.execute(client)
88+
except Exception as e:
89+
print(f"❌ Error while building create transaction: {e}")
90+
sys.exit(1)
91+
92+
token_id = receipt.token_id
93+
print(f"✅ Token {token_id} successfully created without metadata_key")
94+
return token_id
95+
96+
97+
def try_update_metadata_without_key(client, operator_key, token_id):
98+
print(f"\nAttempting token {token_id} metadata update WITHOUT metadata_key...")
99+
updated_metadata = b"updated metadata (without metadata_key)"
100+
try:
101+
update_transaction = (
102+
TokenUpdateTransaction()
103+
.set_token_id(token_id)
104+
.set_metadata(updated_metadata)
105+
.freeze_with(client)
106+
)
107+
update_transaction.sign(operator_key)
108+
receipt = update_transaction.execute(client)
109+
status = ResponseCode(receipt.status).name
110+
111+
if receipt.status == ResponseCode.SUCCESS:
112+
print(
113+
f"❌ Unexpected SUCCESS. Status: {receipt.status}"
114+
"(this should normally fail when metadata_key is missing)"
115+
)
116+
sys.exit(1)
117+
else:
118+
print(f"✅ Expected failure: metadata update rejected -> status={status}")
119+
120+
except Exception as e:
121+
print(f"Failed: {e}")
122+
123+
124+
def create_token_with_metadata_key(client, metadata_key, operator_id, operator_key):
125+
"""
126+
Create token with metadata_key and on-ledger metadata (max 100 bytes)
127+
"""
128+
metadata = b"Example on-ledger token metadata"
129+
130+
print("\nCreating token with metadata and metadata_key...")
131+
try:
132+
transaction = (
133+
TokenCreateTransaction()
134+
.set_token_name("Metadata Fungible Token")
135+
.set_token_symbol("MFT")
136+
.set_decimals(2)
137+
.set_initial_supply(1000)
138+
.set_treasury_account_id(operator_id)
139+
.set_token_type(TokenType.FUNGIBLE_COMMON)
140+
.set_supply_type(SupplyType.INFINITE)
141+
.set_metadata_key(metadata_key)
142+
.set_metadata(metadata)
143+
.freeze_with(client)
144+
)
145+
146+
transaction.sign(operator_key)
147+
transaction.sign(metadata_key)
148+
receipt = transaction.execute(client)
149+
except Exception as e:
150+
print(f"❌ Error while creating transaction: {e}")
151+
sys.exit(1)
152+
153+
if receipt.status != ResponseCode.SUCCESS:
154+
print(
155+
f"❌ Token creation failed with status: {ResponseCode(receipt.status).name}"
156+
)
157+
sys.exit(1)
158+
159+
token_id = receipt.token_id
160+
print(f"✅ Token {token_id} created with metadat_key: {metadata_key.public_key()}")
161+
print(f"Metadata: {metadata!r}")
162+
return token_id, metadata_key
163+
164+
165+
def update_metadata_with_key(client, token_id, metadata_key, operator_key):
166+
"""
167+
Update token metadata with metadata_key
168+
"""
169+
print(f"\nUpdating token {token_id} metadata WITH metadata_key...")
170+
updated_metadata = b"Updated metadata (with key)"
171+
172+
try:
173+
update_transaction = (
174+
TokenUpdateTransaction()
175+
.set_token_id(token_id)
176+
.set_metadata(updated_metadata)
177+
.freeze_with(client)
178+
.sign(metadata_key)
179+
)
180+
receipt = update_transaction.execute(client)
181+
if receipt.status != ResponseCode.SUCCESS:
182+
print(
183+
f"❌ Token update failed with status: {ResponseCode(receipt.status).name}"
184+
)
185+
sys.exit(1)
186+
except Exception as e:
187+
print(f"Error while freezing update transaction: {e}")
188+
sys.exit(1)
189+
190+
print(f"✅ Token {token_id} metadata successfully updated")
191+
print(f"Updated metadata: {updated_metadata}")
192+
193+
194+
def demonstrate_metadata_length_validation(client, operator_key, operator_id):
195+
"""
196+
Demonstrate that metadata longer than 100 bytes trigger a ValueError
197+
in the TokenCreateTransaction.set_metadata() validation.
198+
"""
199+
print("\nDemonstrating metadata length validation (> 100 bytes)...")
200+
too_long_metadata = b"x" * 101
201+
202+
try:
203+
transaction = (
204+
TokenCreateTransaction()
205+
.set_token_name("TooLongMetadataToken")
206+
.set_token_symbol("TLM")
207+
.set_treasury_account_id(operator_id)
208+
.set_metadata(too_long_metadata)
209+
)
210+
211+
transaction.sign(operator_key)
212+
receipt = transaction.execute(client)
213+
if receipt.status == ResponseCode.SUCCESS:
214+
print(f"❌ Unexpected success for this operation!")
215+
else:
216+
print(
217+
"Error: Expected ValueError for metadata > 100 bytes, but none was raised."
218+
)
219+
220+
sys.exit(1)
221+
except ValueError as exc:
222+
print("Expected error raised for metadata > 100 bytes")
223+
print(f"✅ Error raised: {exc}")
224+
225+
226+
def create_token_with_metadata():
227+
"""
228+
Main function to create and update fungible token with metadata with two scenarios:
229+
- create token WITHOUT metadata_key (expected to fail)
230+
- create token WITH metadat_key (expected to succed)
231+
and validate metadata length
232+
"""
233+
client, operator_id, operator_key = setup_client()
234+
metadata_key = generate_metadata_key()
235+
236+
token_a = create_token_without_metadata_key(client, operator_key, operator_id)
237+
try_update_metadata_without_key(client, operator_key, token_a)
238+
239+
token_b, metadata_key = create_token_with_metadata_key(
240+
client, metadata_key, operator_id, operator_key
241+
)
242+
update_metadata_with_key(client, token_b, metadata_key, operator_key)
243+
244+
demonstrate_metadata_length_validation(client, operator_key, operator_id)
245+
246+
247+
if __name__ == "__main__":
248+
create_token_with_metadata()

src/hiero_sdk_python/tokens/token_create_transaction.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class TokenParams:
5050
max_supply (optional): The max tokens or NFT serial numbers.
5151
supply_type (optional): The token supply status as finite or infinite.
5252
freeze_default (optional): An initial Freeze status for accounts associated to this token.
53+
metadata (optional): The on-ledger token metadata as bytes (max 100 bytes).
5354
"""
5455

5556
token_name: str
@@ -66,6 +67,7 @@ class TokenParams:
6667
auto_renew_account_id: Optional[AccountId] = None
6768
auto_renew_period: Optional[Duration] = AUTO_RENEW_PERIOD # Default around ~90 days
6869
memo: Optional[str] = None
70+
metadata: Optional[bytes] = None
6971

7072

7173
@dataclass
@@ -426,6 +428,24 @@ def set_fee_schedule_key(self, key: Key) -> "TokenCreateTransaction":
426428
self._keys.fee_schedule_key = key
427429
return self
428430

431+
def set_metadata(self, metadata: bytes | str) -> "TokenCreateTransaction":
432+
"""Sets the metadata for the token (max 100 bytes)"""
433+
self._require_not_frozen()
434+
435+
# accept stringt and converts to bytes
436+
if isinstance(metadata, str):
437+
metadata = metadata.encode("utf-8")
438+
439+
# type validation, if users pass something that is not a str or a byte
440+
if not isinstance(metadata, (bytes, bytearray)):
441+
raise TypeError("Metadata must be bytes or string")
442+
443+
if len(metadata) > 100:
444+
raise ValueError("Metadata must not exceed 100 bytes")
445+
446+
self._token_params.metadata = metadata
447+
return self
448+
429449
def _to_proto_key(self, key: Optional[Key]) -> Optional[basic_types_pb2.Key]:
430450
"""
431451
Helper method to convert a PrivateKey or PublicKey to the protobuf Key format.
@@ -546,6 +566,7 @@ def _build_proto_body(self) -> token_create_pb2.TokenCreateTransactionBody:
546566
else None
547567
),
548568
memo=self._token_params.memo,
569+
metadata=self._token_params.metadata,
549570
adminKey=admin_key_proto,
550571
supplyKey=supply_key_proto,
551572
freezeKey=freeze_key_proto,

tests/integration/token_create_transaction_e2e_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,42 @@ def test_token_create_non_custodial_flow():
236236
finally:
237237
# Clean up the environment
238238
env.close()
239+
240+
def test_fungible_token_create_with_metadata():
241+
"""
242+
Test creating a fungible token with on-ledger metadata and verifying
243+
the metadata via TokenInfoQuery.
244+
"""
245+
env = IntegrationTestEnv()
246+
247+
try:
248+
# On-ledger token metadata bytes (must not exceed 100 bytes)
249+
metadata = b"Integration test token metadata"
250+
251+
params = TokenParams(
252+
token_name="Hiero FT Metadata",
253+
token_symbol="HFTM",
254+
initial_supply=1,
255+
treasury_account_id=env.client.operator_account_id,
256+
token_type=TokenType.FUNGIBLE_COMMON,
257+
)
258+
259+
# Build, freeze and execute the token creation transaction with metadata
260+
receipt = (
261+
TokenCreateTransaction(params)
262+
.set_metadata(metadata)
263+
.freeze_with(env.client)
264+
.execute(env.client)
265+
)
266+
267+
assert receipt.token_id is not None, "TokenID not found in receipt. Token may not have been created."
268+
269+
token_id = receipt.token_id
270+
271+
# Query the created token to verify that metadata has been set
272+
token_info = TokenInfoQuery(token_id=token_id).execute(env.client)
273+
274+
assert token_info.metadata == metadata, "Token metadata mismatch"
275+
276+
finally:
277+
env.close()

tests/unit/test_token_create_transaction.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,38 @@ def test_build_transaction_body(mock_account_ids):
145145
assert transaction_body.tokenCreation.kycKey == private_key_kyc.public_key()._to_proto()
146146
assert transaction_body.tokenCreation.fee_schedule_key == private_key_fee_schedule.public_key()._to_proto()
147147

148+
# This test uses fixture mock_account_ids as parameter
149+
def test_build_transaction_body_with_metadata(mock_account_ids):
150+
"""Test building a token creation transaction body with metadata bytes set."""
151+
treasury_account, _, node_account_id, _, _ = mock_account_ids
152+
153+
token_tx = TokenCreateTransaction()
154+
token_tx.set_token_name("MyTokenWithMetadata")
155+
token_tx.set_token_symbol("MTKM")
156+
token_tx.set_decimals(2)
157+
token_tx.set_initial_supply(1000)
158+
token_tx.set_treasury_account_id(treasury_account)
159+
160+
metadata = b"Example on-ledger token metadata"
161+
token_tx.set_metadata(metadata)
162+
163+
token_tx.transaction_id = generate_transaction_id(treasury_account)
164+
token_tx.node_account_id = node_account_id
165+
166+
transaction_body = token_tx.build_transaction_body()
167+
168+
assert transaction_body.tokenCreation.name == "MyTokenWithMetadata"
169+
assert transaction_body.tokenCreation.symbol == "MTKM"
170+
assert transaction_body.tokenCreation.metadata == metadata
171+
172+
def test_set_metadata_raises_when_over_100_bytes():
173+
"""set_metadata must reject metadata longer than 100 bytes."""
174+
token_tx = TokenCreateTransaction()
175+
too_long_metadata = b"x" * 101 # 101 bytes
176+
177+
with pytest.raises(ValueError, match="Metadata must not exceed 100 bytes"):
178+
token_tx.set_metadata(too_long_metadata)
179+
148180
@pytest.mark.parametrize(
149181
"token_name, token_symbol, decimals, initial_supply, token_type, expected_error",
150182
[
@@ -1222,4 +1254,4 @@ def test_to_proto_key_with_invalid_string_raises_error():
12221254
with pytest.raises(TypeError) as e:
12231255
tx._to_proto_key("this is not a key")
12241256

1225-
assert "Key must be of type PrivateKey or PublicKey" in str(e.value)
1257+
assert "Key must be of type PrivateKey or PublicKey" in str(e.value)

0 commit comments

Comments
 (0)