From 9b7c41dd1ba8cd7f438d807331ea2772bb8dee28 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Sun, 11 Oct 2026 15:43:52 +0300 Subject: [PATCH] feat(tck): implement executeContract JSON-RPC method Add the executeContract handler, ExecuteContractParams and ExecuteContractResponse to the contract TCK modules, wiring contractId, gas, amount and hex-encoded functionParameters onto ContractExecuteTransaction. ContractExecuteTransaction now rejects negative gas locally, like ContractCreateTransaction, and no longer raises when the contract ID is missing so the network can reject it with INVALID_CONTRACT_ID, matching the other SDKs and the TCK driver's expectations. Fixes #2602 Signed-off-by: Ntege Daniel --- .../contract/contract_execute_transaction.py | 20 ++-- tck/handlers/contract.py | 42 +++++++ tck/param/contract.py | 23 ++++ tck/response/contract.py | 5 + tests/tck/contract_handler_test.py | 104 +++++++++++++++++- .../unit/contract_execute_transaction_test.py | 24 +++- 6 files changed, 207 insertions(+), 11 deletions(-) diff --git a/src/hiero_sdk_python/contract/contract_execute_transaction.py b/src/hiero_sdk_python/contract/contract_execute_transaction.py index 14229fd89..ff7464c2e 100644 --- a/src/hiero_sdk_python/contract/contract_execute_transaction.py +++ b/src/hiero_sdk_python/contract/contract_execute_transaction.py @@ -42,8 +42,13 @@ def __init__( You may pass an integer (tinybars) or an Hbar object. The value is always stored internally as an integer (tinybars). function_parameters (bytes, optional): The parameters to pass to the contract function. + + Raises: + ValueError: If gas is negative. """ super().__init__() + if gas is not None and gas < 0: + raise ValueError("Gas cannot be negative") self.contract_id: ContractId | None = contract_id self.gas: int | None = gas self.amount: int | None = amount.to_tinybars() if isinstance(amount, Hbar) else amount @@ -66,8 +71,13 @@ def set_gas(self, gas: int | None) -> ContractExecuteTransaction: Args: gas (int | None): The gas to use for the contract execution. + + Raises: + ValueError: If gas is negative. """ self._require_not_frozen() + if gas is not None and gas < 0: + raise ValueError("Gas cannot be negative") self.gas = gas return self @@ -124,15 +134,11 @@ def _build_proto_body(self): Returns: ContractCallTransactionBody: The protobuf body for this transaction. - - Raises: - ValueError: If contract_id is not set. """ - if self.contract_id is None: - raise ValueError("Missing required ContractID") - + # A missing contract ID is left for the network to reject with + # INVALID_CONTRACT_ID, matching the other SDKs. return ContractCallTransactionBody( - contractID=self.contract_id._to_proto(), + contractID=(self.contract_id._to_proto() if self.contract_id is not None else None), gas=self.gas, amount=self.amount, functionParameters=self.function_parameters, diff --git a/tck/handlers/contract.py b/tck/handlers/contract.py index 51f5fa002..2db1ef03c 100644 --- a/tck/handlers/contract.py +++ b/tck/handlers/contract.py @@ -5,6 +5,7 @@ from hiero_sdk_python.contract.contract_call_query import ContractCallQuery from hiero_sdk_python.contract.contract_create_transaction import ContractCreateTransaction from hiero_sdk_python.contract.contract_delete_transaction import ContractDeleteTransaction +from hiero_sdk_python.contract.contract_execute_transaction import ContractExecuteTransaction from hiero_sdk_python.contract.contract_function_result import ContractFunctionResult from hiero_sdk_python.contract.contract_id import ContractId from hiero_sdk_python.contract.contract_info import ContractInfo @@ -24,6 +25,7 @@ ContractInfoQueryParams, CreateContractParams, DeleteContractParams, + ExecuteContractParams, UpdateContractParams, ) from tck.response.contract import ( @@ -32,6 +34,7 @@ ContractInfoResponse, CreateContractResponse, DeleteContractResponse, + ExecuteContractResponse, StakingInfoResponse, UpdateContractResponse, ) @@ -229,6 +232,45 @@ def delete_contract(params: DeleteContractParams) -> DeleteContractResponse: return DeleteContractResponse(ResponseCode(receipt.status).name) +def _build_execute_contract_transaction(params: ExecuteContractParams) -> ContractExecuteTransaction: + """Map executeContract JSON-RPC params onto a ContractExecuteTransaction. + + Only supplied params are applied, so SDK defaults stay intact. + """ + transaction = ContractExecuteTransaction().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT) + + if params.contractId is not None: + transaction.set_contract_id(ContractId.from_string(params.contractId)) + + if params.gas is not None: + transaction.set_gas(_require_int64(params.gas, "gas")) + + # Negative amounts are passed through for the network to reject with + # CONTRACT_NEGATIVE_VALUE. + if params.amount is not None: + transaction.set_payable_amount(_require_int64(params.amount, "amount")) + + if params.functionParameters is not None: + transaction.set_function_parameters(decode_hex(params.functionParameters)) + + return transaction + + +@rpc_method("executeContract") +def execute_contract(params: ExecuteContractParams) -> ExecuteContractResponse: + """Execute a smart contract function.""" + client = get_client(params.sessionId) + + transaction = _build_execute_contract_transaction(params) + + if params.commonTransactionParams is not None: + params.commonTransactionParams.apply_common_params(transaction, client) + + receipt = execute_validated(transaction, client) + + return ExecuteContractResponse(ResponseCode(receipt.status).name) + + def _build_contract_call_query(params: ContractCallQueryParams) -> ContractCallQuery: """Build ContractCallQuery from ContractCallQueryParams.""" query = ContractCallQuery().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT) diff --git a/tck/param/contract.py b/tck/param/contract.py index 18f0e00fc..e21b4a3ea 100644 --- a/tck/param/contract.py +++ b/tck/param/contract.py @@ -161,3 +161,26 @@ def parse_json_params(cls, params: dict) -> DeleteContractParams: sessionId=parse_session_id(params), commonTransactionParams=parse_common_transaction_params(params), ) + + +@dataclass +class ExecuteContractParams(BaseTransactionParams): + """Parameters for executing a smart contract function. Extends BaseTransactionParams to include common transaction parameters.""" + + contractId: str | None = None + gas: str | None = None + amount: str | None = None + functionParameters: str | None = None + + @classmethod + def parse_json_params(cls, params: dict) -> ExecuteContractParams: + # The TCK driver sends the call data as "functionParameters" (the spec + # table calls it "parameters"); follow the driver, like the JS TCK server. + return cls( + contractId=params.get("contractId"), + gas=params.get("gas"), + amount=params.get("amount"), + functionParameters=params.get("functionParameters"), + sessionId=parse_session_id(params), + commonTransactionParams=parse_common_transaction_params(params), + ) diff --git a/tck/response/contract.py b/tck/response/contract.py index 3aae2d77f..3966fd5d7 100644 --- a/tck/response/contract.py +++ b/tck/response/contract.py @@ -24,6 +24,11 @@ class DeleteContractResponse(StatusOnlyResponse): """Response payload for deleteContract.""" +@dataclass +class ExecuteContractResponse(StatusOnlyResponse): + """Response payload for executeContract.""" + + @dataclass class ContractCallResponse: """Response payload for contractCallQuery.""" diff --git a/tests/tck/contract_handler_test.py b/tests/tck/contract_handler_test.py index 21ff4459d..5f7c4610a 100644 --- a/tests/tck/contract_handler_test.py +++ b/tests/tck/contract_handler_test.py @@ -6,7 +6,7 @@ from tck.errors import INVALID_PARAMS, JsonRpcError from tck.handlers import contract as contract_handlers -from tck.param.contract import CreateContractParams, UpdateContractParams +from tck.param.contract import CreateContractParams, ExecuteContractParams, UpdateContractParams pytestmark = pytest.mark.unit @@ -44,6 +44,108 @@ def test_gas_out_of_int64_range_raises_invalid_params(self, gas): assert excinfo.value.code == INVALID_PARAMS +class TestExecuteContractParams: + def test_reads_call_data_from_function_parameters_key(self): + params = ExecuteContractParams.parse_json_params( + { + "sessionId": "session-1", + "contractId": "0.0.123", + "gas": "100000", + "amount": "1000", + "functionParameters": "0xdeadbeef", + } + ) + + assert params.contractId == "0.0.123" + assert params.gas == "100000" + assert params.amount == "1000" + assert params.functionParameters == "0xdeadbeef" + assert params.commonTransactionParams is None + + +class TestBuildExecuteContractTransaction: + def test_supplied_fields_are_set(self): + params = ExecuteContractParams( + sessionId="session-1", + contractId="0.0.123", + gas="100000", + amount="1000", + functionParameters="0xdeadbeef", + ) + + transaction = contract_handlers._build_execute_contract_transaction(params) + + assert str(transaction.contract_id) == "0.0.123" + assert transaction.gas == 100000 + assert transaction.amount == 1000 + assert transaction.function_parameters == bytes.fromhex("deadbeef") + + def test_unsupplied_fields_keep_sdk_defaults(self): + params = ExecuteContractParams(sessionId="session-1") + + transaction = contract_handlers._build_execute_contract_transaction(params) + proto_body = transaction._build_proto_body() + + assert transaction.contract_id is None + assert transaction.gas is None + assert transaction.amount is None + assert transaction.function_parameters is None + assert not proto_body.HasField("contractID") + + def test_empty_function_parameters_are_set_as_empty_bytes(self): + params = ExecuteContractParams(sessionId="session-1", functionParameters="") + + transaction = contract_handlers._build_execute_contract_transaction(params) + + assert transaction.function_parameters == b"" + + @pytest.mark.parametrize("amount", ["-100", "-9223372036854775808"]) + def test_negative_amount_is_passed_through(self, amount): + params = ExecuteContractParams(sessionId="session-1", amount=amount) + + transaction = contract_handlers._build_execute_contract_transaction(params) + + assert transaction.amount == int(amount) + + @pytest.mark.parametrize("gas", ["-1", "-9223372036854775808"]) + def test_negative_gas_raises_value_error(self, gas): + params = ExecuteContractParams(sessionId="session-1", gas=gas) + + with pytest.raises(ValueError, match="Gas cannot be negative"): + contract_handlers._build_execute_contract_transaction(params) + + @pytest.mark.parametrize("contract_id", ["invalid", ""]) + def test_invalid_contract_id_raises(self, contract_id): + params = ExecuteContractParams(sessionId="session-1", contractId=contract_id) + + with pytest.raises(ValueError): + contract_handlers._build_execute_contract_transaction(params) + + def test_invalid_hex_function_parameters_raises(self): + params = ExecuteContractParams(sessionId="session-1", functionParameters="0xZZ") + + with pytest.raises(ValueError): + contract_handlers._build_execute_contract_transaction(params) + + @pytest.mark.parametrize( + ("field_name", "value"), + [ + ("gas", "not-a-number"), + ("gas", "9223372036854775808"), + ("amount", "not-a-number"), + ("amount", "9223372036854775808"), + ("amount", "-9223372036854775809"), + ], + ) + def test_invalid_int64_field_raises_invalid_params(self, field_name, value): + params = ExecuteContractParams(sessionId="session-1", **{field_name: value}) + + with pytest.raises(JsonRpcError) as excinfo: + contract_handlers._build_execute_contract_transaction(params) + + assert excinfo.value.code == INVALID_PARAMS + + class TestBuildUpdateContractTransaction: def test_only_supplied_fields_are_set(self): params = UpdateContractParams( diff --git a/tests/unit/contract_execute_transaction_test.py b/tests/unit/contract_execute_transaction_test.py index c76e2edc0..80b11485b 100644 --- a/tests/unit/contract_execute_transaction_test.py +++ b/tests/unit/contract_execute_transaction_test.py @@ -124,11 +124,29 @@ def test_build_scheduled_body_with_valid_parameters(mock_account_ids, execute_pa def test_build_transaction_body_missing_contract_id(): - """Test that build_transaction_body raises ValueError when contract_id is missing.""" + """Test that a missing contract_id is left unset for the network to reject.""" execute_tx = ContractExecuteTransaction() - with pytest.raises(ValueError, match="Missing required ContractID"): - execute_tx.build_transaction_body() + transaction_body = execute_tx.build_transaction_body() + + assert transaction_body.HasField("contractCall") + assert not transaction_body.contractCall.HasField("contractID") + + +def test_set_gas_rejects_negative_values(): + """Test that set_gas rejects negative gas, matching the JS SDK.""" + execute_tx = ContractExecuteTransaction() + + with pytest.raises(ValueError, match="Gas cannot be negative"): + execute_tx.set_gas(-1) + + assert execute_tx.set_gas(0).gas == 0 + + +def test_constructor_rejects_negative_gas(): + """Test that gas supplied via the constructor is validated like set_gas.""" + with pytest.raises(ValueError, match="Gas cannot be negative"): + ContractExecuteTransaction(gas=-1) def test_set_contract_id(execute_params):