Skip to content

Commit 3af0f20

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

File tree

4 files changed

+183
-39
lines changed

4 files changed

+183
-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: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,65 @@ def generate_transaction_id(account_id_proto):
6161

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

64+
def test_set_key_with_public_key(mock_client):
65+
"""
66+
Tests the NEW path: setting a key using a PublicKey.
67+
This proves the non-custodial path works.
68+
"""
69+
# 1. Create a real key pair
70+
real_private_key = PrivateKey.generate_ed25519()
71+
real_public_key = real_private_key.public_key()
72+
73+
# 2. Build the transaction
74+
token_tx = TokenCreateTransaction(
75+
TokenParams(
76+
token_name="MyToken",
77+
token_symbol="MTK",
78+
treasury_account_id=AccountId(0, 0, 123),
79+
initial_supply=100, # Must be > 0 for fungible
80+
token_type=TokenType.FUNGIBLE_COMMON,
81+
)
82+
)
83+
84+
# 3. Use the NEW feature: set a key using a PublicKey
85+
token_tx.set_admin_key(real_public_key)
86+
87+
# 4. Spy on the _to_proto_key method
88+
token_tx._to_proto_key = MagicMock(return_value=real_public_key._to_proto())
89+
90+
# 5. Freeze (this calls _build_proto_body)
91+
token_tx.freeze_with(mock_client)
92+
93+
# 6. THE PROOF: Assert that _to_proto_key was called
94+
# with the PublicKey object at least once.
95+
token_tx._to_proto_key.assert_any_call(real_public_key)
96+
97+
def test_set_key_with_invalid_type_raises_error(mock_client):
98+
"""
99+
Tests the SAFETY NET: proves that passing a non-key type fails
100+
when the transaction is built.
101+
"""
102+
token_tx = TokenCreateTransaction(
103+
TokenParams(
104+
token_name="MyToken",
105+
token_symbol="MTK",
106+
treasury_account_id=AccountId(0, 0, 123),
107+
initial_supply=100,
108+
token_type=TokenType.FUNGIBLE_COMMON
109+
)
110+
)
111+
112+
# Try to set an invalid key type
113+
token_tx.set_admin_key("this is just a string")
114+
115+
# Prove that the transaction build fails
116+
# with the exact error we want
117+
with pytest.raises(TypeError) as e:
118+
token_tx.freeze_with(mock_client) # This calls _build_proto_body
119+
120+
# Check that our specific error was raised
121+
assert "Key must be of type PrivateKey or PublicKey" in str(e.value)
122+
64123
# This test uses fixture mock_account_ids as parameter
65124
def test_build_transaction_body_without_key(mock_account_ids):
66125
"""Test building a token creation transaction body without an admin, supply or freeze key."""
@@ -260,36 +319,36 @@ def test_sign_transaction(mock_account_ids, mock_client):
260319
private_key.sign.return_value = b"signature"
261320
private_key.public_key().to_bytes_raw.return_value = b"public_key"
262321

263-
private_key_admin = MagicMock()
322+
private_key_admin = MagicMock(spec=PrivateKey)
264323
private_key_admin.sign.return_value = b"admin_signature"
265324
private_key_admin.public_key().to_bytes_raw.return_value = b"admin_public_key"
266325
private_key_admin.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"admin_public_key")
267326

268-
private_key_supply = MagicMock()
327+
private_key_supply = MagicMock(spec=PrivateKey)
269328
private_key_supply.sign.return_value = b"supply_signature"
270329
private_key_supply.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"supply_public_key")
271330

272-
private_key_freeze = MagicMock()
331+
private_key_freeze = MagicMock(spec=PrivateKey)
273332
private_key_freeze.sign.return_value = b"freeze_signature"
274333
private_key_freeze.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"freeze_public_key")
275334

276-
private_key_wipe = MagicMock()
335+
private_key_wipe = MagicMock(spec=PrivateKey)
277336
private_key_wipe.sign.return_value = b"wipe_signature"
278337
private_key_wipe.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"wipe_public_key")
279338

280-
private_key_metadata = MagicMock()
339+
private_key_metadata = MagicMock(spec=PrivateKey)
281340
private_key_metadata.sign.return_value = b"metadata_signature"
282341
private_key_metadata.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"metadata_public_key")
283342

284-
private_key_pause = MagicMock()
343+
private_key_pause = MagicMock(spec=PrivateKey)
285344
private_key_pause.sign.return_value = b"pause_signature"
286345
private_key_pause.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"pause_public_key")
287346

288-
private_key_kyc = MagicMock()
347+
private_key_kyc = MagicMock(spec=PrivateKey)
289348
private_key_kyc.sign.return_value = b"kyc_signature"
290349
private_key_kyc.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"kyc_public_key")
291350

292-
private_key_fee_schedule = MagicMock()
351+
private_key_fee_schedule = MagicMock(spec=PrivateKey)
293352
private_key_fee_schedule.sign.return_value = b"fee_schedule_signature"
294353
private_key_fee_schedule.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"fee_schedule_public_key")
295354

@@ -722,36 +781,36 @@ def test_build_and_sign_nft_transaction_to_proto(mock_account_ids, mock_client):
722781
private_key_private.sign.return_value = b"private_signature"
723782
private_key_private.public_key().to_bytes_raw.return_value = b"private_public_key"
724783

725-
private_key_admin = MagicMock()
784+
private_key_admin = MagicMock(spec=PrivateKey)
726785
private_key_admin.sign.return_value = b"admin_signature"
727786
private_key_admin.public_key().to_bytes_raw.return_value = b"admin_public_key"
728787
private_key_admin.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"admin_public_key")
729788

730-
private_key_supply = MagicMock()
789+
private_key_supply = MagicMock(spec=PrivateKey)
731790
private_key_supply.sign.return_value = b"supply_signature"
732791
private_key_supply.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"supply_public_key")
733792

734-
private_key_freeze = MagicMock()
793+
private_key_freeze = MagicMock(spec=PrivateKey)
735794
private_key_freeze.sign.return_value = b"freeze_signature"
736795
private_key_freeze.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"freeze_public_key")
737796

738-
private_key_wipe = MagicMock()
797+
private_key_wipe = MagicMock(spec=PrivateKey)
739798
private_key_wipe.sign.return_value = b"wipe_signature"
740799
private_key_wipe.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"wipe_public_key")
741800

742-
private_key_metadata = MagicMock()
801+
private_key_metadata = MagicMock(spec=PrivateKey)
743802
private_key_metadata.sign.return_value = b"metadata_signature"
744803
private_key_metadata.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"metadata_public_key")
745804

746-
private_key_pause = MagicMock()
805+
private_key_pause = MagicMock(spec=PrivateKey)
747806
private_key_pause.sign.return_value = b"pause_signature"
748807
private_key_pause.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"pause_public_key")
749808

750-
private_key_kyc = MagicMock()
809+
private_key_kyc = MagicMock(spec=PrivateKey)
751810
private_key_kyc.sign.return_value = b"kyc_signature"
752811
private_key_kyc.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"kyc_public_key")
753812

754-
private_key_fee_schedule = MagicMock()
813+
private_key_fee_schedule = MagicMock(spec=PrivateKey)
755814
private_key_fee_schedule.sign.return_value = b"fee_schedule_signature"
756815
private_key_fee_schedule.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"fee_schedule_public_key")
757816

0 commit comments

Comments
 (0)