Skip to content

Commit 043dbb7

Browse files
authored
feat(tck): implement createContract JSON-RPC method (#2611)
Signed-off-by: exploreriii <133720349+exploreriii@users.noreply.github.com>
1 parent c973743 commit 043dbb7

10 files changed

Lines changed: 421 additions & 23 deletions

File tree

src/hiero_sdk_python/contract/contract_create_transaction.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def __init__(self, contract_params: ContractCreateParams | None = None):
9494
super().__init__()
9595

9696
params = contract_params or ContractCreateParams()
97+
if params.gas is not None and params.gas < 0:
98+
raise ValueError("Gas cannot be negative")
9799
self.bytecode_file_id: FileId | None = params.bytecode_file_id
98100
self.proxy_account_id: AccountId | None = params.proxy_account_id
99101
self.admin_key: Key | None = params.admin_key
@@ -115,6 +117,9 @@ def set_bytecode_file_id(self, bytecode_file_id: FileId | None) -> ContractCreat
115117
"""
116118
Sets the FileID of the file containing the contract bytecode.
117119
120+
The two bytecode sources share the protobuf initcodeSource oneof, so a
121+
non-None value clears any inline bytecode.
122+
118123
Args:
119124
bytecode_file_id (FileId | None): The FileID of the
120125
bytecode file.
@@ -124,6 +129,8 @@ def set_bytecode_file_id(self, bytecode_file_id: FileId | None) -> ContractCreat
124129
"""
125130
self._require_not_frozen()
126131
self.bytecode_file_id = bytecode_file_id
132+
if bytecode_file_id is not None:
133+
self.bytecode = None
127134
return self
128135

129136
def set_bytecode(self, code: bytes | None) -> ContractCreateTransaction:
@@ -133,6 +140,9 @@ def set_bytecode(self, code: bytes | None) -> ContractCreateTransaction:
133140
If the bytecode is small enough, it may be stored directly in the
134141
transaction, otherwise it should be stored in a file.
135142
143+
The two bytecode sources share the protobuf initcodeSource oneof, so a
144+
non-None value clears any bytecode file ID.
145+
136146
Args:
137147
code (bytes | None): The contract bytecode.
138148
@@ -141,7 +151,8 @@ def set_bytecode(self, code: bytes | None) -> ContractCreateTransaction:
141151
"""
142152
self._require_not_frozen()
143153
self.bytecode = code
144-
self.bytecode_file_id = None
154+
if code is not None:
155+
self.bytecode_file_id = None
145156
return self
146157

147158
def set_proxy_account_id(self, proxy_account_id: AccountId | None) -> ContractCreateTransaction:
@@ -181,8 +192,13 @@ def set_gas(self, gas: int | None) -> ContractCreateTransaction:
181192
182193
Returns:
183194
ContractCreateTransaction: This transaction instance.
195+
196+
Raises:
197+
ValueError: If gas is negative.
184198
"""
185199
self._require_not_frozen()
200+
if gas is not None and gas < 0:
201+
raise ValueError("Gas cannot be negative")
186202
self.gas = gas
187203
return self
188204

@@ -284,6 +300,9 @@ def set_staked_account_id(self, staked_account_id: AccountId | None) -> Contract
284300
"""
285301
Sets the account ID to stake to.
286302
303+
The two staking targets share the protobuf staked_id oneof, so a
304+
non-None value clears any staked node ID.
305+
287306
Args:
288307
staked_account_id (AccountId | None): The staked account ID.
289308
@@ -292,12 +311,17 @@ def set_staked_account_id(self, staked_account_id: AccountId | None) -> Contract
292311
"""
293312
self._require_not_frozen()
294313
self.staked_account_id = staked_account_id
314+
if staked_account_id is not None:
315+
self.staked_node_id = None
295316
return self
296317

297318
def set_staked_node_id(self, staked_node_id: int | None) -> ContractCreateTransaction:
298319
"""
299320
Sets the node ID to stake to.
300321
322+
The two staking targets share the protobuf staked_id oneof, so a
323+
non-None value clears any staked account ID.
324+
301325
Args:
302326
staked_node_id (int | None): The staked node ID.
303327
@@ -306,6 +330,8 @@ def set_staked_node_id(self, staked_node_id: int | None) -> ContractCreateTransa
306330
"""
307331
self._require_not_frozen()
308332
self.staked_node_id = staked_node_id
333+
if staked_node_id is not None:
334+
self.staked_account_id = None
309335
return self
310336

311337
def set_decline_reward(self, decline_reward: bool | None) -> ContractCreateTransaction:
@@ -323,26 +349,25 @@ def set_decline_reward(self, decline_reward: bool | None) -> ContractCreateTrans
323349
self.decline_reward = decline_reward
324350
return self
325351

326-
def _validate_parameters(self):
327-
"""Validates the parameters for the contract creation transaction."""
328-
if self.bytecode_file_id is None and self.bytecode is None:
329-
raise ValueError("Either bytecode_file_id or bytecode must be provided")
330-
331-
if self.gas is None:
332-
raise ValueError("Gas limit must be provided")
333-
334352
def _build_proto_body(self):
335353
"""
336354
Returns the protobuf body for the contract create transaction.
337355
356+
Missing fields are not validated client-side; the network reports
357+
errors such as CONTRACT_BYTECODE_EMPTY or INSUFFICIENT_GAS.
358+
338359
Returns:
339360
ContractCreateTransactionBody: The protobuf body for this transaction.
340361
341362
Raises:
342-
ValueError: If required fields are missing.
343-
"""
344-
self._validate_parameters()
345-
363+
ValueError: If both staked_account_id and staked_node_id are set,
364+
or both bytecode and bytecode_file_id are set; each pair shares
365+
a protobuf oneof that would silently drop one of them.
366+
"""
367+
if self.staked_account_id is not None and self.staked_node_id is not None:
368+
raise ValueError("Specify either staked_node_id or staked_account_id, not both.")
369+
if self.bytecode is not None and self.bytecode_file_id is not None:
370+
raise ValueError("Specify either bytecode or bytecode_file_id, not both.")
346371
return ContractCreateTransactionBody(
347372
gas=self.gas,
348373
initialBalance=self.initial_balance,

tck/handlers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from . import (
66
account,
77
allowance,
8+
contract,
89
file,
910
key,
1011
schedule,

tck/handlers/contract.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from __future__ import annotations
2+
3+
from hiero_sdk_python.account.account_id import AccountId
4+
from hiero_sdk_python.contract.contract_create_transaction import ContractCreateTransaction
5+
from hiero_sdk_python.Duration import Duration
6+
from hiero_sdk_python.file.file_id import FileId
7+
from hiero_sdk_python.response_code import ResponseCode
8+
from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt
9+
from tck.errors import JsonRpcError
10+
from tck.handlers.registry import rpc_method
11+
from tck.param.contract import CreateContractParams
12+
from tck.response.contract import CreateContractResponse
13+
from tck.util.client_utils import get_client
14+
from tck.util.constants import DEFAULT_GRPC_TIMEOUT
15+
from tck.util.key_utils import get_key_from_string
16+
from tck.util.param_utils import decode_hex, to_int
17+
18+
19+
INT64_MIN = -(2**63)
20+
INT64_MAX = 2**63 - 1
21+
22+
23+
def _require_int64(value: str, name: str) -> int:
24+
"""Parse an int64 JSON-RPC param transported as a string.
25+
26+
Python ints are unbounded, so enforce the wire type's int64 range here
27+
(gas, initialBalance, autoRenewPeriod, stakedNodeId); boundary values
28+
themselves are valid and left for the network to judge.
29+
"""
30+
parsed = to_int(value)
31+
if parsed is None:
32+
raise JsonRpcError.invalid_params_error(f"{name} must be an integer")
33+
if not INT64_MIN <= parsed <= INT64_MAX:
34+
raise JsonRpcError.invalid_params_error(f"{name} must fit in an int64")
35+
return parsed
36+
37+
38+
def _build_create_contract_transaction(params: CreateContractParams) -> ContractCreateTransaction:
39+
"""Map createContract JSON-RPC params onto a ContractCreateTransaction.
40+
41+
Only supplied params are applied, so SDK defaults stay intact.
42+
"""
43+
transaction = ContractCreateTransaction().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT)
44+
45+
if params.adminKey is not None:
46+
transaction.set_admin_key(get_key_from_string(params.adminKey))
47+
48+
if params.autoRenewPeriod is not None:
49+
transaction.set_auto_renew_period(Duration(_require_int64(params.autoRenewPeriod, "autoRenewPeriod")))
50+
51+
if params.gas is not None:
52+
transaction.set_gas(_require_int64(params.gas, "gas"))
53+
54+
if params.autoRenewAccountId is not None:
55+
transaction.set_auto_renew_account_id(AccountId.from_string(params.autoRenewAccountId))
56+
57+
if params.initialBalance is not None:
58+
transaction.set_initial_balance(_require_int64(params.initialBalance, "initialBalance"))
59+
60+
# Order matters: when both bytecode sources are supplied, bytecodeFileId wins
61+
# (matches the JS TCK server; the setters clear each other).
62+
if params.initcode is not None:
63+
transaction.set_bytecode(decode_hex(params.initcode))
64+
65+
if params.bytecodeFileId is not None:
66+
transaction.set_bytecode_file_id(FileId.from_string(params.bytecodeFileId))
67+
68+
# The SDK's staking-target setters clear each other (the fields share the
69+
# protobuf staked_id oneof), so if both are supplied the one applied last
70+
# (stakedNodeId) wins, matching the JS TCK server.
71+
if params.stakedAccountId is not None:
72+
transaction.set_staked_account_id(AccountId.from_string(params.stakedAccountId))
73+
74+
if params.stakedNodeId is not None:
75+
transaction.set_staked_node_id(_require_int64(params.stakedNodeId, "stakedNodeId"))
76+
77+
if params.declineStakingReward is not None:
78+
transaction.set_decline_reward(params.declineStakingReward)
79+
80+
if params.memo is not None:
81+
transaction.set_contract_memo(params.memo)
82+
83+
if params.maxAutomaticTokenAssociations is not None:
84+
transaction.set_max_automatic_token_associations(params.maxAutomaticTokenAssociations)
85+
86+
if params.constructorParameters is not None:
87+
transaction.set_constructor_parameters(decode_hex(params.constructorParameters))
88+
89+
return transaction
90+
91+
92+
@rpc_method("createContract")
93+
def create_contract(params: CreateContractParams) -> CreateContractResponse:
94+
"""Create a smart contract."""
95+
client = get_client(params.sessionId)
96+
97+
transaction = _build_create_contract_transaction(params)
98+
99+
if params.commonTransactionParams is not None:
100+
params.commonTransactionParams.apply_common_params(transaction, client)
101+
102+
response = transaction.execute(client, wait_for_receipt=False)
103+
receipt: TransactionReceipt = response.get_receipt(client, validate_status=True)
104+
105+
contract_id = ""
106+
if receipt.status == ResponseCode.SUCCESS and receipt.contract_id is not None:
107+
contract_id = str(receipt.contract_id)
108+
109+
return CreateContractResponse(contract_id, ResponseCode(receipt.status).name)

tck/param/contract.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""TCK request parameter models for contract endpoints."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
from tck.param.base import BaseTransactionParams
8+
from tck.util.param_utils import parse_common_transaction_params, parse_session_id
9+
10+
11+
@dataclass
12+
class CreateContractParams(BaseTransactionParams):
13+
"""Parameters for creating a smart contract. Extends BaseTransactionParams to include common transaction parameters."""
14+
15+
bytecodeFileId: str | None = None
16+
initcode: str | None = None
17+
adminKey: str | None = None
18+
gas: str | None = None
19+
initialBalance: str | None = None
20+
constructorParameters: str | None = None
21+
autoRenewPeriod: str | None = None
22+
autoRenewAccountId: str | None = None
23+
memo: str | None = None
24+
stakedAccountId: str | None = None
25+
stakedNodeId: str | None = None
26+
declineStakingReward: bool | None = None
27+
maxAutomaticTokenAssociations: int | None = None
28+
29+
@classmethod
30+
def parse_json_params(cls, params: dict) -> CreateContractParams:
31+
return cls(
32+
bytecodeFileId=params.get("bytecodeFileId"),
33+
initcode=params.get("initcode"),
34+
adminKey=params.get("adminKey"),
35+
gas=params.get("gas"),
36+
initialBalance=params.get("initialBalance"),
37+
constructorParameters=params.get("constructorParameters"),
38+
autoRenewPeriod=params.get("autoRenewPeriod"),
39+
autoRenewAccountId=params.get("autoRenewAccountId"),
40+
memo=params.get("memo"),
41+
stakedAccountId=params.get("stakedAccountId"),
42+
stakedNodeId=params.get("stakedNodeId"),
43+
declineStakingReward=params.get("declineStakingReward"),
44+
maxAutomaticTokenAssociations=params.get("maxAutomaticTokenAssociations"),
45+
sessionId=parse_session_id(params),
46+
commonTransactionParams=parse_common_transaction_params(params),
47+
)

tck/response/contract.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass
7+
class CreateContractResponse:
8+
"""Response payload for createContract."""
9+
10+
contractId: str | None = None
11+
status: str | None = None

tck/util/param_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from __future__ import annotations
22

3+
import re
4+
5+
6+
_HEX_DIGITS_RE = re.compile(r"[0-9a-fA-F]*")
7+
38

49
def parse_session_id(params: dict) -> str:
510
"""Parse sessionId from the json rpc params."""
@@ -52,6 +57,20 @@ def non_empty_string_list(values) -> list[str] | None:
5257
return cleaned_values
5358

5459

60+
def decode_hex(value: str) -> bytes:
61+
"""Decode a hex string (optionally 0x-prefixed) into bytes.
62+
63+
Raises ValueError on odd-length, whitespace-containing, or otherwise
64+
non-hexadecimal input, matching the behaviour of the other SDKs' TCK
65+
servers. bytes.fromhex alone is too lenient: it ignores embedded ASCII
66+
whitespace.
67+
"""
68+
text = value[2:] if value.startswith("0x") else value
69+
if not _HEX_DIGITS_RE.fullmatch(text):
70+
raise ValueError(f"non-hexadecimal characters in hex string: {value!r}")
71+
return bytes.fromhex(text)
72+
73+
5574
def to_bool(value) -> bool | None:
5675
"""Helper to convert value to bool."""
5776
if isinstance(value, str):

tests/tck/contract_handler_test.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Test cases for the TCK createContract handler."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from tck.errors import INVALID_PARAMS, JsonRpcError
8+
from tck.handlers import contract as contract_handlers
9+
from tck.param.contract import CreateContractParams
10+
11+
12+
pytestmark = pytest.mark.unit
13+
14+
15+
class TestBuildCreateContractTransaction:
16+
def test_bytecode_file_id_wins_when_both_sources_supplied(self):
17+
params = CreateContractParams(
18+
sessionId="session-1",
19+
initcode="0x60006000",
20+
bytecodeFileId="0.0.123",
21+
gas="1000000",
22+
)
23+
24+
transaction = contract_handlers._build_create_contract_transaction(params)
25+
26+
assert str(transaction.bytecode_file_id) == "0.0.123"
27+
assert transaction.bytecode is None
28+
29+
def test_invalid_gas_raises_invalid_params(self):
30+
params = CreateContractParams(sessionId="session-1", gas="not-a-number")
31+
32+
with pytest.raises(JsonRpcError) as excinfo:
33+
contract_handlers._build_create_contract_transaction(params)
34+
35+
assert excinfo.value.code == INVALID_PARAMS
36+
37+
@pytest.mark.parametrize("gas", ["9223372036854775808", "-9223372036854775809"])
38+
def test_gas_out_of_int64_range_raises_invalid_params(self, gas):
39+
params = CreateContractParams(sessionId="session-1", gas=gas)
40+
41+
with pytest.raises(JsonRpcError) as excinfo:
42+
contract_handlers._build_create_contract_transaction(params)
43+
44+
assert excinfo.value.code == INVALID_PARAMS

0 commit comments

Comments
 (0)