Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/hiero_sdk_python/contract/contract_execute_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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),
Comment thread
danielmarv marked this conversation as resolved.
gas=self.gas,
amount=self.amount,
functionParameters=self.function_parameters,
Expand Down
42 changes: 42 additions & 0 deletions tck/handlers/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@
ContractInfoQueryParams,
CreateContractParams,
DeleteContractParams,
ExecuteContractParams,
UpdateContractParams,
)
from tck.response.contract import (
Expand All @@ -32,6 +34,7 @@
ContractInfoResponse,
CreateContractResponse,
DeleteContractResponse,
ExecuteContractResponse,
StakingInfoResponse,
UpdateContractResponse,
)
Expand Down Expand Up @@ -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"))
Comment thread
danielmarv marked this conversation as resolved.

# 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)
Expand Down
23 changes: 23 additions & 0 deletions tck/param/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment thread
danielmarv marked this conversation as resolved.
sessionId=parse_session_id(params),
commonTransactionParams=parse_common_transaction_params(params),
)
5 changes: 5 additions & 0 deletions tck/response/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
104 changes: 103 additions & 1 deletion tests/tck/contract_handler_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Comment thread
danielmarv marked this conversation as resolved.
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(
Expand Down
24 changes: 21 additions & 3 deletions tests/unit/contract_execute_transaction_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading