diff --git a/.env.example b/.env.example index a4f371c0d..d31f966cd 100644 --- a/.env.example +++ b/.env.example @@ -42,7 +42,7 @@ GENVM_SOURCE_MODE="" GENVM_TAG="" # Exact release tag, e.g. v0.6.0-rc0. Mutually exclusive with GENVM_REF. GENVM_REF="" # Source git ref/SHA; set mode=source, or use : for auto source mode. GENVM_EXECUTOR_VERSION_NAME="" # Optional source-build executor label, e.g. v0.3.0-e2e-dev. -NIX_NETRC_FILE="/dev/null" # Netrc with a nix cache pull token; must exist, empty one just means no cache hits. +NIX_NETRC_FILE="./docker/empty.netrc" # Netrc with a nix cache pull token; must exist, empty one just means no cache hits. GENVM_LLM_DEBUG="0" GENVM_WEB_DEBUG="0" diff --git a/.github/workflows/genvm-lint.yml b/.github/workflows/genvm-lint.yml index 120b1c929..75044aea6 100644 --- a/.github/workflows/genvm-lint.yml +++ b/.github/workflows/genvm-lint.yml @@ -35,8 +35,8 @@ jobs: # Contracts pinned to runners newer than the released linter's bundled # SDK cannot be loaded by genvm-linter (<= 0.11.0): the genvm-main # runner (1zr6nqk...) fails with "No module named 'genlayer.py'", and - # the v0.3.0-rc7 runner (bq43ya7v...) fails with - # "filename 'runners/py-genlayer/bq/43ya....zip' not found". Skip both + # the v0.3 runners (5jycge4q... and babwp9t3...) fail because their + # archives are not bundled. Skip them # until a linter release bundles them (genvm-linter dxp-694), then # drop this filter. - name: Lint example contracts @@ -44,7 +44,7 @@ jobs: failed=0 for f in examples/contracts/*.py; do echo "::group::$f" - if grep -qE "py-genlayer:(1zr6nqk597d97kg0dyxg0shhrykx5v02zjgnyrajapy4wlqvfvwh|5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng)" "$f"; then + if grep -qE "py-genlayer:(1zr6nqk597d97kg0dyxg0shhrykx5v02zjgnyrajapy4wlqvfvwh|5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng|babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0)" "$f"; then echo "SKIP: pinned to a runner unsupported by released genvm-linter" elif ! genvm-lint check "$f"; then failed=1 @@ -59,7 +59,7 @@ jobs: for f in tests/load/contracts/*.py tests/direct/contracts/*.py; do [ -f "$f" ] || continue echo "::group::$f" - if grep -qE "py-genlayer:(1zr6nqk597d97kg0dyxg0shhrykx5v02zjgnyrajapy4wlqvfvwh|5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng)" "$f"; then + if grep -qE "py-genlayer:(1zr6nqk597d97kg0dyxg0shhrykx5v02zjgnyrajapy4wlqvfvwh|5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng|babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0)" "$f"; then echo "SKIP: pinned to a runner unsupported by released genvm-linter" elif ! genvm-lint check "$f"; then failed=1 diff --git a/backend/consensus/base.py b/backend/consensus/base.py index a26bdeee7..0a7c0b340 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -85,6 +85,7 @@ runtime_rotations_for_round, stamp_receipt_execution_policy, unwind_reveal_message_fees, + uses_unmetered_message_fee_pool, validate_receipt_admission_caps, ) from backend.rollup.consensus_service import ConsensusService @@ -4145,7 +4146,15 @@ def _attach_child_fee_accounting( pending_transaction: PendingTransaction, data: dict, ) -> None: - if int(message_payload.get("declaredBudget", 0) or 0) <= 0: + if ( + int(message_payload.get("declaredBudget", 0) or 0) <= 0 + and not pending_transaction.use_balance + and not parent_fee_accounting.get("message_allocations_from_grant") + ): + return + if not pending_transaction.use_balance and uses_unmetered_message_fee_pool( + parent_fee_accounting + ): return try: @@ -4160,9 +4169,21 @@ def _attach_child_fee_accounting( sender=( context.transaction.to_address if pending_transaction.use_balance - else context.transaction.origin_address + else next( + ( + contribution["depositor"] + for contribution in parent_fee_accounting.get("contributions") + or [] + if contribution.get("depositor") + ), + parent_fee_accounting.get("sender"), + ) + or context.transaction.origin_address or context.transaction.from_address ), + message_allocations_from_grant=bool( + parent_fee_accounting.get("message_allocations_from_grant") + ), policy=execution_policy_for_accounting( parent_fee_accounting, StudioFeePolicy.from_env(), diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index 8e3e372a9..be8d41896 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -437,6 +437,13 @@ def _emission_int(emission: dict, name: str) -> int: return int(_emission_value(emission, name) or 0) +def _emission_declared_budget(emission: dict) -> int: + value = _emission_value(emission, "messageFee") + if value is None: + value = _emission_value(emission, "declaredBudget") + return int(value or 0) + + def _emission_on(emission: dict) -> typing.Literal["accepted", "finalized"]: """GenVM calls the pre-finalization lifecycle `decided`; Studio calls it `accepted`. @@ -634,7 +641,7 @@ def provide_result( value=emission["value"], on=_emission_on(emission), fee_params=_emission_internal_fee_params(emission), - declared_budget=_emission_int(emission, "declaredBudget"), + declared_budget=_emission_declared_budget(emission), call_key=_emission_hex(emission, "callKey"), allocation_subtree=_emission_allocation_subtree(emission), use_balance=bool( @@ -652,7 +659,7 @@ def provide_result( value=emission["value"], on=_emission_on(emission), fee_params=_emission_internal_fee_params(emission), - declared_budget=_emission_int(emission, "declaredBudget"), + declared_budget=_emission_declared_budget(emission), call_key=_emission_hex(emission, "callKey"), allocation_subtree=_emission_allocation_subtree(emission), use_balance=bool( @@ -671,7 +678,7 @@ def provide_result( on="finalized", is_eth_send=True, fee_params=_emission_external_fee_params(emission), - declared_budget=_emission_int(emission, "declaredBudget"), + declared_budget=_emission_declared_budget(emission), call_key=_emission_hex(emission, "callKey"), allocation_subtree=_emission_allocation_subtree(emission), gas_used=_emission_int(emission, "gasUsed"), diff --git a/backend/node/genvm/origin/base_host.py b/backend/node/genvm/origin/base_host.py index df98456f6..20b0a11ad 100644 --- a/backend/node/genvm/origin/base_host.py +++ b/backend/node/genvm/origin/base_host.py @@ -127,17 +127,19 @@ class UnsafeOverrides: Request overrides that reach boundaries production traffic cannot. Each member states the `debug_mode` the manager requires before it applies: - `reroute_to` from `safe`, `initial_recursion` from `unsafe`. With debugging + `reroute_to` from `safe`, other overrides from `unsafe`. With debugging disabled none of them take effect. """ reroute_to: str = "" initial_recursion: int | None = None + allow_two_workers: bool | None = None def as_request_field(self) -> dict[str, typing.Any]: return { "reroute_to": self.reroute_to, "initial_recursion": self.initial_recursion, + "allow_two_workers": self.allow_two_workers, } @@ -685,9 +687,9 @@ def __init__(self, message: str, *, genvm_id: int): super().__init__(message) -# Transient manager refusals worth retrying. The manager reports these with the -# generic `Errors.INTERNAL` code (no dedicated variant yet), so the message is -# the only discriminator -- kept here so callers never have to string-match. +# Transient manager refusals worth retrying. They arrive as a `failed_to_start` +# error string, so the message is the only discriminator -- kept here so callers +# never have to string-match. _RETRYABLE_RUN_REFUSAL_MARKERS: typing.Final = ( "modules are required but not running", "modules are required but not all are running", diff --git a/backend/node/genvm/origin/fees.py b/backend/node/genvm/origin/fees.py index 26ca9fd59..0f540bfa5 100644 --- a/backend/node/genvm/origin/fees.py +++ b/backend/node/genvm/origin/fees.py @@ -51,6 +51,22 @@ class MessageAllocationNode(typing.TypedDict): subtree: bytes +UNMATCHED_EXTERNAL_GUARD_ALLOC: MessageAllocationNode = { + "recipient": None, + "call_key": None, + "budget": 0, + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": 1, + "max_gas_price": 2**256 - 1, + }, + }, + "children_budget": 0, + "subtree": b"", +} + + DEFAULT_EXTERNAL_MESSAGE_ALLOC: MessageAllocationNode = { "budget": 2**200, "recipient": None, diff --git a/backend/node/genvm/origin/public_abi.py b/backend/node/genvm/origin/public_abi.py index aeaaeb9d9..d43ed72a2 100644 --- a/backend/node/genvm/origin/public_abi.py +++ b/backend/node/genvm/origin/public_abi.py @@ -277,15 +277,25 @@ def external() -> "VmError": return VmError("fee no_matching_allocation # external") +class _VmErrorFeeDescendantGrant: + @staticmethod + def budget() -> "VmError": + return VmError("fee descendant_grant budget") + + @staticmethod + def tree() -> "VmError": + return VmError("fee descendant_grant tree") + + @staticmethod + def external() -> "VmError": + return VmError("fee descendant_grant external") + + class _VmErrorFee: @staticmethod def below_minimum() -> "VmError": return VmError("fee below_minimum") - @staticmethod - def phase_timeout_out_of_bounds() -> "VmError": - return VmError("fee phase_timeout_out_of_bounds") - @staticmethod def too_many_rounds() -> "VmError": return VmError("fee too_many_rounds") @@ -294,6 +304,10 @@ def too_many_rounds() -> "VmError": def no_matching_allocation() -> "_VmErrorFeeNoMatchingAllocation": return _VmErrorFeeNoMatchingAllocation() + @staticmethod + def descendant_grant() -> "_VmErrorFeeDescendantGrant": + return _VmErrorFeeDescendantGrant() + class _VmErrorEvm: @staticmethod diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 4f59216ac..108b2623a 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -4,6 +4,7 @@ import copy import os from dataclasses import dataclass, fields, replace +from enum import Enum from typing import Any, Callable import rlp @@ -18,6 +19,7 @@ logical_fee_round_entries, ) from backend.consensus.types import ConsensusRound +from backend.node.genvm.origin.fees import UNMATCHED_EXTERNAL_GUARD_ALLOC VALIDATORS_PER_ROUND = ( 5, @@ -82,9 +84,17 @@ MESSAGE_ALLOCATION_NODE_ABI_TYPE = ( "(uint8,bool,uint256,address,bytes32,uint256,bytes)[]" ) +CONTRACT_DESCENDANT_GRANT_ABI_TYPE = ( + "(uint8,uint8,uint256,(uint8,bool,uint256,address,bytes32,uint256,bytes)[])" +) SUBMITTED_MESSAGE_ABI_TYPE = ( "(uint8,address,uint256,bytes,bool,uint256,bytes,uint256,bytes,bytes32,bool)[]" ) +CONTRACT_DESCENDANT_GRANT_DOMAIN = keccak(b"genlayer.contract-descendant-grant") +CONTRACT_DESCENDANT_GRANT_VERSION = 1 +MAX_CONTRACT_DESCENDANT_GRANT_NODES = 24 +MAX_CONTRACT_DESCENDANT_FEE_PARAMS_BYTES = 1_024 +MAX_CONTRACT_DESCENDANT_GRANT_BYTES = 31_712 WEI_PER_GEN = 10**18 # Keep the standalone Studio policy identical to the v0.6 deployment defaults. @@ -205,8 +215,8 @@ class InvalidFeeParams(FeeValidationError): pass -class Mode1MessageFeesRequireGenVMPerEmissionSupport(FeeValidationError): - """GenVM must expose per-emission feeParams/declaredBudget before Mode 1 is safe.""" +class InvalidContractDescendantGrant(FeeValidationError): + pass class InvalidAppealBond(FeeValidationError): @@ -279,6 +289,19 @@ class MessageEffectDescriptorMismatch(FeeValidationError): pass +class MessageAllocationPolicy(str, Enum): + OPEN = "open" + PINNED = "pinned" + CLOSED = "closed" + + +@dataclass(frozen=True) +class ContractDescendantGrant: + policy: MessageAllocationPolicy + budget: int + allocations: list[dict[str, Any]] + + @dataclass(frozen=True) class StudioFeePolicy: gen_per_time_unit: int = 0 @@ -1550,7 +1573,10 @@ def calculate_round_fees( policy = policy or StudioFeePolicy() if round == 0: - time_unit_work = _calculate_initial_round_total(fees, num_of_validators) + time_unit_work = _calculate_initial_round_total( + fees, + num_of_validators, + ) appeal_profit_reserve = _calculate_appeal_profit_reserve( fees, gen_per_time_unit=int(fees["maxPriceGenPerTimeUnit"]), @@ -1916,53 +1942,305 @@ def activate_fee_accounting( return updated, False +def decode_contract_descendant_grant(value: Any) -> ContractDescendantGrant: + if isinstance(value, str) and value.startswith("0x"): + try: + bytes.fromhex(value[2:]) + except ValueError as exc: + raise InvalidContractDescendantGrant( + "InvalidContractDescendantGrant" + ) from exc + try: + raw = _allocation_subtree_bytes(value) + except Exception as exc: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") from exc + if not raw: + return ContractDescendantGrant( + policy=MessageAllocationPolicy.CLOSED, + budget=0, + allocations=[], + ) + if len(raw) > MAX_CONTRACT_DESCENDANT_GRANT_BYTES: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + + try: + domain, decoded = decode(["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], raw) + canonical = encode( + ["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], + [domain, decoded], + ) + except Exception as exc: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") from exc + + version, wire_policy, budget, decoded_nodes = decoded + policies = { + 0: MessageAllocationPolicy.CLOSED, + 1: MessageAllocationPolicy.OPEN, + 2: MessageAllocationPolicy.PINNED, + } + grant_policy = policies.get(int(wire_policy)) + if ( + bytes(domain) != CONTRACT_DESCENDANT_GRANT_DOMAIN + or int(version) != CONTRACT_DESCENDANT_GRANT_VERSION + or grant_policy is None + or canonical != raw + ): + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + + allocations = [ + { + "messageType": int(node[0]), + "onAcceptance": bool(node[1]), + "parentIndex": int(node[2]), + "recipient": str(node[3]).lower(), + "callKey": "0x" + bytes(node[4]).hex(), + "budget": int(node[5]), + "feeParams": "0x" + bytes(node[6]).hex(), + } + for node in decoded_nodes + ] + budget = int(budget) + valid_shape = ( + ( + grant_policy is MessageAllocationPolicy.CLOSED + and budget == 0 + and not allocations + ) + or ( + grant_policy is MessageAllocationPolicy.OPEN + and budget > 0 + and not allocations + ) + or ( + grant_policy is MessageAllocationPolicy.PINNED + and budget > 0 + and bool(allocations) + ) + ) + if not valid_shape: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + return ContractDescendantGrant(grant_policy, budget, allocations) + + +def _is_contract_descendant_grant(value: Any) -> bool: + try: + raw = _allocation_subtree_bytes(value) + except Exception: + return False + return len(raw) >= 32 and raw[:32] == CONTRACT_DESCENDANT_GRANT_DOMAIN + + +def _validate_contract_descendant_grant( + grant: ContractDescendantGrant, + *, + child_fee_params: dict[str, Any], + policy: StudioFeePolicy, +) -> None: + if grant.policy is not MessageAllocationPolicy.PINNED: + return + + allocations = grant.allocations + if len(allocations) > MAX_CONTRACT_DESCENDANT_GRANT_NODES: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + children_budgets = [0] * len(allocations) + depths = [0] * len(allocations) + internal_params: list[dict[str, Any] | None] = [None] * len(allocations) + sibling_keys: set[tuple[int, int, str, str]] = set() + root_sum = 0 + + for index, raw_node in enumerate(allocations): + node = _normalize_message_allocation(raw_node) + message_type = int(node["messageType"]) + parent_index = int(node["parentIndex"]) + budget = _require_uint256(int(node["budget"])) + if budget == 0: + raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") + if message_type not in {MESSAGE_TYPE_EXTERNAL, MESSAGE_TYPE_INTERNAL}: + raise AllocationTreeMalformed("AllocationTreeMalformed") + if parent_index != NODE_ROOT_SENTINEL: + if parent_index >= index: + raise AllocationTreeMalformed("AllocationTreeMalformed") + parent = _normalize_message_allocation(allocations[parent_index]) + if int(parent["messageType"]) != MESSAGE_TYPE_INTERNAL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + key = (parent_index, *_allocation_key(node)) + if key in sibling_keys: + raise AllocationDuplicateKey("AllocationDuplicateKey") + sibling_keys.add(key) + + depths[index] = ( + 1 if parent_index == NODE_ROOT_SENTINEL else depths[parent_index] + 1 + ) + if depths[index] > int(policy.max_allocation_tree_depth or 5): + raise AllocationTreeTooDeep("AllocationTreeTooDeep") + + if parent_index == NODE_ROOT_SENTINEL: + root_sum = _u256_add(root_sum, budget) + else: + children_budgets[parent_index] = _u256_add( + children_budgets[parent_index], budget + ) + + raw_fee_params = _fee_params_bytes(node["feeParams"]) + if len(raw_fee_params) > MAX_CONTRACT_DESCENDANT_FEE_PARAMS_BYTES: + raise InvalidFeeParams("InvalidFeeParams") + if message_type == MESSAGE_TYPE_EXTERNAL: + _validate_contract_external_allocation(node, raw_fee_params, policy) + else: + internal_params[index] = _validate_contract_internal_fee_params( + raw_fee_params, + policy, + ) + + if root_sum != grant.budget: + raise MessageAllocationsNotEqualBudget("MessageAllocationsNotEqualBudget") + + for index, raw_node in enumerate(allocations): + node = _normalize_message_allocation(raw_node) + params = internal_params[index] + if params is None: + continue + parent_index = int(node["parentIndex"]) + parent_params = ( + child_fee_params + if parent_index == NODE_ROOT_SENTINEL + else internal_params[parent_index] + ) + if parent_params is None: + raise AllocationTreeMalformed("AllocationTreeMalformed") + multiplier = ( + int(parent_params["appealRounds"]) + 1 if node["onAcceptance"] else 1 + ) + required = _u256_mul( + _u256_add( + min_message_primary_fees(params, policy), + children_budgets[index], + ), + multiplier, + ) + if int(node["budget"]) < required: + raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") + + +def _validate_contract_internal_fee_params( + raw_fee_params: bytes, + policy: StudioFeePolicy, +) -> dict[str, Any]: + params = decode_internal_message_fee_params(raw_fee_params) + try: + decoded = decode([INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], raw_fee_params)[0] + canonical = encode([INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], [decoded]) + except Exception as exc: + raise InvalidFeeParams("InvalidFeeParams") from exc + if canonical != raw_fee_params: + raise InvalidFeeParams("InvalidFeeParams") + + rotations = params["rotations"] + if not rotations or int(params["appealRounds"]) != len(rotations) - 1: + raise InvalidAppealRounds("InvalidAppealRounds") + leader = int(params["leaderTimeunitsAllocation"]) + validator = int(params["validatorTimeunitsAllocation"]) + if (leader == 0) != (validator == 0): + raise PhaseTimeoutOutOfBounds("PhaseTimeoutOutOfBounds") + if leader > 0: + _validate_phase_timeout_bounds(leader, validator, policy) + + _validate_internal_message_price_caps(params) + _validate_internal_execution_budget_floor(params, policy) + for field in ( + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", + "executionBudgetPerRound", + ): + if int(params[field]).bit_length() > 96: + raise InvalidFeeParams("InvalidFeeParams") + for value in (leader, validator, *rotations): + if int(value).bit_length() > 32: + raise InvalidFeeParams("InvalidFeeParams") + return params + + +def _validate_contract_external_allocation( + node: dict[str, Any], + raw_fee_params: bytes, + policy: StudioFeePolicy, +) -> None: + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + if bool(node["onAcceptance"]): + raise ExternalAllocationInvalid("ExternalOnAcceptanceNotSupported") + params = decode_external_message_fee_params(raw_fee_params) + if ( + encode( + [EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [(int(params["gasLimit"]), int(params["maxGasPrice"]))], + ) + != raw_fee_params + ): + raise InvalidFeeParams("InvalidFeeParams") + gas_limit = int(params["gasLimit"]) + max_gas_price = int(params["maxGasPrice"]) + if ( + gas_limit <= 0 + or max_gas_price <= 0 + or gas_limit < int(policy.min_external_gas_limit) + ): + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + per_call = _u256_mul(gas_limit, max_gas_price) + budget = int(node["budget"]) + if budget < per_call or budget % per_call != 0: + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + + def create_child_fee_accounting( *, message: dict[str, Any], parent_fees_distribution: dict[str, Any] | None, message_allocations: list[dict[str, Any]] | str | None = None, + message_allocations_from_grant: bool = False, sender: str | None = None, policy: StudioFeePolicy | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: policy = policy or StudioFeePolicy() declared_budget = int(message.get("declaredBudget", 0) or 0) + use_balance = bool(message.get("useBalance", False)) # Consensus permits a zero-budget child when its complete calculated # primary obligation and allocation subtree are also zero. Negative # values are impossible in the ABI and remain invalid locally. if declared_budget < 0: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") - fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) - _validate_internal_message_price_caps(fee_params) - _validate_phase_timeout_bounds( - int(fee_params["leaderTimeunitsAllocation"]), - int(fee_params["validatorTimeunitsAllocation"]), - policy, - allow_zero=True, - ) - funding_child_fees = _fees_distribution_from_internal_params( - fee_params, - total_message_fees=0, - parent_fees_distribution=normalize_fees_distribution({}), - ) - # Consensus prices child funding at the child's declared GEN ceiling. The - # live GEN price is checked only when the child activates, while storage - # and receipt ceilings guard the child's later work and therefore do not - # participate in this floor calculation. - funding_child_fees["storageFeeMaxGasPrice"] = 0 - funding_child_fees["receiptFeeMaxGasPrice"] = 0 - execution_budget_per_round = int(funding_child_fees["executionBudgetPerRound"]) + raw_fee_params = _fee_params_bytes(message.get("feeParams", b"")) + grant: ContractDescendantGrant | None = None + if use_balance: + grant = decode_contract_descendant_grant(message.get("allocationSubtree")) + fee_params = _validate_contract_internal_fee_params( + raw_fee_params, + policy, + ) + else: + fee_params = decode_internal_message_fee_params(raw_fee_params) + _validate_internal_message_price_caps(fee_params) + _validate_phase_timeout_bounds( + int(fee_params["leaderTimeunitsAllocation"]), + int(fee_params["validatorTimeunitsAllocation"]), + policy, + allow_zero=True, + ) + execution_budget_per_round = int(fee_params["executionBudgetPerRound"]) if ( execution_budget_per_round > 0 and execution_budget_per_round < policy.message_fee_params_budget_floor() ): raise BudgetTooLow("BudgetTooLow") - child_primary = calculate_round_fees( - funding_child_fees, - VALIDATORS_PER_ROUND[0], - 0, + # Child funding uses its declared GEN ceiling; storage and receipt caps + # guard later work and do not participate in this quote. + child_primary = min_message_primary_fees( + fee_params, policy, - enforce_gen_price_cap=False, ) if declared_budget < child_primary: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") @@ -1975,26 +2253,50 @@ def create_child_fee_accounting( # Old GenVM receipts may expose the allocation subtree as its encoded hex # form. Preserve that raw value in the receipt/hash path, but never iterate # its characters as FlatArrays allocation nodes here. - allocation_nodes = ( - message_allocations if isinstance(message_allocations, list) else [] - ) - child_message_allocations = _child_allocations_from_message_subtree( - message, - allocation_nodes, - ) - # Mode 1 children have no allocation subtree but still receive the remainder - # of their declared budget as a message-fee bucket for their own children. - child_message_budget = declared_budget - child_primary + if use_balance: + assert grant is not None + _validate_contract_descendant_grant( + grant, + child_fee_params=fee_params, + policy=policy, + ) + minimum = _u256_add(child_primary, grant.budget) + if declared_budget < minimum: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + child_message_allocations = grant.allocations + child_message_budget = grant.budget + allocation_policy = grant.policy + else: + if _is_contract_descendant_grant(message.get("allocationSubtree")): + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + allocation_nodes = ( + message_allocations if isinstance(message_allocations, list) else [] + ) + child_message_allocations = _child_allocations_from_message_subtree( + message, + allocation_nodes, + ) + child_message_budget = declared_budget - child_primary + allocation_policy = ( + MessageAllocationPolicy.PINNED + if child_message_allocations + else ( + MessageAllocationPolicy.CLOSED + if allocation_nodes + else MessageAllocationPolicy.OPEN + ) + ) child_fees = _fees_distribution_from_internal_params( fee_params, total_message_fees=child_message_budget, parent_fees_distribution=parent_fees, ) - validate_message_allocations( - child_message_allocations, - total_message_fees=int(child_fees["totalMessageFees"]), - policy=policy, - ) + if not use_balance and not message_allocations_from_grant: + validate_message_allocations( + child_message_allocations, + total_message_fees=int(child_fees["totalMessageFees"]), + policy=policy, + ) user_value = int(message.get("value", 0) or 0) accounting = _new_fee_accounting( fees_distribution=child_fees, @@ -2006,6 +2308,11 @@ def create_child_fee_accounting( sender=sender, source="internal_message", policy=policy, + allocation_policy=allocation_policy, + ) + # Grant trees retain their admission rules when inherited by descendants. + accounting["message_allocations_from_grant"] = ( + grant is not None or message_allocations_from_grant ) return child_fees, accounting @@ -2050,14 +2357,15 @@ def genvm_fee_context( "messageBudgetFloor": str(policy.message_fee_params_budget_floor()), } message_bucket_total = int(accounting.get("message_fee_budget", 0) or 0) - has_fee_budgets = bucket_total > 0 or message_bucket_total > 0 data_bucket_total = ( bucket_total if bucket_total > 0 else GENVM_UNMETERED_DATA_FEE_BUCKET ) bucket_totals = { "execution_data_gas": data_bucket_total, "message_fee": ( - message_bucket_total if has_fee_budgets else GENVM_UNMETERED_DATA_FEE_BUCKET + GENVM_UNMETERED_DATA_FEE_BUCKET + if uses_unmetered_message_fee_pool(accounting) + else message_bucket_total ), "nondet_outputs": GENVM_UNMETERED_DATA_FEE_BUCKET, "submitted_messages": GENVM_UNMETERED_DATA_FEE_BUCKET, @@ -2070,17 +2378,23 @@ def genvm_message_fee_allocation( accounting: dict[str, Any] | None, *, address_factory: Callable[[str], Any] | None = None, + policy: StudioFeePolicy | None = None, ) -> list[dict[str, Any]]: if not accounting: - return _genvm_unmetered_message_fee_allocation() + return _genvm_unmetered_message_fee_allocation(None, policy) + allocation_policy = _accounting_allocation_policy(accounting) + if allocation_policy is MessageAllocationPolicy.CLOSED: + return [UNMATCHED_EXTERNAL_GUARD_ALLOC] + + message_budget = int(accounting.get("message_fee_budget", 0) or 0) if not accounting.get("message_allocations"): - if int(accounting.get("message_fee_budget", 0) or 0) > 0: - raise Mode1MessageFeesRequireGenVMPerEmissionSupport( - "Mode1MessageFeesRequireGenVMPerEmissionSupport: fee-bearing " - "GenVM messages require a message allocation tree" + if message_budget > 0 or accounting.get("message_allocations_from_grant"): + return _genvm_open_message_fee_allocation( + accounting, + message_budget, ) - return _genvm_unmetered_message_fee_allocation() + return _genvm_unmetered_message_fee_allocation(accounting, policy) fees_distribution = normalize_fees_distribution( accounting.get("fees_distribution") or {} @@ -2112,9 +2426,24 @@ def genvm_message_fee_allocation( ) roots.append(allocation) + roots.append(UNMATCHED_EXTERNAL_GUARD_ALLOC) return roots +def uses_unmetered_message_fee_pool(accounting: dict[str, Any] | None) -> bool: + if not accounting: + return True + if accounting.get("message_allocations_from_grant"): + return False + if int(accounting.get("message_fee_budget", 0) or 0) > 0: + return False + if accounting.get("message_allocations"): + return False + if accounting.get("message_allocations_restricted"): + return False + return accounting.get("message_allocation_policy") in {None, "open"} + + def apply_fee_top_up( accounting: dict[str, Any], *, @@ -2802,6 +3131,7 @@ def _consume_external_message_fee( ) -> tuple[int, int]: if int(message.get("declaredBudget", 0) or 0) != 0: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + allocation_policy = _accounting_allocation_policy(accounting) event_count = len(accounting.get("external_message_events") or []) consumed = _reserve_external_execution( accounting, @@ -2813,6 +3143,8 @@ def _consume_external_message_fee( ) events = accounting.get("external_message_events") or [] if len(events) == event_count: + if allocation_policy is not MessageAllocationPolicy.OPEN: + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") return consumed, 0 event = events[-1] return consumed, int(event.get("reservation", 0) or 0) @@ -2824,23 +3156,81 @@ def _consume_internal_message_fee( policy: StudioFeePolicy, ) -> int: declared_budget = int(message.get("declaredBudget", 0) or 0) - fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) - _validate_internal_message_price_caps(fee_params) - _validate_internal_execution_budget_floor(fee_params, policy) - - min_required = min_message_primary_fees(fee_params, policy) + use_balance = bool(message.get("useBalance", False)) + descendant_budget = 0 + grant: ContractDescendantGrant | None = None + raw_fee_params = _fee_params_bytes(message.get("feeParams", b"")) + + if use_balance: + grant = decode_contract_descendant_grant(message.get("allocationSubtree")) + fee_params = _validate_contract_internal_fee_params( + raw_fee_params, + policy, + ) + _validate_contract_descendant_grant( + grant, + child_fee_params=fee_params, + policy=policy, + ) + descendant_budget = grant.budget + else: + fee_params = decode_internal_message_fee_params(raw_fee_params) + if _is_contract_descendant_grant(message.get("allocationSubtree")): + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + if _accounting_allocation_policy(accounting) is MessageAllocationPolicy.CLOSED: + raise MessageAllocationsRestricted("MessageAllocationsRestricted") + _validate_internal_message_price_caps(fee_params) + _validate_internal_execution_budget_floor(fee_params, policy) + + min_required = min_message_primary_fees( + fee_params, + policy, + ) + if use_balance: + min_required = _u256_add(min_required, descendant_budget) if declared_budget < min_required: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") - if bool(message.get("useBalance", False)): + if use_balance: # Consensus reserves this budget from the emitting contract (ghost), # outside the sender-funded message bucket and allocation tree. return 0 + if uses_unmetered_message_fee_pool(accounting): + expected = _genvm_unmetered_internal_fee_params(accounting, policy) + expected_decoded = { + "leaderTimeunitsAllocation": expected["leader_timeunits_allocation"], + "validatorTimeunitsAllocation": expected["validator_timeunits_allocation"], + "appealRounds": len(expected["rotations"]) - 1, + "executionBudgetPerRound": expected["execution_budget_per_round"], + "rotations": expected["rotations"], + "maxPriceGenPerTimeUnit": expected["max_price_gen_per_time_unit"], + "storageFeeMaxGasPrice": expected["storage_fee_max_gas_price"], + "receiptFeeMaxGasPrice": expected["receipt_fee_max_gas_price"], + } + if fee_params != expected_decoded or declared_budget != min_required: + raise MessageBudgetExceeded("MessageBudgetExceeded") + return 0 + _consume_against_allocation(accounting, message, declared_budget) return declared_budget +def _accounting_allocation_policy( + accounting: dict[str, Any], +) -> MessageAllocationPolicy: + value = accounting.get("message_allocation_policy") + if value: + return MessageAllocationPolicy(value) + if accounting.get("message_allocations_restricted"): + return MessageAllocationPolicy.CLOSED + return ( + MessageAllocationPolicy.PINNED + if accounting.get("message_allocations") + else MessageAllocationPolicy.OPEN + ) + + def _validate_internal_execution_budget_floor( fee_params: dict[str, Any], policy: StudioFeePolicy, @@ -3008,6 +3398,8 @@ def refund_failed_internal_message_fee( return updated if bool(message.get("useBalance", False)): return updated + if uses_unmetered_message_fee_pool(accounting): + return updated declared_budget = int(message.get("declaredBudget", 0) or 0) if declared_budget <= 0: @@ -3039,6 +3431,7 @@ def unwind_reveal_message_fees( external_unreserved = 0 external_reimbursement_rolled_back = 0 external_remainder_rolled_back = 0 + unmetered_internal = uses_unmetered_message_fee_pool(accounting) for message in messages: if ( @@ -3062,6 +3455,8 @@ def unwind_reveal_message_fees( continue if bool(message.get("useBalance", False)): continue + if unmetered_internal: + continue if acceptance_dispatched and bool(message.get("onAcceptance", False)): continue @@ -3895,7 +4290,11 @@ def _calculate_initial_round_total( num_of_validators: int, ) -> int: validator_index = _validator_index(num_of_validators) - if int(fees["appealRounds"]) != len(fees["rotations"]) - 1: + rotations = fees["rotations"] + if not isinstance(rotations, list) or not rotations: + raise InvalidAppealRounds("InvalidAppealRounds") + appeal_rounds = int(fees["appealRounds"]) + if appeal_rounds != len(rotations) - 1: raise InvalidAppealRounds("InvalidAppealRounds") return _calculate_fees(fees, validator_index) @@ -4479,6 +4878,7 @@ def _new_fee_accounting( sender: str | None, source: str, policy: StudioFeePolicy, + allocation_policy: MessageAllocationPolicy | None = None, ) -> dict[str, Any]: fees = _serializable_fees_distribution(fees_distribution) # FeeManager owns this counter. A caller may include the field in the ABI @@ -4498,6 +4898,11 @@ def _new_fee_accounting( incoming_primary=primary_budget, split_bps=policy.time_unit_overlay_bps, ) + allocation_policy = allocation_policy or ( + MessageAllocationPolicy.PINNED + if message_allocations + else MessageAllocationPolicy.OPEN + ) return { "version": 2, "source": source, @@ -4566,11 +4971,10 @@ def _new_fee_accounting( _serializable_message_allocation(allocation) for allocation in message_allocations ], - # Studio resolves the canonical FlatArrays subtree locally, so an - # ordinary child with no descendants is open Mode 1, not Consensus' - # distinct sealed-child failure state. Keep the explicit bit for - # imported/recovered sealed state without conflating the two. - "message_allocations_restricted": False, + "message_allocation_policy": allocation_policy.value, + "message_allocations_restricted": ( + allocation_policy is MessageAllocationPolicy.CLOSED + ), "allocation_consumed": {}, "message_consumption_events": [], } @@ -4684,18 +5088,75 @@ def _genvm_call_key(node: dict[str, Any]) -> bytes | None: return bytes.fromhex(call_key.removeprefix("0x")) -def _genvm_unmetered_message_fee_allocation() -> list[dict[str, Any]]: +def _genvm_unmetered_internal_fee_params( + accounting: dict[str, Any] | None, + policy: StudioFeePolicy | None, +) -> dict[str, Any]: + if policy is None and not accounting: + policy = StudioFeePolicy.from_env() + policy = execution_policy_for_accounting(accounting, policy) + fees = normalize_fees_distribution( + (accounting or {}).get("fees_distribution") or {} + ) + + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + if not ( + int(policy.min_propose_timeunits) + <= leader_timeunits + <= int(policy.max_propose_timeunits) + ): + leader_timeunits = min( + max(DEFAULT_LEADER_TIMEUNITS_ALLOCATION, policy.min_propose_timeunits), + policy.max_propose_timeunits, + ) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + if not ( + int(policy.min_commit_timeunits) + <= validator_timeunits + <= int(policy.max_commit_timeunits) + ): + validator_timeunits = min( + max( + DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION, + policy.min_commit_timeunits, + ), + policy.max_commit_timeunits, + ) + + return { + "leader_timeunits_allocation": leader_timeunits, + "validator_timeunits_allocation": validator_timeunits, + "execution_budget_per_round": max( + int(fees["executionBudgetPerRound"]), + int(policy.message_fee_params_budget_floor()), + DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, + ), + "rotations": [0], + "max_price_gen_per_time_unit": max( + int(fees["maxPriceGenPerTimeUnit"]), + _with_cap_headroom(policy.gen_per_time_unit), + 1, + ), + "storage_fee_max_gas_price": max( + int(fees["storageFeeMaxGasPrice"]), + _with_cap_headroom(policy.storage_unit_price), + 1, + ), + "receipt_fee_max_gas_price": max( + int(fees["receiptFeeMaxGasPrice"]), + _with_cap_headroom(policy.receipt_gas_price), + 1, + ), + } + + +def _genvm_unmetered_message_fee_allocation( + accounting: dict[str, Any] | None, + policy: StudioFeePolicy | None, +) -> list[dict[str, Any]]: budget = 2**200 internal_fee_params = { - "Internal": { - "leader_timeunits_allocation": 0, - "validator_timeunits_allocation": 0, - "execution_budget_per_round": 0, - "rotations": [0], - "max_price_gen_per_time_unit": 1, - "storage_fee_max_gas_price": 2**200, - "receipt_fee_max_gas_price": 2**200, - }, + "Internal": _genvm_unmetered_internal_fee_params(accounting, policy) } return [ { @@ -4717,13 +5178,7 @@ def _genvm_unmetered_message_fee_allocation() -> list[dict[str, Any]]: "call_key": None, "budget": budget, "on": "finalized", - "fee_params": { - "Internal": { - **internal_fee_params["Internal"], - "storage_fee_max_gas_price": 20, - "receipt_fee_max_gas_price": 20, - }, - }, + "fee_params": internal_fee_params, "children_budget": 0, "subtree": b"", }, @@ -4756,6 +5211,58 @@ def _genvm_external_legacy_fallback_message_fee_allocation() -> dict[str, Any]: } +def _genvm_open_message_fee_allocation( + accounting: dict[str, Any], + budget: int, +) -> list[dict[str, Any]]: + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + internal_fee_params = { + "Internal": { + "leader_timeunits_allocation": int(fees["leaderTimeunitsAllocation"]), + "validator_timeunits_allocation": int(fees["validatorTimeunitsAllocation"]), + "execution_budget_per_round": int(fees["executionBudgetPerRound"]), + "rotations": [int(rotation) for rotation in fees["rotations"]], + "max_price_gen_per_time_unit": int(fees["maxPriceGenPerTimeUnit"]), + "storage_fee_max_gas_price": int(fees["storageFeeMaxGasPrice"]), + "receipt_fee_max_gas_price": int(fees["receiptFeeMaxGasPrice"]), + } + } + return [ + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": budget, + "max_gas_price": 0, + } + }, + "children_budget": 0, + "subtree": b"", + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": internal_fee_params, + "children_budget": 0, + "subtree": b"", + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "decided", + "fee_params": internal_fee_params, + "children_budget": 0, + "subtree": b"", + }, + ] + + def _allocation_subtree( message_allocations: list[dict[str, Any]], root_index: int, diff --git a/docker-compose.yml b/docker-compose.yml index 8e2853201..ff3dacffa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -481,9 +481,10 @@ volumes: redis_data: # Credential for the private nix cache, consumed only by the GenVM source build -# in Dockerfile.backend. Defaults to /dev/null — an empty netrc — so a checkout -# without a token still builds, just without cache hits. CI points it at the -# netrc `nix-setup` wrote. +# in Dockerfile.backend. Defaults to an empty netrc in the repo, so a checkout +# without a token still builds, just without cache hits; not /dev/null, as +# buildx bake refuses to read a file outside the project without --allow=fs.read. +# CI points it at the netrc `nix-setup` wrote. secrets: nix_netrc: - file: ${NIX_NETRC_FILE:-/dev/null} + file: ${NIX_NETRC_FILE:-./docker/empty.netrc} diff --git a/docker/empty.netrc b/docker/empty.netrc new file mode 100644 index 000000000..e69de29bb diff --git a/examples/contracts/_hello_world.py b/examples/contracts/_hello_world.py index 273df4263..6761b4ba9 100644 --- a/examples/contracts/_hello_world.py +++ b/examples/contracts/_hello_world.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } # Always put above lines as first in the contract file # In actual genlayer network `:latest` is not allowed and hash must be specified diff --git a/examples/contracts/faucet.py b/examples/contracts/faucet.py index 075eae651..48f050edd 100644 --- a/examples/contracts/faucet.py +++ b/examples/contracts/faucet.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/examples/contracts/football_prediction_market.py b/examples/contracts/football_prediction_market.py index 492c07bf2..26ec54708 100644 --- a/examples/contracts/football_prediction_market.py +++ b/examples/contracts/football_prediction_market.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/examples/contracts/llm_erc20.py b/examples/contracts/llm_erc20.py index 419794527..d3f928699 100644 --- a/examples/contracts/llm_erc20.py +++ b/examples/contracts/llm_erc20.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import json diff --git a/examples/contracts/log_indexer.py b/examples/contracts/log_indexer.py index b36391db9..0952d60ec 100644 --- a/examples/contracts/log_indexer.py +++ b/examples/contracts/log_indexer.py @@ -2,7 +2,7 @@ # { # "Seq": [ # { "Depends": "py-lib-genlayer-embeddings:hqpree1t3470fnac2aeee1y5c2205k22bgk1p98sg8m3s1ndmxbg" }, -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } # ] # } diff --git a/examples/contracts/storage.py b/examples/contracts/storage.py index 6affe74b3..4fa40adbb 100644 --- a/examples/contracts/storage.py +++ b/examples/contracts/storage.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/examples/contracts/tip_jar.py b/examples/contracts/tip_jar.py index b6c3e3226..590e6182b 100644 --- a/examples/contracts/tip_jar.py +++ b/examples/contracts/tip_jar.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/examples/contracts/user_storage.py b/examples/contracts/user_storage.py index 227c67ffb..78e02e011 100644 --- a/examples/contracts/user_storage.py +++ b/examples/contracts/user_storage.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/examples/contracts/wizard_of_coin.py b/examples/contracts/wizard_of_coin.py index 947852cab..c4191d59b 100644 --- a/examples/contracts/wizard_of_coin.py +++ b/examples/contracts/wizard_of_coin.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl import json diff --git a/tests/direct/contracts/error_llm_contract_direct.py b/tests/direct/contracts/error_llm_contract_direct.py index 2c42b2d31..6ae83c66a 100644 --- a/tests/direct/contracts/error_llm_contract_direct.py +++ b/tests/direct/contracts/error_llm_contract_direct.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl import json diff --git a/tests/direct/contracts/error_web_contract_direct.py b/tests/direct/contracts/error_web_contract_direct.py index 2efde9b49..93fa1f4b0 100644 --- a/tests/direct/contracts/error_web_contract_direct.py +++ b/tests/direct/contracts/error_web_contract_direct.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/tests/direct/storage_read_bench.py b/tests/direct/storage_read_bench.py index a257f79e7..a77e9fa4a 100644 --- a/tests/direct/storage_read_bench.py +++ b/tests/direct/storage_read_bench.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/fixtures/descendant_grant_vectors.json b/tests/fixtures/descendant_grant_vectors.json new file mode 100644 index 000000000..151b63433 --- /dev/null +++ b/tests/fixtures/descendant_grant_vectors.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "validVectors": [ + { + "name": "closed-explicit", + "decodedGrant": { + "version": "1", + "policy": "0", + "budget": "0", + "allocations": [] + } + }, + { + "name": "open-explicit", + "decodedGrant": { + "version": "1", + "policy": "1", + "budget": "300000000000000000", + "allocations": [] + } + }, + { + "name": "pinned-single-internal", + "decodedGrant": { + "version": "1", + "policy": "2", + "budget": "300000000000000000", + "allocations": [ + { + "messageType": "1", + "onAcceptance": false, + "parentIndex": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "recipient": "0x0000000000000000000000000000000000000011", + "callKey": "0x0000000000000000000000000000000000000000000000000000000000000000", + "budget": "300000000000000000", + "feeParams": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + }, + { + "name": "pinned-nested-internal-and-external-root", + "decodedGrant": { + "version": "1", + "policy": "2", + "budget": "600200000000000000", + "allocations": [ + { + "messageType": "1", + "onAcceptance": false, + "parentIndex": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "recipient": "0x0000000000000000000000000000000000000011", + "callKey": "0x0000000000000000000000000000000000000000000000000000000000000000", + "budget": "600000000000000000", + "feeParams": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000" + }, + { + "messageType": "1", + "onAcceptance": true, + "parentIndex": "0", + "recipient": "0x0000000000000000000000000000000000000022", + "callKey": "0x0000000000000000000000000000000000000000000000000000000000000000", + "budget": "200000000000000000", + "feeParams": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000" + }, + { + "messageType": "0", + "onAcceptance": false, + "parentIndex": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "recipient": "0x0000000000000000000000000000000000000033", + "callKey": "0xb9ee2e14f00c68d60911d6c13db158044bd494f772f7785c2f108fdeb2949f81", + "budget": "200000000000000", + "feeParams": "0x0000000000000000000000000000000000000000000000000000000000030d40000000000000000000000000000000000000000000000000000000003b9aca00" + } + ] + } + }, + { + "name": "closed-legacy-omitted", + "decodedGrant": { + "version": "1", + "policy": "0", + "budget": "0", + "allocations": [] + } + } + ] +} diff --git a/tests/integration/icontracts/contracts/company_naming.py b/tests/integration/icontracts/contracts/company_naming.py index 1c122fe7b..986795322 100644 --- a/tests/integration/icontracts/contracts/company_naming.py +++ b/tests/integration/icontracts/contracts/company_naming.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import json import genlayer as gl diff --git a/tests/integration/icontracts/contracts/descendant_grants.py b/tests/integration/icontracts/contracts/descendant_grants.py new file mode 100644 index 000000000..c72bef3b5 --- /dev/null +++ b/tests/integration/icontracts/contracts/descendant_grants.py @@ -0,0 +1,89 @@ +# v0.3.0 +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } + +import genlayer as gl +from genlayer.message_allocation import InternalAllocation +from genlayer.types import * +from genlayer.vm.public_abi import Permissions + +DESCENDANT_BUDGET = 10**21 + + +def _fee_params() -> gl.chain.InternalMessageParams: + return gl.chain.InternalMessageParams( + leader_time_units_allocation=100, + validator_time_units_allocation=200, + execution_budget_per_round=10**18, + rotations=[0], + max_price_gen_per_time_unit=1, + storage_fee_max_gas_price=10**18, + receipt_fee_max_gas_price=10**18, + ) + + +class DescendantGrants(gl.contract.Contract): + last: str + + def __init__(self): + self.last = "" + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write.payable + def fund(self) -> None: + pass + + @gl.public.write + def emit_closed(self, relay: str, target: str) -> None: + grant = gl.contract.UseBalanceParams(_fee_params()) + gl.contract.get_at(Address(relay)).emit(grant).relay(target, "closed") + + @gl.public.write + def emit_closed_balance_funded(self, relay: str, target: str) -> None: + grant = gl.contract.UseBalanceParams(_fee_params()) + gl.contract.get_at(Address(relay)).emit(grant).relay_balance_funded( + target, "closed-balance-funded" + ) + + @gl.public.write + def emit_open(self, relay: str, target: str) -> None: + grant = gl.contract.UseBalanceParams( + _fee_params(), + descendants=DESCENDANT_BUDGET, + ) + gl.contract.get_at(Address(relay)).emit(grant).relay(target, "open") + + @gl.public.write + def emit_pinned(self, relay: str, target: str) -> None: + allocation = InternalAllocation( + recipient=Address(target), + call_key="record", + fee_params=_fee_params(), + budget=DESCENDANT_BUDGET, + ) + grant = gl.contract.UseBalanceParams( + _fee_params(), + descendants=[allocation], + ) + gl.contract.get_at(Address(relay)).emit(grant).relay(target, "pinned") + + @gl.public.write + def relay(self, target: str, value: str) -> None: + self.last = value + gl.contract.get_at(Address(target)).emit().record(value) + + @gl.public.write + def relay_balance_funded(self, target: str, value: str) -> None: + self.last = value + gl.contract.get_at(Address(target)).emit( + gl.contract.UseBalanceParams(_fee_params()) + ).record(value) + + @gl.public.write + def record(self, value: str) -> None: + self.last = value + + @gl.public.view + def get_last(self) -> str: + return self.last diff --git a/tests/integration/icontracts/contracts/error_execution_contract.py b/tests/integration/icontracts/contracts/error_execution_contract.py index 29bc0dc63..c26c50515 100644 --- a/tests/integration/icontracts/contracts/error_execution_contract.py +++ b/tests/integration/icontracts/contracts/error_execution_contract.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/error_llm_contract.py b/tests/integration/icontracts/contracts/error_llm_contract.py index 8d945d921..547641b72 100644 --- a/tests/integration/icontracts/contracts/error_llm_contract.py +++ b/tests/integration/icontracts/contracts/error_llm_contract.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl import json diff --git a/tests/integration/icontracts/contracts/error_web_contract.py b/tests/integration/icontracts/contracts/error_web_contract.py index a51934270..983d7bb59 100644 --- a/tests/integration/icontracts/contracts/error_web_contract.py +++ b/tests/integration/icontracts/contracts/error_web_contract.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/tests/integration/icontracts/contracts/faucet.py b/tests/integration/icontracts/contracts/faucet.py index 538364d98..9a76cae28 100644 --- a/tests/integration/icontracts/contracts/faucet.py +++ b/tests/integration/icontracts/contracts/faucet.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/genvm_smoke_v1.py b/tests/integration/icontracts/contracts/genvm_smoke_v1.py index 0ccd80519..a402cda16 100644 --- a/tests/integration/icontracts/contracts/genvm_smoke_v1.py +++ b/tests/integration/icontracts/contracts/genvm_smoke_v1.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/tests/integration/icontracts/contracts/intelligent_oracle.py b/tests/integration/icontracts/contracts/intelligent_oracle.py index 17923a1ff..57e90e349 100644 --- a/tests/integration/icontracts/contracts/intelligent_oracle.py +++ b/tests/integration/icontracts/contracts/intelligent_oracle.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import json from enum import Enum diff --git a/tests/integration/icontracts/contracts/intelligent_oracle_factory.py b/tests/integration/icontracts/contracts/intelligent_oracle_factory.py index 3318c32e5..3d94627f1 100644 --- a/tests/integration/icontracts/contracts/intelligent_oracle_factory.py +++ b/tests/integration/icontracts/contracts/intelligent_oracle_factory.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/tests/integration/icontracts/contracts/multi_file_contract/other.py b/tests/integration/icontracts/contracts/multi_file_contract/other.py index 0da3cc5c1..a16822e6b 100644 --- a/tests/integration/icontracts/contracts/multi_file_contract/other.py +++ b/tests/integration/icontracts/contracts/multi_file_contract/other.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/tests/integration/icontracts/contracts/multi_file_contract/runner.json b/tests/integration/icontracts/contracts/multi_file_contract/runner.json index a9f972e74..826671a81 100644 --- a/tests/integration/icontracts/contracts/multi_file_contract/runner.json +++ b/tests/integration/icontracts/contracts/multi_file_contract/runner.json @@ -1,3 +1,3 @@ { - "Depends": "py-genlayer-multi:faykzar6hr5ehfm07jatm69erx6nv96wmz1ab1dszjthbcgre3m0" + "Depends": "py-genlayer-multi:2f6ynxqmzzg1dmcsaj5m2k1b1bgpe1y3850y7vhm8m2eewcvg130" } diff --git a/tests/integration/icontracts/contracts/multi_read_erc20.py b/tests/integration/icontracts/contracts/multi_read_erc20.py index b33c9c616..fed1e223c 100644 --- a/tests/integration/icontracts/contracts/multi_read_erc20.py +++ b/tests/integration/icontracts/contracts/multi_read_erc20.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/multi_tenant_storage.py b/tests/integration/icontracts/contracts/multi_tenant_storage.py index ee210066a..204537aa0 100644 --- a/tests/integration/icontracts/contracts/multi_tenant_storage.py +++ b/tests/integration/icontracts/contracts/multi_tenant_storage.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/payable_escrow.py b/tests/integration/icontracts/contracts/payable_escrow.py index a0dafd188..c26dff2c6 100644 --- a/tests/integration/icontracts/contracts/payable_escrow.py +++ b/tests/integration/icontracts/contracts/payable_escrow.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/read_erc20.py b/tests/integration/icontracts/contracts/read_erc20.py index 3433c8507..71b8acdff 100644 --- a/tests/integration/icontracts/contracts/read_erc20.py +++ b/tests/integration/icontracts/contracts/read_erc20.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/utf8_roundtrip_contract.py b/tests/integration/icontracts/contracts/utf8_roundtrip_contract.py index fbfc860fe..2184ca51a 100644 --- a/tests/integration/icontracts/contracts/utf8_roundtrip_contract.py +++ b/tests/integration/icontracts/contracts/utf8_roundtrip_contract.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/tests/integration/icontracts/tests/test_descendant_grant_wait.py b/tests/integration/icontracts/tests/test_descendant_grant_wait.py new file mode 100644 index 000000000..136ebfc2d --- /dev/null +++ b/tests/integration/icontracts/tests/test_descendant_grant_wait.py @@ -0,0 +1,47 @@ +from unittest.mock import Mock + +import pytest + +from tests.integration.icontracts.tests import test_descendant_grants as integration + + +def test_wait_for_descendants_visits_all_finalized_levels(monkeypatch): + receipts = { + "child": {"triggered_transactions": ["grandchild", "sibling"]}, + "grandchild": {"triggered_transactions": ["great-grandchild"]}, + "sibling": {"triggered_transactions": []}, + "great-grandchild": {"triggered_transactions": []}, + } + client = Mock() + config = Mock() + config.get_default_wait_interval.return_value = 3000 + config.get_default_wait_retries.return_value = 50 + wait = Mock( + side_effect=lambda _client, **kwargs: receipts[kwargs["transaction_hash"]] + ) + monkeypatch.setattr(integration, "get_gl_client", lambda: client) + monkeypatch.setattr(integration, "get_general_config", lambda: config) + monkeypatch.setattr(integration, "wait_for_transaction_receipt", wait) + + integration._wait_for_descendants({"triggered_transactions": ["child"]}) + + assert {call.kwargs["transaction_hash"] for call in wait.call_args_list} == set( + receipts + ) + assert wait.call_count == len(receipts) + for call in wait.call_args_list: + assert call.args == (client,) + assert call.kwargs["wait_until"] == "finalized" + assert call.kwargs["interval"] == 3000 + assert call.kwargs["retries"] == 50 + + +def test_wait_for_descendants_propagates_timeout(monkeypatch): + monkeypatch.setattr(integration, "get_gl_client", Mock()) + monkeypatch.setattr(integration, "get_general_config", Mock()) + monkeypatch.setattr( + integration, "wait_for_transaction_receipt", Mock(side_effect=TimeoutError) + ) + + with pytest.raises(TimeoutError): + integration._wait_for_descendants({"triggered_transactions": ["child"]}) diff --git a/tests/integration/icontracts/tests/test_descendant_grants.py b/tests/integration/icontracts/tests/test_descendant_grants.py new file mode 100644 index 000000000..4447e1cb8 --- /dev/null +++ b/tests/integration/icontracts/tests/test_descendant_grants.py @@ -0,0 +1,71 @@ +from gltest import get_contract_factory +from gltest.assertions import tx_execution_succeeded +from gltest.clients import get_gl_client +from gltest.contracts.wait import wait_for_transaction_receipt +from gltest.types import TransactionStatus +from gltest_cli.config.general import get_general_config + + +def _wait_for_descendants(receipt: dict) -> None: + client = get_gl_client() + config = get_general_config() + pending = list(receipt.get("triggered_transactions", [])) + while pending: + child = wait_for_transaction_receipt( + client, + transaction_hash=pending.pop(), + wait_until="finalized", + interval=config.get_default_wait_interval(), + retries=config.get_default_wait_retries(), + ) + pending.extend(child.get("triggered_transactions", [])) + + +def test_balance_funded_descendant_grant_policies(setup_validators): + setup_validators() + factory = get_contract_factory( + contract_file_path=( + "tests/integration/icontracts/contracts/descendant_grants.py" + ) + ) + relay = factory.deploy() + target = factory.deploy() + + funding = relay.fund(args=[]).transact( + value=10**23, + wait_transaction_status=TransactionStatus.FINALIZED, + ) + assert tx_execution_succeeded(funding) + + closed = relay.emit_closed(args=[relay.address, target.address]).transact( + wait_transaction_status=TransactionStatus.FINALIZED, + ) + _wait_for_descendants(closed) + assert tx_execution_succeeded(closed) + # The relay fails on its sender-funded emission, so its write rolls back + assert relay.get_last(args=[]).call() == "" + assert target.get_last(args=[]).call() == "" + + closed_balance_funded = relay.emit_closed_balance_funded( + args=[relay.address, target.address] + ).transact( + wait_transaction_status=TransactionStatus.FINALIZED, + ) + _wait_for_descendants(closed_balance_funded) + assert tx_execution_succeeded(closed_balance_funded) + assert relay.get_last(args=[]).call() == "closed-balance-funded" + assert target.get_last(args=[]).call() == "closed-balance-funded" + + opened = relay.emit_open(args=[relay.address, target.address]).transact( + wait_transaction_status=TransactionStatus.FINALIZED, + ) + _wait_for_descendants(opened) + assert tx_execution_succeeded(opened) + assert target.get_last(args=[]).call() == "open" + + pinned = relay.emit_pinned(args=[relay.address, target.address]).transact( + wait_transaction_status=TransactionStatus.FINALIZED, + ) + _wait_for_descendants(pinned) + assert tx_execution_succeeded(pinned) + assert target.get_last(args=[]).call() == "pinned" diff --git a/tests/integration/test_deploy_reroute_to.py b/tests/integration/test_deploy_reroute_to.py index 641ad4454..3e9b453d3 100644 --- a/tests/integration/test_deploy_reroute_to.py +++ b/tests/integration/test_deploy_reroute_to.py @@ -47,20 +47,12 @@ # GenVM manager's executors path in the image under test. LEGACY_EXECUTOR = os.environ.get("TEST_REROUTE_EXECUTOR", "v0.2.17") -# Executor version directory of the *current* line. Pinning a contract to it is -# a no-op for how that contract runs on its own, but it gives the resolve hook -# something to answer with when a caller on another line asks where this -# contract lives. -# -# Hardcoded because the test has no way to ask for it, not because pinning it -# here is desirable. The manager knows the answer -- -# `$GENVMROOT/data/manifest.yaml` lists `executor_versions` -- but it serves no -# route that reads the manifest back (`/manifest/reload` only reloads it), and -# `/contract/detect-version` answers with a `specified_major`, which is 0 for -# every 0.x line and so cannot name a directory. Until something exposes it, -# this constant has to be updated whenever the image's current line moves; -# override the env var when it does. -CURRENT_EXECUTOR = os.environ.get("TEST_CURRENT_EXECUTOR", "v0.3.0-rc9") +# Selector of the *current* line. Pinning a contract to it is a no-op for how +# that contract runs on its own, but it gives the resolve hook something to +# answer with when a caller on another line asks where this contract lives. +# A `re:` pattern, so release-candidate renames of the line's directory do not +# break it. +CURRENT_EXECUTOR = os.environ.get("TEST_CURRENT_EXECUTOR", r"re:^v0\.3\.") # Shares the validator registry with the other integration suites, which wipe # validators while seeding mock responses. @@ -69,7 +61,7 @@ # Current SDK, current executor. CONTRACT_A = """# v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/test_upgrade_contract.py b/tests/integration/test_upgrade_contract.py index 0ee0423b1..8aa9f39fd 100644 --- a/tests/integration/test_upgrade_contract.py +++ b/tests/integration/test_upgrade_contract.py @@ -162,7 +162,7 @@ def write_contract_method( # ============================================================================= CONTRACT_V1 = """# v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * @@ -197,7 +197,7 @@ def set_name(self, new_name: str) -> None: """ CONTRACT_V2 = """# v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * @@ -236,7 +236,7 @@ def new_method(self) -> str: """ CONTRACT_V3_WITH_NEW_STATE = """# v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * @@ -277,7 +277,7 @@ def set_name(self, new_name: str) -> None: """ INVALID_CONTRACT = """# v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * @@ -288,7 +288,7 @@ def __init__(self): """ SIMPLE_CONTRACT = """# v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/load/contracts/counter.py b/tests/load/contracts/counter.py index 1447d8f9b..d8d7b77cc 100644 --- a/tests/load/contracts/counter.py +++ b/tests/load/contracts/counter.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/test_linter_endpoint.py b/tests/test_linter_endpoint.py index 4b0b42936..fc86ea0f8 100644 --- a/tests/test_linter_endpoint.py +++ b/tests/test_linter_endpoint.py @@ -22,7 +22,7 @@ def get_balance(self) -> u256: # Should return int """ # Valid contract -VALID_CONTRACT = """# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +VALID_CONTRACT = """# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/unit/test_descendant_grants.py b/tests/unit/test_descendant_grants.py new file mode 100644 index 000000000..c903edb53 --- /dev/null +++ b/tests/unit/test_descendant_grants.py @@ -0,0 +1,674 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +from eth_abi import encode +from eth_hash.auto import keccak + +from backend.protocol_rpc.fees import ( + CONTRACT_DESCENDANT_GRANT_ABI_TYPE, + CONTRACT_DESCENDANT_GRANT_DOMAIN, + CONTRACT_DESCENDANT_GRANT_VERSION, + EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE, + INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE, + NODE_ROOT_SENTINEL, + AllocationDuplicateKey, + AllocationTreeMalformed, + AllocationTreeTooDeep, + ArithmeticOverflow, + ExternalAllocationInvalid, + InvalidContractDescendantGrant, + InvalidAppealRounds, + InvalidFeeParams, + PhaseTimeoutOutOfBounds, + MessageAllocationsNotEqualBudget, + MessageAllocationsRestricted, + MessageBudgetExceeded, + MessageDeclaredBudgetInsufficient, + MessageNoMatchingAllocation, + StudioFeePolicy, + FEE_ACCOUNTING_KEY, + _validate_contract_descendant_grant, + consume_message_fees, + cancel_fee_accounting, + create_child_fee_accounting, + decode_contract_descendant_grant, + genvm_message_fee_allocation, + fill_message_fee_payload_from_allocation, + settle_fee_accounting, + genvm_fee_context, + uses_unmetered_message_fee_pool, +) +from backend.consensus.base import _attach_child_fee_accounting +from backend.node.genvm.origin.fees import UNMATCHED_EXTERNAL_GUARD_ALLOC + + +def _internal_fee_params( + *, + leader: int = 0, + validator: int = 0, + execution_budget: int = 10, + rotations: list[int] | None = None, + appeal_rounds: int | None = None, + max_price: int = 1, + storage_price: int = 1, + receipt_price: int = 1, +) -> bytes: + rotations = rotations or [0] + return encode( + [INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [ + ( + leader, + validator, + len(rotations) - 1 if appeal_rounds is None else appeal_rounds, + execution_budget, + rotations, + max_price, + storage_price, + receipt_price, + ) + ], + ) + + +def _node( + *, + message_type: int = 1, + on_acceptance: bool = False, + parent_index: int = NODE_ROOT_SENTINEL, + recipient: str = "0x0000000000000000000000000000000000000011", + call_key: bytes = bytes(32), + budget: int = 30, + fee_params: bytes | None = None, +) -> tuple: + return ( + message_type, + on_acceptance, + parent_index, + recipient, + call_key, + budget, + fee_params or _internal_fee_params(), + ) + + +def _grant(policy: int, budget: int, nodes: list[tuple]) -> bytes: + return encode( + ["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], + [CONTRACT_DESCENDANT_GRANT_DOMAIN, (1, policy, budget, nodes)], + ) + + +def _portable_vectors() -> list[tuple[str, bytes, int, str]]: + vector_internal = _internal_fee_params( + leader=5, + validator=10, + execution_budget=100_000_000_000_000_000, + storage_price=1_000_000_000_000_000_000, + receipt_price=1_000_000_000_000_000_000, + ) + external = encode( + [EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [(200_000, 1_000_000_000)], + ) + single = [ + _node( + budget=300_000_000_000_000_000, + fee_params=vector_internal, + ) + ] + nested = [ + _node( + budget=600_000_000_000_000_000, + fee_params=vector_internal, + ), + _node( + on_acceptance=True, + parent_index=0, + recipient="0x0000000000000000000000000000000000000022", + budget=200_000_000_000_000_000, + fee_params=vector_internal, + ), + _node( + message_type=0, + recipient="0x0000000000000000000000000000000000000033", + call_key=bytes.fromhex( + "b9ee2e14f00c68d60911d6c13db158044bd494f772f7785c2f108fdeb2949f81" + ), + budget=200_000_000_000_000, + fee_params=external, + ), + ] + return [ + ( + "closed-explicit", + _grant(0, 0, []), + 224, + "406f5f74fd9c19c37478c7833fb1cd5a5bf63e9fe49e7f969b52e01afc52573d", + ), + ( + "open-explicit", + _grant(1, 300_000_000_000_000_000, []), + 224, + "3bfb47b06c42438373ee95ee5b4818d37fd8042319ee42a46ef5fac6df0cef68", + ), + ( + "pinned-single-internal", + _grant(2, 300_000_000_000_000_000, single), + 864, + "07669715194cf1db8fbc1b70d6e4aa91fa9df21305a7eed1570c50112a74f47d", + ), + ( + "pinned-nested-internal-and-external-root", + _grant(2, 600_200_000_000_000_000, nested), + 1_856, + "cb946323c2548d70c2511cfcdd74564e0ebb7fa19610a2a379d9aebd4037882d", + ), + ( + "closed-legacy-omitted", + b"", + 0, + "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + ), + ] + + +@pytest.mark.parametrize("name,payload,size,payload_hash", _portable_vectors()) +def test_portable_descendant_grant_vectors( + name: str, + payload: bytes, + size: int, + payload_hash: str, +) -> None: + grant = decode_contract_descendant_grant(payload) + fixture = json.loads( + ( + Path(__file__).parents[1] / "fixtures/descendant_grant_vectors.json" + ).read_text() + ) + vector = next(item for item in fixture["validVectors"] if item["name"] == name) + expected = vector["decodedGrant"] + expected_allocations = [ + { + "messageType": int(node["messageType"]), + "onAcceptance": node["onAcceptance"], + "parentIndex": int(node["parentIndex"]), + "recipient": node["recipient"], + "callKey": node["callKey"], + "budget": int(node["budget"]), + "feeParams": node["feeParams"], + } + for node in expected["allocations"] + ] + + assert len(payload) == size, name + assert keccak(payload).hex() == payload_hash, name + assert int(expected["version"]) == CONTRACT_DESCENDANT_GRANT_VERSION + assert ( + grant.policy.value + == {"0": "closed", "1": "open", "2": "pinned"}[expected["policy"]] + ) + assert grant.budget == int(expected["budget"]) + assert grant.allocations == expected_allocations + + +@pytest.mark.parametrize( + "payload", + [ + "0xnot-hex", + encode( + ["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], + [bytes(32), (1, 0, 0, [])], + ), + encode( + ["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], + [CONTRACT_DESCENDANT_GRANT_DOMAIN, (2, 0, 0, [])], + ), + _grant(0, 0, []) + b"\x00", + encode( + ["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], + [CONTRACT_DESCENDANT_GRANT_DOMAIN, (1, 3, 0, [])], + ), + ], +) +def test_portable_codec_negative_vectors(payload: bytes | str) -> None: + with pytest.raises(InvalidContractDescendantGrant): + decode_contract_descendant_grant(payload) + + +def test_pinned_root_sum_is_an_admission_error() -> None: + node = _node() + grant = decode_contract_descendant_grant(_grant(2, 31, [node])) + + with pytest.raises(MessageAllocationsNotEqualBudget): + _validate_contract_descendant_grant( + grant, + child_fee_params={"appealRounds": 0}, + policy=StudioFeePolicy(max_allocation_tree_depth=24), + ) + + +def test_pinned_rejects_duplicate_sibling_keys() -> None: + grant = decode_contract_descendant_grant(_grant(2, 60, [_node(), _node()])) + + with pytest.raises(AllocationDuplicateKey): + _validate_contract_descendant_grant( + grant, + child_fee_params={"appealRounds": 0}, + policy=StudioFeePolicy(max_allocation_tree_depth=24), + ) + + +def test_pinned_rejects_forward_parent_and_excessive_depth() -> None: + forward = decode_contract_descendant_grant( + _grant( + 2, + 30, + [ + _node(parent_index=1), + _node(recipient="0x0000000000000000000000000000000000000022"), + ], + ) + ) + with pytest.raises(AllocationTreeMalformed): + _validate_contract_descendant_grant( + forward, + child_fee_params={"appealRounds": 0}, + policy=StudioFeePolicy(max_allocation_tree_depth=24), + ) + + nested = decode_contract_descendant_grant( + _grant( + 2, + 40, + [ + _node(budget=40), + _node( + parent_index=0, + recipient="0x0000000000000000000000000000000000000022", + ), + ], + ) + ) + with pytest.raises(AllocationTreeTooDeep): + _validate_contract_descendant_grant( + nested, + child_fee_params={"appealRounds": 0}, + policy=StudioFeePolicy(max_allocation_tree_depth=1), + ) + + +def test_pinned_rejects_noncanonical_fee_params_and_checked_overflow() -> None: + noncanonical = decode_contract_descendant_grant( + _grant(2, 30, [_node(fee_params=_internal_fee_params() + bytes(32))]) + ) + with pytest.raises(InvalidFeeParams): + _validate_contract_descendant_grant( + noncanonical, + child_fee_params={"appealRounds": 0}, + policy=StudioFeePolicy(max_allocation_tree_depth=24), + ) + + overflow = decode_contract_descendant_grant( + _grant( + 2, + NODE_ROOT_SENTINEL, + [ + _node(budget=NODE_ROOT_SENTINEL), + _node( + parent_index=0, + recipient="0x0000000000000000000000000000000000000022", + budget=NODE_ROOT_SENTINEL, + ), + _node( + parent_index=0, + recipient="0x0000000000000000000000000000000000000033", + budget=NODE_ROOT_SENTINEL, + ), + ], + ) + ) + with pytest.raises(ArithmeticOverflow): + _validate_contract_descendant_grant( + overflow, + child_fee_params={"appealRounds": 0}, + policy=StudioFeePolicy(max_allocation_tree_depth=24), + ) + + +def _child_message(subtree: bytes, declared_budget: int) -> dict: + return { + "messageType": 1, + "recipient": "0x0000000000000000000000000000000000000011", + "value": 0, + "onAcceptance": False, + "feeParams": _internal_fee_params(), + "declaredBudget": declared_budget, + "allocationSubtree": "0x" + subtree.hex(), + "callKey": "0x" + "00" * 32, + "useBalance": True, + } + + +def test_child_accounting_installs_closed_open_and_pinned_policies() -> None: + omitted_fees, omitted = create_child_fee_accounting( + message=_child_message(b"", 15), + parent_fees_distribution=None, + ) + explicit_fees, explicit = create_child_fee_accounting( + message=_child_message(_grant(0, 0, []), 15), + parent_fees_distribution=None, + ) + open_fees, opened = create_child_fee_accounting( + message=_child_message(_grant(1, 30, []), 45), + parent_fees_distribution=None, + ) + pinned_fees, pinned = create_child_fee_accounting( + message=_child_message(_grant(2, 30, [_node()]), 40), + parent_fees_distribution=None, + ) + + assert omitted_fees == explicit_fees + assert omitted == explicit + assert omitted["message_allocations_restricted"] is True + assert explicit_fees["totalMessageFees"] == 0 + assert explicit["primary_fee_budget"] == 15 + assert explicit["message_allocation_policy"] == "closed" + assert genvm_message_fee_allocation(omitted) == [UNMATCHED_EXTERNAL_GUARD_ALLOC] + assert genvm_message_fee_allocation(explicit) == [UNMATCHED_EXTERNAL_GUARD_ALLOC] + assert open_fees["totalMessageFees"] == 30 + assert opened["primary_fee_budget"] == 15 + assert opened["message_allocation_policy"] == "open" + assert pinned_fees["totalMessageFees"] == 30 + assert pinned["message_allocation_policy"] == "pinned" + assert len(pinned["message_allocations"]) == 1 + assert genvm_message_fee_allocation(pinned)[-1] == UNMATCHED_EXTERNAL_GUARD_ALLOC + + +def test_open_policy_materializes_bounded_wildcards_and_aggregate_cap() -> None: + _, accounting = create_child_fee_accounting( + message=_child_message(_grant(1, 30, []), 40), + parent_fees_distribution=None, + ) + + allocations = genvm_message_fee_allocation(accounting) + assert [node["on"] for node in allocations] == [ + "finalized", + "finalized", + "decided", + ] + assert all(node["recipient"] is None for node in allocations) + assert all(node["budget"] == 30 for node in allocations) + assert UNMATCHED_EXTERNAL_GUARD_ALLOC not in allocations + + message = _child_message(b"", 10) + message["useBalance"] = False + message["allocationSubtree"] = [] + updated = consume_message_fees(accounting, [message, message, message]) + assert updated["message_fee_consumed"] == 30 + with pytest.raises(MessageBudgetExceeded): + consume_message_fees(updated, [message]) + + +def test_closed_rejects_ordinary_descendants_but_allows_new_balance_branch() -> None: + _, accounting = create_child_fee_accounting( + message=_child_message(_grant(0, 0, []), 15), + parent_fees_distribution=None, + ) + ordinary = _child_message(b"", 10) + ordinary["useBalance"] = False + ordinary["allocationSubtree"] = [] + + with pytest.raises(MessageAllocationsRestricted): + consume_message_fees(accounting, [ordinary]) + + nested_balance = _child_message(b"", 10) + updated = consume_message_fees(accounting, [nested_balance]) + assert updated["message_fee_consumed"] == 0 + assert genvm_message_fee_allocation(accounting) == [UNMATCHED_EXTERNAL_GUARD_ALLOC] + + +def test_legacy_restricted_accounting_remains_closed_in_genvm_inputs() -> None: + allocations = genvm_message_fee_allocation( + { + "message_fee_budget": 30, + "message_allocations": [], + "message_allocations_restricted": True, + } + ) + + assert allocations == [UNMATCHED_EXTERNAL_GUARD_ALLOC] + + +@pytest.mark.parametrize("allocation_policy", ["closed", "pinned"]) +def test_unmatched_external_hits_guard_before_consensus_restriction( + allocation_policy: str, +) -> None: + allocations = [_node()] if allocation_policy == "pinned" else [] + accounting = { + "message_fee_budget": 30 if allocations else 0, + "message_fee_consumed": 0, + "message_allocations": [ + { + "messageType": node[0], + "onAcceptance": node[1], + "parentIndex": node[2], + "recipient": node[3], + "callKey": "0x" + node[4].hex(), + "budget": node[5], + "feeParams": "0x" + node[6].hex(), + } + for node in allocations + ], + "message_allocation_policy": allocation_policy, + } + + genvm_allocations = genvm_message_fee_allocation(accounting) + assert genvm_allocations[-1] == UNMATCHED_EXTERNAL_GUARD_ALLOC + assert [node for node in genvm_allocations if "External" in node["fee_params"]] == [ + UNMATCHED_EXTERNAL_GUARD_ALLOC + ] + assert genvm_allocations[-1]["budget"] < ( + genvm_allocations[-1]["fee_params"]["External"]["gas_limit"] + * genvm_allocations[-1]["fee_params"]["External"]["max_gas_price"] + ) + + with pytest.raises(MessageNoMatchingAllocation): + consume_message_fees( + accounting, + [ + { + "messageType": 0, + "recipient": "0x0000000000000000000000000000000000000099", + "onAcceptance": False, + "declaredBudget": 0, + "callKey": "0x" + "12" * 32, + "gasUsed": 100, + } + ], + policy=StudioFeePolicy(receipt_gas_price=7), + ) + + +def test_closed_external_on_acceptance_keeps_phase_error_precedence() -> None: + accounting = { + "message_fee_budget": 0, + "message_fee_consumed": 0, + "message_allocations": [], + "message_allocation_policy": "closed", + } + + with pytest.raises(ExternalAllocationInvalid, match="ExternalOnAcceptance"): + consume_message_fees( + accounting, + [ + { + "messageType": 0, + "recipient": "0x0000000000000000000000000000000000000099", + "onAcceptance": True, + "declaredBudget": 0, + "callKey": "0x" + "12" * 32, + } + ], + ) + + +@pytest.mark.parametrize("subtree", [b"", _grant(0, 0, [])]) +@pytest.mark.parametrize( + "fee_params,error", + [ + (_internal_fee_params() + bytes(32), InvalidFeeParams), + (_internal_fee_params(rotations=[0], appeal_rounds=1), InvalidAppealRounds), + (_internal_fee_params(leader=1, validator=1), PhaseTimeoutOutOfBounds), + ], +) +def test_omitted_and_explicit_closed_grants_validate_fee_terms( + subtree: bytes, fee_params: bytes, error: type[Exception] +) -> None: + message = _child_message(subtree, 1_000_000) + message["feeParams"] = fee_params + policy = StudioFeePolicy( + min_propose_timeunits=2, + min_commit_timeunits=2, + ) + + with pytest.raises(error): + create_child_fee_accounting( + message=message, + parent_fees_distribution=None, + policy=policy, + ) + with pytest.raises(error): + consume_message_fees( + {"message_fee_budget": 0, "message_allocation_policy": "closed"}, + [message], + policy=policy, + ) + + +def test_declared_budget_must_cover_primary_and_explicit_grant() -> None: + message = _child_message(_grant(1, 30, []), 39) + accounting = { + "message_fee_budget": 0, + "message_allocations": [], + "message_allocation_policy": "open", + } + + with pytest.raises(MessageDeclaredBudgetInsufficient): + consume_message_fees(accounting, [message]) + + +def _attach_descendant(parent: dict, message: dict) -> dict: + parent["activation_prices_locked"] = True + data = {} + _attach_child_fee_accounting( + SimpleNamespace( + transaction=SimpleNamespace( + origin_address="0x0000000000000000000000000000000000000099", + from_address="0x0000000000000000000000000000000000000088", + to_address="0x0000000000000000000000000000000000000077", + ) + ), + parent, + fill_message_fee_payload_from_allocation(parent, message), + SimpleNamespace(use_balance=message["useBalance"], value=0), + data, + ) + return data[FEE_ACCOUNTING_KEY] + + +@pytest.mark.parametrize("finish", [cancel_fee_accounting, settle_fee_accounting]) +@pytest.mark.parametrize("policy", [1, 2]) +def test_descendant_refund_returns_to_contract_depositor(finish, policy: int) -> None: + depositor = "0x0000000000000000000000000000000000000066" + _, parent = create_child_fee_accounting( + message=_child_message( + _grant(policy, 30, [_node()] if policy == 2 else []), 40 + ), + parent_fees_distribution=None, + sender=depositor, + ) + message = _child_message(b"", 30) + message["useBalance"] = False + child = _attach_descendant(parent, message) + + settled, refund = finish(child) + + assert refund > 0 + assert {item["recipient"] for item in settled["fee_refund_settlements"]} == { + depositor + } + assert sum(item["amount"] for item in settled["fee_refund_settlements"]) == refund + assert finish(settled)[1] == 0 + + +def test_pinned_descendants_keep_admitted_parent_appeal_rules() -> None: + nodes = [ + _node(budget=50), + _node(parent_index=0, budget=40), + _node( + parent_index=1, + budget=30, + on_acceptance=True, + fee_params=_internal_fee_params(rotations=[0, 0]), + ), + ] + _, parent = create_child_fee_accounting( + message=_child_message(_grant(2, 50, nodes), 60), + parent_fees_distribution=None, + sender="0x0000000000000000000000000000000000000066", + ) + + for node, message_budget in zip(nodes, [40, 30, 0]): + message = _child_message(b"", node[5]) + message.update( + useBalance=False, + feeParams=node[6], + onAcceptance=node[1], + ) + child = _attach_descendant(parent, message) + assert child["message_allocations_from_grant"] is True + assert child["message_fee_budget"] == message_budget + parent = child + + +def test_zero_budget_balance_child_retains_closed_grant() -> None: + message = _child_message(_grant(0, 0, []), 0) + message["feeParams"] = _internal_fee_params(execution_budget=0) + + child = _attach_descendant({}, message) + + assert child["message_allocation_policy"] == "closed" + assert child["message_allocations_from_grant"] is True + assert child["sender"] == "0x0000000000000000000000000000000000000077" + assert genvm_message_fee_allocation(child) == [UNMATCHED_EXTERNAL_GUARD_ALLOC] + + +@pytest.mark.parametrize("declared_budget", [0, 10]) +def test_open_grant_descendant_with_no_remaining_budget_stays_metered( + declared_budget: int, +) -> None: + _, parent = create_child_fee_accounting( + message=_child_message(_grant(1, 30, []), 40), + parent_fees_distribution=None, + ) + message = _child_message(b"", declared_budget) + message["feeParams"] = _internal_fee_params(execution_budget=declared_budget) + message["useBalance"] = False + child = _attach_descendant(parent, message) + + assert child["message_fee_budget"] == 0 + assert not uses_unmetered_message_fee_pool(child) + assert genvm_fee_context(child)[0]["message_fee"] == 0 + assert all(node["budget"] == 0 for node in genvm_message_fee_allocation(child)) + unfunded = { + **message, + "declaredBudget": 10, + "feeParams": _internal_fee_params(), + } + with pytest.raises(MessageBudgetExceeded): + consume_message_fees(child, [unfunded]) diff --git a/tests/unit/test_genvm_debug_mode_gate.py b/tests/unit/test_genvm_debug_mode_gate.py index e5c9d14d8..0d4672405 100644 --- a/tests/unit/test_genvm_debug_mode_gate.py +++ b/tests/unit/test_genvm_debug_mode_gate.py @@ -42,7 +42,7 @@ def test_debug_mode_enabled_when_true(monkeypatch, base_module): def test_debug_mode_disabled_when_false(monkeypatch, base_module): """Prd setting: GENVM_DEBUG_MODE=false → 'safe' so the executor rejects - `py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng` / `:test` runner aliases. + `py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0` / `:test` runner aliases. """ for value in ("false", "FALSE", "0", "no", "off", "anything-else"): monkeypatch.setenv("GENVM_DEBUG_MODE", value) diff --git a/tests/unit/test_node_state_proxy_metrics.py b/tests/unit/test_node_state_proxy_metrics.py index 75f78ba9c..ee34e4cb2 100644 --- a/tests/unit/test_node_state_proxy_metrics.py +++ b/tests/unit/test_node_state_proxy_metrics.py @@ -17,10 +17,13 @@ from backend.node.genvm.base import Host as GenVMHost import backend.node.genvm.origin.calldata as gvm_calldata from backend.node.genvm.origin.base_host import RunHostAndProgramRes +from backend.node.genvm.origin.fees import UNMATCHED_EXTERNAL_GUARD_ALLOC from backend.node.genvm.origin.host_fns import ResultCode from backend.node.genvm.origin.leader_public_data import LeaderPublicData from backend.node.types import Address, ExecutionMode, ExecutionResultStatus from backend.protocol_rpc.fees import ( + CONTRACT_DESCENDANT_GRANT_ABI_TYPE, + CONTRACT_DESCENDANT_GRANT_DOMAIN, GENVM_UNMETERED_DATA_FEE_BUCKET, MESSAGE_ALLOCATION_NODE_ABI_TYPE, StudioFeePolicy, @@ -167,10 +170,10 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): "storage_fee_max_gas_price": 12, "receipt_fee_max_gas_price": 13, }, - "declaredBudget": 60, - "callKey": bytes.fromhex("12" * 32), + "message_fee": 60, + "call_key": bytes.fromhex("12" * 32), "subtree": genvm_subtree, - "useBalance": True, + "use_balance": True, }, { "type": "InternalDeployMessage", @@ -180,7 +183,7 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): "value": 0, "on": "finalized", "fee_params": "0x" + deploy_fee_params.hex(), - "declared_budget": 70, + "message_fee": 70, "call_key": "0x" + "34" * 32, "allocation_subtree": allocation_subtree, }, @@ -193,10 +196,8 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): "gas_limit": 21_000, "max_gas_price": 10, }, - "declaredBudget": 0, - "callKey": bytes.fromhex("56" * 32), - "allocationSubtree": [], - "gasUsed": 123, + "message_fee": 0, + "call_key": bytes.fromhex("56" * 32), }, ], result_leader_public_data=LeaderPublicData([]).encode(), @@ -239,7 +240,7 @@ def test_host_provide_result_preserves_fee_metadata_from_genvm_emissions(): assert eth_send.fee_params == eth_send_fee_params assert eth_send.declared_budget == 0 assert eth_send.call_key == "0x" + "56" * 32 - assert eth_send.gas_used == 123 + assert eth_send.gas_used == 0 assert execution.data_fees_remaining == { "execution_data_gas": 100, "message_fee": 90, @@ -251,6 +252,15 @@ def test_malformed_allocation_subtree_preserves_exact_receipt_bytes(): assert _emission_allocation_subtree({"subtree": b"\x01\x02\xff"}) == "0x0102ff" +def test_descendant_grant_subtree_preserves_exact_receipt_bytes(): + grant = encode( + ["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], + [CONTRACT_DESCENDANT_GRANT_DOMAIN, (1, 0, 0, [])], + ) + + assert _emission_allocation_subtree({"subtree": grant}) == "0x" + grant.hex() + + @pytest.mark.asyncio async def test_state_proxy_metrics_use_executed_proxy_instance(): node = _make_node() @@ -346,7 +356,7 @@ async def test_run_genvm_receives_fee_context_from_transaction_accounting(): fee_context = run_genvm_host.await_args.kwargs["fee_context"] assert fee_context.bucket_totals == { "execution_data_gas": fees_distribution["executionBudgetPerRound"], - "message_fee": 0, + "message_fee": GENVM_UNMETERED_DATA_FEE_BUCKET, "nondet_outputs": GENVM_UNMETERED_DATA_FEE_BUCKET, "submitted_messages": GENVM_UNMETERED_DATA_FEE_BUCKET, "submitted_messages_count": 20, @@ -429,7 +439,8 @@ async def test_run_genvm_passes_mode2_message_fee_allocations_to_genvm(): ) allocations = run_genvm_host.await_args.kwargs["fee_context"].message_fee_allocation - assert len(allocations) == 1 + assert allocations[1] == UNMATCHED_EXTERNAL_GUARD_ALLOC + assert len(allocations) == 2 allocation = allocations[0] assert allocation["recipient"].as_hex.lower() == recipient assert allocation["call_key"] == bytes.fromhex("34" * 32) @@ -458,7 +469,7 @@ async def test_run_genvm_passes_mode2_message_fee_allocations_to_genvm(): @pytest.mark.asyncio -async def test_run_genvm_rejects_fee_bearing_mode1_before_genvm(): +async def test_run_genvm_materializes_fee_bearing_open_mode(): node = _make_node() execution_result = ExecutionResult( result=ExecutionReturn(ret=b"\x00\x00"), @@ -505,20 +516,14 @@ async def test_run_genvm_rejects_fee_bearing_mode1_before_genvm(): fee_accounting=fee_accounting, ) - run_genvm_host.assert_not_awaited() - assert receipt.execution_result == ExecutionResultStatus.ERROR - assert ( - receipt.genvm_result["error_code"] - == "Mode1MessageFeesRequireGenVMPerEmissionSupport" - ) - assert receipt.genvm_result["raw_error"] == { - "fatal": False, - "causes": [ - "Mode1MessageFeesRequireGenVMPerEmissionSupport: fee-bearing " - "GenVM messages require a message allocation tree" - ], - "ctx": {"source": "studio_fee_accounting"}, - } + run_genvm_host.assert_awaited_once() + assert receipt.execution_result == ExecutionResultStatus.SUCCESS + allocations = run_genvm_host.await_args.kwargs["fee_context"].message_fee_allocation + assert len(allocations) == 3 + assert all(allocation["budget"] == 55 for allocation in allocations) + assert all(allocation["children_budget"] == 0 for allocation in allocations) + assert all(allocation["subtree"] == b"" for allocation in allocations) + assert all("children" not in allocation for allocation in allocations) @pytest.mark.asyncio diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 9312fde2c..4c6fd14d4 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -88,7 +88,6 @@ MessageAllocationsRestricted, MessageFeesReportMismatch, MessageNoMatchingAllocation, - Mode1MessageFeesRequireGenVMPerEmissionSupport, message_effect_identities, min_message_primary_fees, PhaseTimeoutOutOfBounds, @@ -147,6 +146,7 @@ record_execution_fee_consumption, record_reveal_message_fees, refund_failed_external_message_fee, + refund_failed_internal_message_fee, required_fee_deposit, _genvm_receipt_metered_fee, _receipt_fee_report, @@ -166,6 +166,7 @@ PendingTransaction, Receipt, ) +from backend.node.genvm.origin.fees import UNMATCHED_EXTERNAL_GUARD_ALLOC from backend.protocol_rpc.types import ( DecodedsubmitAppealDataArgs, DecodedRollupTransaction, @@ -3176,7 +3177,7 @@ def test_genvm_fee_context_uses_transaction_execution_budget_and_policy(): assert bucket_totals == { "execution_data_gas": 123, - "message_fee": 0, + "message_fee": GENVM_UNMETERED_DATA_FEE_BUCKET, "nondet_outputs": GENVM_UNMETERED_DATA_FEE_BUCKET, "submitted_messages": GENVM_UNMETERED_DATA_FEE_BUCKET, "submitted_messages_count": 12, @@ -3362,7 +3363,8 @@ def test_genvm_message_fee_allocation_maps_studio_nodes(): ], ), } - assert len(allocations) == 1 + assert allocations[1] == UNMATCHED_EXTERNAL_GUARD_ALLOC + assert len(allocations) == 2 def test_genvm_message_fee_allocation_encodes_descendants_under_roots(): @@ -3391,7 +3393,8 @@ def test_genvm_message_fee_allocation_encodes_descendants_under_roots(): allocations = genvm_message_fee_allocation(accounting) - assert len(allocations) == 1 + assert allocations[1] == UNMATCHED_EXTERNAL_GUARD_ALLOC + assert len(allocations) == 2 assert allocations[0]["recipient"] == root["recipient"] assert allocations[0]["call_key"] == bytes.fromhex("12" * 32) assert allocations[0]["budget"] == 120 @@ -3435,8 +3438,8 @@ def test_genvm_message_fee_allocation_preserves_subtree_order_and_direct_budget( allocations = genvm_message_fee_allocation(accounting) - assert [node["budget"] for node in allocations] == [250, 150] - assert [node["children_budget"] for node in allocations] == [180, 70] + assert [node["budget"] for node in allocations] == [250, 150, 0] + assert [node["children_budget"] for node in allocations] == [180, 70, 0] first = decode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], allocations[0]["subtree"])[0] second = decode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], allocations[1]["subtree"])[0] assert [node[2] for node in first] == [NODE_ROOT_SENTINEL, 0, 1, 0] @@ -3444,6 +3447,7 @@ def test_genvm_message_fee_allocation_preserves_subtree_order_and_direct_budget( assert [node[2] for node in second] == [NODE_ROOT_SENTINEL, 0] assert [node[5] for node in second] == [150, 70] assert all(node[6] == fee_params for node in (*first, *second)) + assert allocations[2] == UNMATCHED_EXTERNAL_GUARD_ALLOC assert nodes[3]["parentIndex"] == 1 assert nodes[4]["parentIndex"] == 2 @@ -3467,7 +3471,7 @@ def test_genvm_message_fee_allocation_rejects_invalid_parent(parent_index): ) -def test_genvm_message_fee_allocation_does_not_add_uncommitted_fallback(): +def test_genvm_message_fee_allocation_preserves_only_pinned_external_keys(): external = _allocation( message_type=0, on_acceptance=False, @@ -3500,17 +3504,24 @@ def test_genvm_message_fee_allocation_does_not_add_uncommitted_fallback(): "max_gas_price": 10, }, } - assert len(allocations) == 1 + assert allocations[1] == UNMATCHED_EXTERNAL_GUARD_ALLOC + assert len(allocations) == 2 -def test_genvm_message_fee_allocation_keeps_legacy_gasless_messages_unmetered(): - allocations = genvm_message_fee_allocation(None) +def test_genvm_message_fee_allocation_keeps_legacy_messages_unmetered_with_funding(): + policy = StudioFeePolicy( + gen_per_time_unit=2, + storage_unit_price=3, + receipt_gas_price=4, + ) + allocations = genvm_message_fee_allocation(None, policy=policy) assert [next(iter(node["fee_params"])) for node in allocations] == [ "External", "Internal", "Internal", ] + assert UNMATCHED_EXTERNAL_GUARD_ALLOC not in allocations assert [node["on"] for node in allocations] == [ "finalized", "finalized", @@ -3520,30 +3531,37 @@ def test_genvm_message_fee_allocation_keeps_legacy_gasless_messages_unmetered(): assert all(node["call_key"] is None for node in allocations) assert all(node["children_budget"] == 0 for node in allocations) assert all(node["subtree"] == b"" for node in allocations) - base_internal_params = { - "leader_timeunits_allocation": 0, - "validator_timeunits_allocation": 0, - "execution_budget_per_round": 0, - "rotations": [0], - "max_price_gen_per_time_unit": 1, - } - # the finalized node keeps the vendored GenVM default gas-price cap - assert [ + internal_params = [ node["fee_params"]["Internal"] for node in allocations if "Internal" in node["fee_params"] - ] == [ - { - **base_internal_params, - "storage_fee_max_gas_price": 20, - "receipt_fee_max_gas_price": 20, - }, - { - **base_internal_params, - "storage_fee_max_gas_price": 2**200, - "receipt_fee_max_gas_price": 2**200, - }, ] + assert internal_params[0] == internal_params[1] + params = internal_params[0] + assert params["leader_timeunits_allocation"] == 100 + assert params["validator_timeunits_allocation"] == 200 + assert params["execution_budget_per_round"] >= max( + DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, + policy.message_fee_params_budget_floor(), + ) + assert params["rotations"] == [0] + assert params["max_price_gen_per_time_unit"] >= policy.gen_per_time_unit + assert params["storage_fee_max_gas_price"] >= policy.storage_unit_price + assert params["receipt_fee_max_gas_price"] >= policy.receipt_gas_price + + decoded = decode_internal_message_fee_params( + _encode_internal_fee_params( + leader_timeunits=params["leader_timeunits_allocation"], + validator_timeunits=params["validator_timeunits_allocation"], + execution_budget_per_round=params["execution_budget_per_round"], + rotations=params["rotations"], + max_price_gen_per_time_unit=params["max_price_gen_per_time_unit"], + storage_fee_max_gas_price=params["storage_fee_max_gas_price"], + receipt_fee_max_gas_price=params["receipt_fee_max_gas_price"], + ) + ) + declared_budget = min_message_primary_fees(decoded, policy) + assert 0 < declared_budget <= allocations[1]["budget"] def test_genvm_message_fee_allocation_keeps_zero_budget_legacy_messages_unmetered(): @@ -3561,9 +3579,57 @@ def test_genvm_message_fee_allocation_keeps_zero_budget_legacy_messages_unmetere "Internal", "Internal", ] + assert UNMATCHED_EXTERNAL_GUARD_ALLOC not in allocations + + +def test_zero_message_bucket_unmetered_params_report_zero_message_fee(): + accounting = create_fee_accounting( + fees_distribution=_fees_distribution( + execution_budget_per_round=123, + total_message_fees=0, + ), + num_of_validators=5, + submitted_value=1223, + user_value=0, + ) + params = genvm_message_fee_allocation(accounting)[1]["fee_params"]["Internal"] + fee_params = _encode_internal_fee_params( + leader_timeunits=params["leader_timeunits_allocation"], + validator_timeunits=params["validator_timeunits_allocation"], + execution_budget_per_round=params["execution_budget_per_round"], + rotations=params["rotations"], + max_price_gen_per_time_unit=params["max_price_gen_per_time_unit"], + storage_fee_max_gas_price=params["storage_fee_max_gas_price"], + receipt_fee_max_gas_price=params["receipt_fee_max_gas_price"], + ) + declared_budget = min_message_primary_fees( + decode_internal_message_fee_params(fee_params) + ) + assert declared_budget > 0 + + bucket_totals, _ = genvm_fee_context(accounting) + assert bucket_totals["message_fee"] == GENVM_UNMETERED_DATA_FEE_BUCKET + + message = { + "messageType": 1, + "recipient": "0x2222222222222222222222222222222222222222", + "onAcceptance": False, + "feeParams": fee_params, + "declaredBudget": declared_budget, + "callKey": CALL_KEY_WILDCARD, + } + updated = consume_message_fees(accounting, [message]) + + assert updated["message_fee_consumed"] == 0 + assert "failed_internal_message_refunds" not in refund_failed_internal_message_fee( + updated, message + ) + assert "message_fee_unwind_events" not in unwind_reveal_message_fees( + updated, [message] + ) -def test_genvm_message_fee_allocation_rejects_fee_bearing_mode1_until_genvm_supports_it(): +def test_genvm_message_fee_allocation_materializes_fee_bearing_open_mode(): accounting = create_fee_accounting( fees_distribution=_fees_distribution(total_message_fees=55), num_of_validators=5, @@ -3571,8 +3637,18 @@ def test_genvm_message_fee_allocation_rejects_fee_bearing_mode1_until_genvm_supp user_value=0, ) - with pytest.raises(Mode1MessageFeesRequireGenVMPerEmissionSupport): - genvm_message_fee_allocation(accounting) + allocations = genvm_message_fee_allocation(accounting) + + assert [next(iter(node["fee_params"])) for node in allocations] == [ + "External", + "Internal", + "Internal", + ] + assert all(node["budget"] == 55 for node in allocations) + assert all(node["children_budget"] == 0 for node in allocations) + assert all(node["subtree"] == b"" for node in allocations) + assert all("children" not in node for node in allocations) + assert UNMATCHED_EXTERNAL_GUARD_ALLOC not in allocations def test_create_fee_accounting_records_user_side_budgets(): @@ -3651,7 +3727,7 @@ def test_genvm_fee_context_uses_locked_fee_policy_by_default(): assert bucket_totals == { "execution_data_gas": execution_budget, - "message_fee": 0, + "message_fee": GENVM_UNMETERED_DATA_FEE_BUCKET, "nondet_outputs": GENVM_UNMETERED_DATA_FEE_BUCKET, "submitted_messages": GENVM_UNMETERED_DATA_FEE_BUCKET, "submitted_messages_count": 20, @@ -7355,7 +7431,10 @@ def test_use_balance_message_validates_floor_without_consuming_sender_bucket(): "messageType": 1, "recipient": "0x2222222222222222222222222222222222222222", "onAcceptance": True, - "feeParams": _encode_internal_fee_params(), + "feeParams": _encode_internal_fee_params( + storage_fee_max_gas_price=2**96 - 1, + receipt_fee_max_gas_price=2**96 - 1, + ), "declaredBudget": 55, "callKey": EMPTY_CALL_KEY, "useBalance": True, @@ -7612,7 +7691,7 @@ def test_mode2_message_fees_reject_missing_allocation_and_phase_mismatch(): ) -def test_mode2_external_message_fees_require_matching_committed_allocation(): +def test_mode2_unmatched_external_message_rejects_at_consensus_freeze(): accounting = create_fee_accounting( fees_distribution=_fees_distribution(total_message_fees=55), message_allocations=[ @@ -9817,6 +9896,11 @@ def test_create_child_fee_accounting_strips_leaf_matched_root_subtree(): assert child_fees["totalMessageFees"] == 0 assert child_accounting["message_fee_budget"] == 0 assert child_accounting["message_allocations"] == [] + assert child_accounting["message_allocation_policy"] == "closed" + assert child_accounting["message_allocations_restricted"] is True + assert genvm_message_fee_allocation(child_accounting) == [ + UNMATCHED_EXTERNAL_GUARD_ALLOC + ] def test_create_child_fee_accounting_rejects_phase_mismatched_root_subtree(): @@ -10371,6 +10455,60 @@ def test_message_dispatch_creates_mode1_child_fee_accounting_from_pending_metada } +def test_message_dispatch_keeps_synthetic_unmetered_child_outside_fee_accounting( + monkeypatch, +): + monkeypatch.setenv("GENLAYER_STUDIO_GEN_PER_TIME_UNIT", "1") + monkeypatch.setenv("GENLAYER_STUDIO_STORAGE_UNIT_PRICE", "1") + monkeypatch.setenv("GENLAYER_STUDIO_RECEIPT_GAS_PRICE", "1") + monkeypatch.setenv("GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS", "1") + monkeypatch.setenv("GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS", "1") + policy = StudioFeePolicy.from_env() + fees_distribution, submitted_value = default_transaction_fees_for_policy(policy) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + num_of_validators=5, + submitted_value=submitted_value, + user_value=0, + sender="0x1111111111111111111111111111111111111111", + policy=policy, + ) + params = genvm_message_fee_allocation(accounting)[2]["fee_params"]["Internal"] + fee_params = _encode_internal_fee_params( + leader_timeunits=params["leader_timeunits_allocation"], + validator_timeunits=params["validator_timeunits_allocation"], + execution_budget_per_round=params["execution_budget_per_round"], + rotations=params["rotations"], + max_price_gen_per_time_unit=params["max_price_gen_per_time_unit"], + storage_fee_max_gas_price=params["storage_fee_max_gas_price"], + receipt_fee_max_gas_price=params["receipt_fee_max_gas_price"], + ) + declared_budget = min_message_primary_fees( + decode_internal_message_fee_params(fee_params), policy + ) + assert declared_budget > 0 + + context, processor = _message_dispatch_context(accounting) + pending = PendingTransaction( + address="0x2222222222222222222222222222222222222222", + calldata=b"\x12\x34", + code=None, + salt_nonce=0, + on="accepted", + value=0, + fee_params=fee_params, + declared_budget=declared_budget, + call_key="0x" + "12" * 32, + ) + + _, inserts = _get_messages_data(context, [pending], "accepted") + + child_data = inserts[0][1] + assert FEE_ACCOUNTING_KEY not in child_data + assert "fee_value" not in child_data + assert processor.updated_fee_accounting["message_fee_consumed"] == 0 + + def test_internal_deployment_descriptor_stays_zero_address_and_carries_salt(): context, _ = _message_dispatch_context({}) pending = PendingTransaction( diff --git a/third_party/genvm/version b/third_party/genvm/version index dcfffbc57..facb544d2 100644 --- a/third_party/genvm/version +++ b/third_party/genvm/version @@ -1 +1 @@ -v0.6.0-rc7 +v0.6.0-rc9