Skip to content

Commit a7f0fa4

Browse files
committed
feat: Allow PublicKey for TokenCreateTransaction keys
Signed-off-by: Adityarya11 <[email protected]>
1 parent cb1e57c commit a7f0fa4

File tree

4 files changed

+181
-39
lines changed

4 files changed

+181
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
2424
- Added expiration_time, auto_renew_period, auto_renew_account, fee_schedule_key, kyc_key in `TokenCreateTransaction`, `TokenUpdateTransaction` classes
2525
- Added comprehensive Google-style docstrings to the `CustomFee` class and its methods in `custom_fee.py`.
2626
- docs: Add `docs/sdk_developers/project_structure.md` to explain repository layout and import paths.
27+
- feat: Allow `PublicKey` to be used for keys in `TokenCreateTransaction` for non-custodial signing.
2728

2829
### Changed
2930
- chore: validate that token airdrop transactions require an available token service on the channel (#632)

src/hiero_sdk_python/tokens/token_create_transaction.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"""
1313

1414
from dataclasses import dataclass, field
15-
from typing import Optional, Any, List
15+
from typing import Optional, Any, List, Union
1616

1717
from hiero_sdk_python.Duration import Duration
1818
from hiero_sdk_python.channels import _Channel
@@ -26,12 +26,15 @@
2626
from hiero_sdk_python.tokens.token_type import TokenType
2727
from hiero_sdk_python.tokens.supply_type import SupplyType
2828
from hiero_sdk_python.account.account_id import AccountId
29+
from hiero_sdk_python.crypto.public_key import PublicKey
2930
from hiero_sdk_python.crypto.private_key import PrivateKey
3031
from hiero_sdk_python.tokens.custom_fee import CustomFee
3132

3233
AUTO_RENEW_PERIOD = Duration(7890000) # around 90 days in seconds
3334
DEFAULT_TRANSACTION_FEE = 3_000_000_000
3435

36+
Key = Union[PublicKey, PrivateKey]
37+
3538
@dataclass
3639
class TokenParams:
3740
"""
@@ -81,14 +84,14 @@ class TokenKeys:
8184
kyc_key: The KYC key for the token to grant KYC to an account.
8285
"""
8386

84-
admin_key: Optional[PrivateKey] = None
85-
supply_key: Optional[PrivateKey] = None
86-
freeze_key: Optional[PrivateKey] = None
87-
wipe_key: Optional[PrivateKey] = None
88-
metadata_key: Optional[PrivateKey] = None
89-
pause_key: Optional[PrivateKey] = None
90-
kyc_key: Optional[PrivateKey] = None
91-
fee_schedule_key: Optional[PrivateKey] = None
87+
admin_key: Optional[Key] = None
88+
supply_key: Optional[Key] = None
89+
freeze_key: Optional[Key] = None
90+
wipe_key: Optional[Key] = None
91+
metadata_key: Optional[Key] = None
92+
pause_key: Optional[Key] = None
93+
kyc_key: Optional[Key] = None
94+
fee_schedule_key: Optional[Key] = None
9295

9396
class TokenCreateValidator:
9497
"""Token, key and freeze checks for creating a token as per the proto"""
@@ -368,43 +371,43 @@ def set_memo(self, memo: str) -> "TokenCreateTransaction":
368371
self._token_params.memo = memo
369372
return self
370373

371-
def set_admin_key(self, key: PrivateKey) -> "TokenCreateTransaction":
374+
def set_admin_key(self, key: Key) -> "TokenCreateTransaction":
372375
""" Sets the admin key for the token, which allows updating and deleting the token."""
373376
self._require_not_frozen()
374377
self._keys.admin_key = key
375378
return self
376379

377-
def set_supply_key(self, key: PrivateKey) -> "TokenCreateTransaction":
380+
def set_supply_key(self, key: Key) -> "TokenCreateTransaction":
378381
""" Sets the supply key for the token, which allows minting and burning tokens."""
379382
self._require_not_frozen()
380383
self._keys.supply_key = key
381384
return self
382385

383-
def set_freeze_key(self, key: PrivateKey) -> "TokenCreateTransaction":
386+
def set_freeze_key(self, key: Key) -> "TokenCreateTransaction":
384387
""" Sets the freeze key for the token, which allows freezing and unfreezing accounts."""
385388
self._require_not_frozen()
386389
self._keys.freeze_key = key
387390
return self
388391

389-
def set_wipe_key(self, key: PrivateKey) -> "TokenCreateTransaction":
392+
def set_wipe_key(self, key: Key) -> "TokenCreateTransaction":
390393
""" Sets the wipe key for the token, which allows wiping tokens from an account."""
391394
self._require_not_frozen()
392395
self._keys.wipe_key = key
393396
return self
394397

395-
def set_metadata_key(self, key: PrivateKey) -> "TokenCreateTransaction":
398+
def set_metadata_key(self, key: Key) -> "TokenCreateTransaction":
396399
""" Sets the metadata key for the token, which allows updating NFT metadata."""
397400
self._require_not_frozen()
398401
self._keys.metadata_key = key
399402
return self
400403

401-
def set_pause_key(self, key: PrivateKey) -> "TokenCreateTransaction":
404+
def set_pause_key(self, key: Key) -> "TokenCreateTransaction":
402405
""" Sets the pause key for the token, which allows pausing and unpausing the token."""
403406
self._require_not_frozen()
404407
self._keys.pause_key = key
405408
return self
406409

407-
def set_kyc_key(self, key: PrivateKey) -> "TokenCreateTransaction":
410+
def set_kyc_key(self, key: Key) -> "TokenCreateTransaction":
408411
""" Sets the KYC key for the token, which allows granting KYC to an account."""
409412
self._require_not_frozen()
410413
self._keys.kyc_key = key
@@ -416,26 +419,35 @@ def set_custom_fees(self, custom_fees: List[CustomFee]) -> "TokenCreateTransacti
416419
self._token_params.custom_fees = custom_fees
417420
return self
418421

419-
def set_fee_schedule_key(self, key: PrivateKey) -> "TokenCreateTransaction":
422+
def set_fee_schedule_key(self, key: Key) -> "TokenCreateTransaction":
420423
"""Sets the fee schedule key for the token."""
421424
self._require_not_frozen()
422425
self._keys.fee_schedule_key = key
423426
return self
424427

425-
def _to_proto_key(self, private_key: Optional[PrivateKey]) -> Optional[basic_types_pb2.Key]:
428+
def _to_proto_key(self, key: Optional[Key]) -> Optional[basic_types_pb2.Key]:
426429
"""
427-
Helper method to convert a private key to protobuf Key format.
430+
Helper method to convert a PrivateKey or PublicKey to protobuf Key format.
428431
429432
Args:
430-
private_key (PrivateKey, Optional): The private key to convert, or None
433+
key (Key, Optional): The private key or public key to convert, or None
431434
432435
Returns:
433-
basic_types_pb2.Key (Optional): The protobuf key or None if private_key is None
436+
basic_types_pb2.Key (Optional): The protobuf key or None if key is None
434437
"""
435-
if not private_key:
438+
if not key:
436439
return None
437440

438-
return private_key.public_key()._to_proto()
441+
# If PrivateKey, get public key first
442+
if isinstance(key, PrivateKey):
443+
return key.public_key()._to_proto()
444+
445+
# If PublicKey, just convert it...
446+
if isinstance(key, PublicKey):
447+
return key._to_proto()
448+
449+
# Handle any other case (though type hinting should prevent this)
450+
raise TypeError("Key must be of type PrivateKey or PublicKey")
439451

440452
def freeze_with(self, client) -> "TokenCreateTransaction":
441453
"""

tests/integration/token_create_transaction_e2e_test.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from hiero_sdk_python.Duration import Duration
55
from hiero_sdk_python.crypto.private_key import PrivateKey
6+
from hiero_sdk_python.crypto.public_key import PublicKey
7+
from hiero_sdk_python.transaction.transaction import Transaction
68
from hiero_sdk_python.tokens.token_type import TokenType
79
from hiero_sdk_python.query.token_info_query import TokenInfoQuery
810
from hiero_sdk_python.timestamp import Timestamp
@@ -146,3 +148,73 @@ def test_fungible_token_create_with_fee_schedule_key():
146148
# TODO (required TokenFeeScheduleUpdateTransaction)
147149
finally:
148150
env.close()
151+
152+
@pytest.mark.integration
153+
def test_token_create_non_custodial_flow():
154+
"""
155+
Tests the full non-custodial flow:
156+
1. Operator builds a TX using only a PublicKey.
157+
2. Operator gets the transaction bytes.
158+
3. User (with the PrivateKey) signs the bytes.
159+
4. Operator executes the signed transaction.
160+
"""
161+
162+
env = IntegrationTestEnv()
163+
client = env.client
164+
165+
try:
166+
# 1. SETUP: Create a new key pair for the "user"
167+
user_private_key = PrivateKey.generate_ed25519()
168+
user_public_key = user_private_key.public_key()
169+
170+
# =================================================================
171+
# STEP 1 & 2: OPERATOR (CLIENT) BUILDS THE TRANSACTION
172+
# =================================================================
173+
174+
tx = (
175+
TokenCreateTransaction()
176+
.set_token_name("NonCustodialToken")
177+
.set_token_symbol("NCT")
178+
.set_token_type(TokenType.FUNGIBLE_COMMON)
179+
.set_treasury_account_id(client.operator_account_id)
180+
.set_initial_supply(100)
181+
.set_admin_key(user_public_key) # <-- The new feature!
182+
.set_supply_key(user_public_key) # <-- Using it again
183+
.freeze_with(client)
184+
)
185+
186+
tx_bytes = tx.to_bytes()
187+
188+
# =================================================================
189+
# STEP 3: USER (SIGNER) SIGNS THE TRANSACTION
190+
# =================================================================
191+
192+
tx_from_bytes = Transaction.from_bytes(tx_bytes)
193+
tx_from_bytes.sign(user_private_key)
194+
195+
# =================================================================
196+
# STEP 4: OPERATOR (CLIENT) EXECUTES THE SIGNED TX
197+
# =================================================================
198+
199+
receipt = tx_from_bytes.execute(client)
200+
201+
assert receipt is not None
202+
token_id = receipt.token_id
203+
assert token_id is not None
204+
205+
print(f"Successfully created non-custodial token: {token_id}")
206+
207+
# PROOF: Query the new token and check if the admin key matches
208+
token_info = TokenInfoQuery(token_id=token_id).execute(client)
209+
210+
assert token_info.admin_key is not None
211+
212+
admin_key_bytes = token_info.admin_key.to_bytes_raw()
213+
public_key_bytes = user_public_key.to_bytes_raw()
214+
215+
assert admin_key_bytes == public_key_bytes
216+
print("Integration Test PASSED: Admin key on network matches public key.")
217+
218+
finally:
219+
# Clean up the environment
220+
env.close()

tests/unit/test_token_create_transaction.py

Lines changed: 73 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,63 @@ def generate_transaction_id(account_id_proto):
6161

6262
########### Basic Tests for Building Transactions ###########
6363

64+
def test_to_proto_key_with_public_key():
65+
"""
66+
Tests the _to_proto_key 'airlock' with a PublicKey.
67+
This is the "new" happy path.
68+
"""
69+
tx = TokenCreateTransaction()
70+
private_key = PrivateKey.generate_ed25519()
71+
public_key = private_key.public_key()
72+
73+
# This is the "proto" object we expect to get back
74+
expected_proto = public_key._to_proto()
75+
76+
# Call the function directly
77+
result_proto = tx._to_proto_key(public_key)
78+
79+
# Assert the result is correct
80+
assert result_proto == expected_proto
81+
assert isinstance(result_proto, basic_types_pb2.Key)
82+
83+
def test_to_proto_key_with_private_key():
84+
"""
85+
Tests the _to_proto_key 'airlock' with a PrivateKey.
86+
This proves backward compatibility.
87+
"""
88+
tx = TokenCreateTransaction()
89+
private_key = PrivateKey.generate_ed25519()
90+
public_key = private_key.public_key()
91+
92+
# We expect the *public key's* proto, even though we passed a private key
93+
expected_proto = public_key._to_proto()
94+
95+
# Call the function with the PrivateKey
96+
result_proto = tx._to_proto_key(private_key)
97+
98+
# Assert it correctly converted it to the public key proto
99+
assert result_proto == expected_proto
100+
assert isinstance(result_proto, basic_types_pb2.Key)
101+
102+
def test_to_proto_key_with_none():
103+
"""
104+
Tests the _to_proto_key 'airlock' with None (a non-happy path).
105+
"""
106+
tx = TokenCreateTransaction()
107+
result = tx._to_proto_key(None)
108+
assert result is None
109+
110+
def test_to_proto_key_with_invalid_string_raises_error():
111+
"""
112+
Tests the _to_proto_key 'airlock' safety net with a string (a non-happy path).
113+
"""
114+
tx = TokenCreateTransaction()
115+
116+
with pytest.raises(TypeError) as e:
117+
tx._to_proto_key("this is not a key")
118+
119+
assert "Key must be of type PrivateKey or PublicKey" in str(e.value)
120+
64121
# This test uses fixture mock_account_ids as parameter
65122
def test_build_transaction_body_without_key(mock_account_ids):
66123
"""Test building a token creation transaction body without an admin, supply or freeze key."""
@@ -260,36 +317,36 @@ def test_sign_transaction(mock_account_ids, mock_client):
260317
private_key.sign.return_value = b"signature"
261318
private_key.public_key().to_bytes_raw.return_value = b"public_key"
262319

263-
private_key_admin = MagicMock()
320+
private_key_admin = MagicMock(spec=PrivateKey)
264321
private_key_admin.sign.return_value = b"admin_signature"
265322
private_key_admin.public_key().to_bytes_raw.return_value = b"admin_public_key"
266323
private_key_admin.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"admin_public_key")
267324

268-
private_key_supply = MagicMock()
325+
private_key_supply = MagicMock(spec=PrivateKey)
269326
private_key_supply.sign.return_value = b"supply_signature"
270327
private_key_supply.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"supply_public_key")
271328

272-
private_key_freeze = MagicMock()
329+
private_key_freeze = MagicMock(spec=PrivateKey)
273330
private_key_freeze.sign.return_value = b"freeze_signature"
274331
private_key_freeze.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"freeze_public_key")
275332

276-
private_key_wipe = MagicMock()
333+
private_key_wipe = MagicMock(spec=PrivateKey)
277334
private_key_wipe.sign.return_value = b"wipe_signature"
278335
private_key_wipe.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"wipe_public_key")
279336

280-
private_key_metadata = MagicMock()
337+
private_key_metadata = MagicMock(spec=PrivateKey)
281338
private_key_metadata.sign.return_value = b"metadata_signature"
282339
private_key_metadata.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"metadata_public_key")
283340

284-
private_key_pause = MagicMock()
341+
private_key_pause = MagicMock(spec=PrivateKey)
285342
private_key_pause.sign.return_value = b"pause_signature"
286343
private_key_pause.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"pause_public_key")
287344

288-
private_key_kyc = MagicMock()
345+
private_key_kyc = MagicMock(spec=PrivateKey)
289346
private_key_kyc.sign.return_value = b"kyc_signature"
290347
private_key_kyc.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"kyc_public_key")
291348

292-
private_key_fee_schedule = MagicMock()
349+
private_key_fee_schedule = MagicMock(spec=PrivateKey)
293350
private_key_fee_schedule.sign.return_value = b"fee_schedule_signature"
294351
private_key_fee_schedule.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"fee_schedule_public_key")
295352

@@ -722,36 +779,36 @@ def test_build_and_sign_nft_transaction_to_proto(mock_account_ids, mock_client):
722779
private_key_private.sign.return_value = b"private_signature"
723780
private_key_private.public_key().to_bytes_raw.return_value = b"private_public_key"
724781

725-
private_key_admin = MagicMock()
782+
private_key_admin = MagicMock(spec=PrivateKey)
726783
private_key_admin.sign.return_value = b"admin_signature"
727784
private_key_admin.public_key().to_bytes_raw.return_value = b"admin_public_key"
728785
private_key_admin.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"admin_public_key")
729786

730-
private_key_supply = MagicMock()
787+
private_key_supply = MagicMock(spec=PrivateKey)
731788
private_key_supply.sign.return_value = b"supply_signature"
732789
private_key_supply.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"supply_public_key")
733790

734-
private_key_freeze = MagicMock()
791+
private_key_freeze = MagicMock(spec=PrivateKey)
735792
private_key_freeze.sign.return_value = b"freeze_signature"
736793
private_key_freeze.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"freeze_public_key")
737794

738-
private_key_wipe = MagicMock()
795+
private_key_wipe = MagicMock(spec=PrivateKey)
739796
private_key_wipe.sign.return_value = b"wipe_signature"
740797
private_key_wipe.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"wipe_public_key")
741798

742-
private_key_metadata = MagicMock()
799+
private_key_metadata = MagicMock(spec=PrivateKey)
743800
private_key_metadata.sign.return_value = b"metadata_signature"
744801
private_key_metadata.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"metadata_public_key")
745802

746-
private_key_pause = MagicMock()
803+
private_key_pause = MagicMock(spec=PrivateKey)
747804
private_key_pause.sign.return_value = b"pause_signature"
748805
private_key_pause.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"pause_public_key")
749806

750-
private_key_kyc = MagicMock()
807+
private_key_kyc = MagicMock(spec=PrivateKey)
751808
private_key_kyc.sign.return_value = b"kyc_signature"
752809
private_key_kyc.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"kyc_public_key")
753810

754-
private_key_fee_schedule = MagicMock()
811+
private_key_fee_schedule = MagicMock(spec=PrivateKey)
755812
private_key_fee_schedule.sign.return_value = b"fee_schedule_signature"
756813
private_key_fee_schedule.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"fee_schedule_public_key")
757814

0 commit comments

Comments
 (0)