From 4e9022fa6391bb98cfb0720f538e37bbb6a20586 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Fri, 25 Sep 2026 15:49:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(genvm):=20forward=20flat=20fee=20alloca?= =?UTF-8?q?tion=20inputs=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-vendor the fee allocation types and replace recursive children with checked direct-child budgets and host-encoded FlatArrays subtrees. Preserve committed fee bytes, ordering, roots and original allowances. Pin GenVM v0.6.0-rc7, the release cut from the fee-allocation branch. --- backend/node/genvm/origin/fees.py | 22 ++-- backend/protocol_rpc/fees.py | 49 +++++---- tests/unit/test_node_state_proxy_metrics.py | 15 ++- tests/unit/test_studio_fees.py | 114 ++++++++++++++++---- third_party/genvm/version | 2 +- 5 files changed, 146 insertions(+), 56 deletions(-) diff --git a/backend/node/genvm/origin/fees.py b/backend/node/genvm/origin/fees.py index 4546185c5..26ca9fd59 100644 --- a/backend/node/genvm/origin/fees.py +++ b/backend/node/genvm/origin/fees.py @@ -1,8 +1,8 @@ """ -Message-fee allocation tree types. +Message-fee allocation types. -Mirrors the executor's `genvm_common::domain::fees` module: the fee parameters -and the nested `MessageAllocationNode` tree that is passed alongside an +Mirrors the shared `genvm_modules_interfaces::fees` module: the fee parameters +and the flat allocations that are passed alongside an execution and matched against emitted messages. """ @@ -43,13 +43,12 @@ class _ExternalParams(typing.TypedDict): class MessageAllocationNode(typing.TypedDict): recipient: Address | None call_key: bytes | None - budget: int + budget: int | None # Lifecycle the node matches against (only meaningful for internal messages). on: typing.Literal["finalized", "decided"] fee_params: MessageAllocationNodeParams - # Nested allocation subtree; the chain receives this flattened to - # parent-pointer form. - children: list["MessageAllocationNode"] + children_budget: int + subtree: bytes DEFAULT_EXTERNAL_MESSAGE_ALLOC: MessageAllocationNode = { @@ -64,7 +63,8 @@ class MessageAllocationNode(typing.TypedDict): "max_gas_price": 0, }, }, - "children": [], + "children_budget": 0, + "subtree": b"", } DEFAULT_INTERNAL_DEC_MESSAGE_ALLOC: MessageAllocationNode = { @@ -83,7 +83,8 @@ class MessageAllocationNode(typing.TypedDict): "receipt_fee_max_gas_price": 2**200, }, }, - "children": [], + "children_budget": 0, + "subtree": b"", } DEFAULT_INTERNAL_FIN_MESSAGE_ALLOC: MessageAllocationNode = { @@ -102,5 +103,6 @@ class MessageAllocationNode(typing.TypedDict): "receipt_fee_max_gas_price": 20, }, }, - "children": [], + "children_budget": 0, + "subtree": b"", } diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 9577baa1f..4f59216ac 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -2089,22 +2089,28 @@ def genvm_message_fee_allocation( _serializable_message_allocation(raw_node) for raw_node in accounting.get("message_allocations") or [] ] - genvm_nodes = [ - _genvm_message_allocation_node( - node, - address_factory, - fees_distribution, - ) - for node in studio_nodes - ] - roots: list[dict[str, Any]] = [] + children_budgets = [0] * len(studio_nodes) for index, node in enumerate(studio_nodes): + _validate_allocation_parent(index, node, studio_nodes) parent_index = int(node["parentIndex"]) - if parent_index == NODE_ROOT_SENTINEL: - roots.append(genvm_nodes[index]) + if parent_index != NODE_ROOT_SENTINEL: + children_budgets[parent_index] = _u256_add( + children_budgets[parent_index], int(node["budget"]) + ) + + roots: list[dict[str, Any]] = [] + for index, node in enumerate(studio_nodes): + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: continue - if 0 <= parent_index < len(genvm_nodes): - genvm_nodes[parent_index]["children"].append(genvm_nodes[index]) + allocation = _genvm_message_allocation_node( + node, address_factory, fees_distribution + ) + allocation["children_budget"] = children_budgets[index] + if int(node["messageType"]) == MESSAGE_TYPE_INTERNAL: + allocation["subtree"] = _allocation_subtree_bytes( + _allocation_subtree(studio_nodes, index) + ) + roots.append(allocation) return roots @@ -3725,7 +3731,7 @@ def _validate_allocation_parent( parent_index = int(node["parentIndex"]) if parent_index == NODE_ROOT_SENTINEL: return - if parent_index >= index: + if parent_index < 0 or parent_index >= index: raise AllocationTreeMalformed("AllocationTreeMalformed") parent_node = _normalize_message_allocation(message_allocations[parent_index]) @@ -4649,7 +4655,8 @@ def _genvm_message_allocation_node( "budget": int(node["budget"]), "on": _genvm_message_on(node), "fee_params": _genvm_message_fee_params(node, fees_distribution), - "children": [], + "children_budget": 0, + "subtree": b"", } @@ -4702,7 +4709,8 @@ def _genvm_unmetered_message_fee_allocation() -> list[dict[str, Any]]: "max_gas_price": 0, }, }, - "children": [], + "children_budget": 0, + "subtree": b"", }, { "recipient": None, @@ -4716,7 +4724,8 @@ def _genvm_unmetered_message_fee_allocation() -> list[dict[str, Any]]: "receipt_fee_max_gas_price": 20, }, }, - "children": [], + "children_budget": 0, + "subtree": b"", }, { "recipient": None, @@ -4724,7 +4733,8 @@ def _genvm_unmetered_message_fee_allocation() -> list[dict[str, Any]]: "budget": budget, "on": "decided", "fee_params": internal_fee_params, - "children": [], + "children_budget": 0, + "subtree": b"", }, ] @@ -4741,7 +4751,8 @@ def _genvm_external_legacy_fallback_message_fee_allocation() -> dict[str, Any]: "max_gas_price": 0, }, }, - "children": [], + "children_budget": 0, + "subtree": b"", } diff --git a/tests/unit/test_node_state_proxy_metrics.py b/tests/unit/test_node_state_proxy_metrics.py index 7087862a5..75f78ba9c 100644 --- a/tests/unit/test_node_state_proxy_metrics.py +++ b/tests/unit/test_node_state_proxy_metrics.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from eth_abi import encode +from eth_abi import decode, encode from backend.database_handler.contract_snapshot import ContractSnapshot from backend.domain.types import LLMProvider, Validator @@ -22,6 +22,7 @@ from backend.node.types import Address, ExecutionMode, ExecutionResultStatus from backend.protocol_rpc.fees import ( GENVM_UNMETERED_DATA_FEE_BUCKET, + MESSAGE_ALLOCATION_NODE_ABI_TYPE, StudioFeePolicy, create_fee_accounting, required_fee_deposit, @@ -445,9 +446,15 @@ async def test_run_genvm_passes_mode2_message_fee_allocations_to_genvm(): "receipt_fee_max_gas_price": 2**200, }, } - assert allocation["children"][0]["recipient"].as_hex.lower() == child_recipient - assert allocation["children"][0]["call_key"] == bytes.fromhex("56" * 32) - assert allocation["children"][0]["budget"] == 60 + assert allocation["children_budget"] == 60 + subtree = decode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], allocation["subtree"])[0] + assert subtree[0][2] == (1 << 256) - 1 + assert subtree[0][3] == recipient + assert subtree[1][2] == 0 + assert subtree[1][3] == child_recipient + assert subtree[1][4] == bytes.fromhex("56" * 32) + assert subtree[1][5] == 60 + assert subtree[1][6] == child_fee_params @pytest.mark.asyncio diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 1bb779609..9312fde2c 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -7,7 +7,7 @@ import pytest import rlp -from eth_abi import encode +from eth_abi import decode, encode from web3 import Web3 from backend.consensus.base import ( @@ -3344,12 +3344,28 @@ def test_genvm_message_fee_allocation_maps_studio_nodes(): "receipt_fee_max_gas_price": 2**200, }, }, - "children": [], + "children_budget": 0, + "subtree": encode( + [MESSAGE_ALLOCATION_NODE_ABI_TYPE], + [ + [ + ( + 1, + True, + NODE_ROOT_SENTINEL, + "0x" + "22" * 20, + bytes.fromhex("12" * 32), + 60, + fee_params, + ) + ] + ], + ), } assert len(allocations) == 1 -def test_genvm_message_fee_allocation_nests_descendants_under_roots(): +def test_genvm_message_fee_allocation_encodes_descendants_under_roots(): root_fee_params = _encode_internal_fee_params(leader_timeunits=6) child_fee_params = _encode_internal_fee_params(leader_timeunits=7) root = _allocation( @@ -3379,26 +3395,76 @@ def test_genvm_message_fee_allocation_nests_descendants_under_roots(): assert allocations[0]["recipient"] == root["recipient"] assert allocations[0]["call_key"] == bytes.fromhex("12" * 32) assert allocations[0]["budget"] == 120 - assert allocations[0]["children"] == [ - { - "recipient": descendant["recipient"], - "call_key": bytes.fromhex("34" * 32), - "budget": 60, - "on": "decided", - "fee_params": { - "Internal": { - "leader_timeunits_allocation": 7, - "validator_timeunits_allocation": 10, - "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, - }, - }, - "children": [], - } + assert allocations[0]["children_budget"] == 60 + assert "children" not in allocations[0] + assert decode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], allocations[0]["subtree"])[0] == ( + ( + 1, + True, + NODE_ROOT_SENTINEL, + root["recipient"], + bytes.fromhex("12" * 32), + 120, + root_fee_params, + ), + ( + 1, + True, + 0, + descendant["recipient"], + bytes.fromhex("34" * 32), + 60, + child_fee_params, + ), + ) + + +def test_genvm_message_fee_allocation_preserves_subtree_order_and_direct_budget(): + fee_params = _encode_internal_fee_params() + nodes = [ + _allocation(budget=250, fee_params=fee_params), + _allocation(budget=150, fee_params=fee_params, recipient="0x" + "33" * 20), + _allocation(parent_index=0, budget=120, fee_params=fee_params), + _allocation(parent_index=1, budget=70, fee_params=fee_params), + _allocation(parent_index=2, budget=55, fee_params=fee_params), + _allocation( + parent_index=0, budget=60, fee_params=fee_params, recipient="0x" + "44" * 20 + ), ] + accounting = {"message_allocations": nodes, "allocation_consumed": {"0": 250}} + + 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] + 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] + assert [node[5] for node in first] == [250, 120, 55, 60] + 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 nodes[3]["parentIndex"] == 1 + assert nodes[4]["parentIndex"] == 2 + + +def test_genvm_message_fee_allocation_rejects_children_budget_overflow(): + nodes = [ + _allocation(budget=NODE_ROOT_SENTINEL), + _allocation(parent_index=0, budget=NODE_ROOT_SENTINEL), + _allocation(parent_index=0, budget=1), + ] + + with pytest.raises(ArithmeticOverflow): + genvm_message_fee_allocation({"message_allocations": nodes}) + + +@pytest.mark.parametrize("parent_index", [-1, 0, 1]) +def test_genvm_message_fee_allocation_rejects_invalid_parent(parent_index): + with pytest.raises(AllocationTreeMalformed): + genvm_message_fee_allocation( + {"message_allocations": [_allocation(parent_index=parent_index)]} + ) def test_genvm_message_fee_allocation_does_not_add_uncommitted_fallback(): @@ -3426,6 +3492,8 @@ def test_genvm_message_fee_allocation_does_not_add_uncommitted_fallback(): ) assert allocations[0]["budget"] == 210_000 assert allocations[0]["on"] == "finalized" + assert allocations[0]["children_budget"] == 0 + assert allocations[0]["subtree"] == b"" assert allocations[0]["fee_params"] == { "External": { "gas_limit": 21_000, @@ -3450,6 +3518,8 @@ def test_genvm_message_fee_allocation_keeps_legacy_gasless_messages_unmetered(): ] assert all(node["recipient"] is None for node in allocations) 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, diff --git a/third_party/genvm/version b/third_party/genvm/version index 60b7d79e2..dcfffbc57 100644 --- a/third_party/genvm/version +++ b/third_party/genvm/version @@ -1 +1 @@ -v0.6.0-rc5 +v0.6.0-rc7 From 41c5c680d97b3aaa26ad4136ffb5f1d79eb08487 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 29 Sep 2026 23:22:56 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(genvm):=20pin=20the=20reroute=20test?= =?UTF-8?q?=20to=20the=20rc9=20executor=20line=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenVM v0.6.0-rc7 ships executor v0.3.0-rc9; the missing v0.3.0-rc7 directory left the pinned deploy stuck in PROPOSING --- tests/integration/test_deploy_reroute_to.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_deploy_reroute_to.py b/tests/integration/test_deploy_reroute_to.py index 08832de54..641ad4454 100644 --- a/tests/integration/test_deploy_reroute_to.py +++ b/tests/integration/test_deploy_reroute_to.py @@ -60,7 +60,7 @@ # 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-rc7") +CURRENT_EXECUTOR = os.environ.get("TEST_CURRENT_EXECUTOR", "v0.3.0-rc9") # Shares the validator registry with the other integration suites, which wipe # validators while seeding mock responses. From 8c040d8b94a54fedeab4358e2b8f802de3121738 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 29 Sep 2026 18:00:03 +0100 Subject: [PATCH 3/3] fix(fees): reserve parent appeal capacity in Studio allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Price each on-acceptance internal allocation using its emitting parent’s prepaid appeal horizon and multiply the complete child requirement, including direct child budgets. Carry the root appeal count from the transaction or child fee distribution. Fund every discovered acceptance emission opportunity while keeping an inferred per-message declared budget to one child primary plus its direct subtree. Cover the 360/1440 boundary, nested parents, finalization, discovery, and child accounting. --- backend/protocol_rpc/fees.py | 77 ++++++-- tests/unit/test_studio_fees.py | 314 +++++++++++++++++++++++++++++---- 2 files changed, 334 insertions(+), 57 deletions(-) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 4f59216ac..db760a6d0 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -1788,6 +1788,7 @@ def validate_transaction_fee_deposit( validate_message_allocations( message_allocations or [], total_message_fees=int(fees["totalMessageFees"]), + root_appeal_rounds=int(fees["appealRounds"]), policy=policy, ) @@ -1993,6 +1994,7 @@ def create_child_fee_accounting( validate_message_allocations( child_message_allocations, total_message_fees=int(child_fees["totalMessageFees"]), + root_appeal_rounds=int(child_fees["appealRounds"]), policy=policy, ) user_value = int(message.get("value", 0) or 0) @@ -2670,11 +2672,10 @@ def fill_message_fee_payload_from_allocation( # second same-key occurrence fail with MessageBudgetExceeded. fee_params = decode_internal_message_fee_params(updated["feeParams"]) policy = execution_policy_for_accounting(accounting) - updated["declaredBudget"] = _internal_allocation_min_required( - allocation, - fee_params, - policy, - ) + _child_allocation_budget_sum(allocations, index) + updated["declaredBudget"] = _u256_add( + min_message_primary_fees(fee_params, policy), + _child_allocation_budget_sum(allocations, index), + ) updated["callKey"] = _normalize_call_key( updated.get("callKey", allocation["callKey"]) ) @@ -3669,16 +3670,20 @@ def validate_message_allocations( message_allocations: list[dict[str, Any]], *, total_message_fees: int, + root_appeal_rounds: int, policy: StudioFeePolicy | None = None, ) -> None: if not message_allocations: return policy = policy or StudioFeePolicy() + root_appeal_rounds = _require_uint256(root_appeal_rounds) root_sum = 0 root_keys: set[tuple[int, str, str]] = set() external_keys: set[tuple[str, str]] = set() min_required_by_index: dict[int, int] = {} + appeal_rounds_by_index: dict[int, int] = {} + emission_factors_by_index: dict[int, int] = {} for index, raw_node in enumerate(message_allocations): root_sum += _validate_message_allocation_node( @@ -3688,13 +3693,18 @@ def validate_message_allocations( root_keys, external_keys, min_required_by_index, + appeal_rounds_by_index, + emission_factors_by_index, + root_appeal_rounds, policy, ) if root_sum != total_message_fees: raise MessageAllocationsNotEqualBudget("MessageAllocationsNotEqualBudget") - _validate_child_budget_consistency(message_allocations, min_required_by_index) + _validate_child_budget_consistency( + message_allocations, min_required_by_index, emission_factors_by_index + ) _validate_allocation_tree_depth(message_allocations, policy) _validate_sibling_duplicates(message_allocations) @@ -3706,6 +3716,9 @@ def _validate_message_allocation_node( root_keys: set[tuple[int, str, str]], external_keys: set[tuple[str, str]], min_required_by_index: dict[int, int], + appeal_rounds_by_index: dict[int, int], + emission_factors_by_index: dict[int, int], + root_appeal_rounds: int, policy: StudioFeePolicy, ) -> int: node = _normalize_message_allocation(raw_node) @@ -3718,8 +3731,24 @@ def _validate_message_allocation_node( if int(node["messageType"]) != MESSAGE_TYPE_INTERNAL: raise AllocationTreeMalformed("AllocationTreeMalformed") - min_required = _validate_internal_allocation_budget(node, policy) + parent_index = int(node["parentIndex"]) + parent_appeal_rounds = ( + root_appeal_rounds + if parent_index == NODE_ROOT_SENTINEL + else appeal_rounds_by_index[parent_index] + ) + emission_factor = _internal_allocation_emission_factor( + bool(node["onAcceptance"]), parent_appeal_rounds + ) + internal_fee_params = decode_internal_message_fee_params(node["feeParams"]) + min_required = _validate_internal_allocation_budget( + node, internal_fee_params, emission_factor, policy + ) min_required_by_index[index] = min_required + appeal_rounds_by_index[index] = _require_uint256( + internal_fee_params["appealRounds"] + ) + emission_factors_by_index[index] = emission_factor return _root_allocation_budget(node, root_keys) @@ -3741,16 +3770,19 @@ def _validate_allocation_parent( def _validate_internal_allocation_budget( node: dict[str, Any], + internal_fee_params: dict[str, Any], + emission_factor: int, policy: StudioFeePolicy, ) -> int: - internal_fee_params = decode_internal_message_fee_params(node["feeParams"]) _validate_phase_timeout_bounds( int(internal_fee_params["leaderTimeunitsAllocation"]), int(internal_fee_params["validatorTimeunitsAllocation"]), policy, allow_zero=True, ) - min_required = _internal_allocation_min_required(node, internal_fee_params, policy) + min_required = _internal_allocation_min_required( + internal_fee_params, emission_factor, policy + ) if int(node["budget"]) < min_required: raise AllocationLifecycleBudgetInsufficient( "AllocationLifecycleBudgetInsufficient" @@ -3775,18 +3807,19 @@ def _validate_internal_message_price_caps( raise FeeValueMustBeNonZero(f"FeeValueMustBeNonZero({field_index})") +def _internal_allocation_emission_factor( + on_acceptance: bool, parent_appeal_rounds: int +) -> int: + return _u256_add(parent_appeal_rounds, 1) if on_acceptance else 1 + + def _internal_allocation_min_required( - node: dict[str, Any], internal_fee_params: dict[str, Any], + emission_factor: int, policy: StudioFeePolicy, ) -> int: min_primary = min_message_primary_fees(internal_fee_params, policy) - lifecycle_multiplier = ( - int(internal_fee_params["appealRounds"]) + 1 - if bool(node["onAcceptance"]) - else 1 - ) - return min_primary * lifecycle_multiplier + return _u256_mul(min_primary, emission_factor) def _root_allocation_budget( @@ -3806,13 +3839,18 @@ def _root_allocation_budget( def _validate_child_budget_consistency( message_allocations: list[dict[str, Any]], min_required_by_index: dict[int, int], + emission_factors_by_index: dict[int, int], ) -> None: for index, raw_node in enumerate(message_allocations): node = _normalize_message_allocation(raw_node) if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: continue child_sum = _child_allocation_budget_sum(message_allocations, index) - if int(node["budget"]) < min_required_by_index[index] + child_sum: + required = _u256_add( + min_required_by_index[index], + _u256_mul(child_sum, emission_factors_by_index[index]), + ) + if int(node["budget"]) < required: raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") @@ -5506,8 +5544,11 @@ def discovered_message_fee_allocations( ], ) per_message_budget = min_message_primary_fees(internal_params, policy) - budget = per_message_budget * count on_acceptance = bool(item["onAcceptance"]) + emission_factor = _internal_allocation_emission_factor( + on_acceptance, int(fees["appealRounds"]) + ) + budget = _u256_mul(per_message_budget, count, emission_factor) allocations.append( { diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 9312fde2c..67b3f2ab6 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -2212,9 +2212,58 @@ def test_discovered_message_allocations_are_exact_and_consensus_valid(): allocations, total_message_fees=total_message_fees, policy=policy, + root_appeal_rounds=0, ) +def test_discovered_acceptance_allocations_reserve_each_parent_appeal(): + policy = StudioFeePolicy.from_env() + fees_distribution = _env_fees_distribution(appeals=2, rotations=[0, 0, 0]) + receipt = { + "pending_transactions": [ + { + "messageType": "Internal", + "address": "0x2222222222222222222222222222222222222222", + "on": "accepted", + }, + { + "messageType": "Internal", + "address": "0x2222222222222222222222222222222222222222", + "on": "accepted", + }, + { + "messageType": "Internal", + "address": "0x3333333333333333333333333333333333333333", + "on": "finalized", + }, + ] + } + + allocations = discovered_message_fee_allocations(receipt, fees_distribution, policy) + accepted, finalized = allocations + accepted_primary = min_message_primary_fees( + decode_internal_message_fee_params(accepted["feeParams"]), policy + ) + finalized_primary = min_message_primary_fees( + decode_internal_message_fee_params(finalized["feeParams"]), policy + ) + assert accepted["budget"] == accepted_primary * 2 * 3 + assert finalized["budget"] == finalized_primary + + fees_distribution["totalMessageFees"] = sum( + allocation["budget"] for allocation in allocations + ) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + message_allocations=allocations, + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5, policy), + user_value=0, + policy=policy, + ) + assert accounting["message_fee_budget"] == fees_distribution["totalMessageFees"] + + def test_discovered_internal_deploy_allocation_funds_child_execution_after_startup(): policy = StudioFeePolicy.from_env() fees_distribution = _env_fees_distribution(execution_budget_per_round=0) @@ -2790,63 +2839,62 @@ async def fake_sim_call(**kwargs): def test_message_allocations_accept_root_internal_budget_matching_total(): allocation = _allocation(budget=55) - validate_message_allocations([allocation], total_message_fees=55) + validate_message_allocations( + [allocation], total_message_fees=55, root_appeal_rounds=0 + ) def test_message_allocations_reject_root_budget_mismatch(): allocation = _allocation(budget=55) with pytest.raises(MessageAllocationsNotEqualBudget): - validate_message_allocations([allocation], total_message_fees=56) + validate_message_allocations( + [allocation], total_message_fees=56, root_appeal_rounds=0 + ) def test_message_allocations_reject_parent_that_does_not_precede_child(): allocation = _allocation(parent_index=0, budget=55) with pytest.raises(AllocationTreeMalformed): - validate_message_allocations([allocation], total_message_fees=0) + validate_message_allocations( + [allocation], total_message_fees=0, root_appeal_rounds=0 + ) def test_message_allocations_reject_lifecycle_budget_below_minimum(): allocation = _allocation(budget=54) with pytest.raises(AllocationLifecycleBudgetInsufficient): - validate_message_allocations([allocation], total_message_fees=54) + validate_message_allocations( + [allocation], total_message_fees=54, root_appeal_rounds=0 + ) -def test_message_allocations_enforce_on_acceptance_lifecycle_multiplier(): - fee_params = _encode_internal_fee_params(appeals=1, rotations=[0, 0]) - min_primary = calculate_round_fees( - _fees_distribution( - leader_timeunits=5, - validator_timeunits=10, - appeals=1, - rotations=[0, 0], - ), - 5, +@pytest.mark.parametrize( + "parent_appeals,child_appeals,emission_factor", + [(3, 0, 4), (0, 3, 1)], +) +def test_message_allocations_use_emitting_parent_appeal_count( + parent_appeals, child_appeals, emission_factor +): + fee_params = _encode_internal_fee_params(appeals=child_appeals) + min_primary = min_message_primary_fees( + decode_internal_message_fee_params(fee_params) ) + required = min_primary * emission_factor with pytest.raises(AllocationLifecycleBudgetInsufficient): validate_message_allocations( - [ - _allocation( - on_acceptance=True, - budget=(min_primary * 2) - 1, - fee_params=fee_params, - ) - ], - total_message_fees=(min_primary * 2) - 1, + [_allocation(budget=required - 1, fee_params=fee_params)], + total_message_fees=required - 1, + root_appeal_rounds=parent_appeals, ) validate_message_allocations( - [ - _allocation( - on_acceptance=True, - budget=min_primary * 2, - fee_params=fee_params, - ) - ], - total_message_fees=min_primary * 2, + [_allocation(budget=required, fee_params=fee_params)], + total_message_fees=required, + root_appeal_rounds=parent_appeals, ) @@ -2871,6 +2919,7 @@ def test_message_allocations_do_not_multiply_on_finalization_budget(): ) ], total_message_fees=min_primary, + root_appeal_rounds=3, ) @@ -2881,7 +2930,74 @@ def test_message_allocations_reject_parent_budget_below_child_sum_plus_minimum() ] with pytest.raises(AllocationTreeBudgetInconsistent): - validate_message_allocations(allocations, total_message_fees=100) + validate_message_allocations( + allocations, total_message_fees=100, root_appeal_rounds=0 + ) + + +@pytest.mark.parametrize( + "underfunded_budget,expected_error", + [ + (360, AllocationLifecycleBudgetInsufficient), + (1_439, AllocationTreeBudgetInconsistent), + ], +) +def test_message_allocations_multiply_direct_child_budget_by_parent_appeals( + underfunded_budget, expected_error +): + fee_params = _encode_internal_fee_params( + leader_timeunits=30, validator_timeunits=30 + ) + primary = min_message_primary_fees(decode_internal_message_fee_params(fee_params)) + assert primary == 180 + child_budget = 180 + one_emission = primary + child_budget + allocations = [ + _allocation(budget=underfunded_budget, fee_params=fee_params), + _allocation(parent_index=0, budget=child_budget, fee_params=fee_params), + ] + + with pytest.raises(expected_error): + validate_message_allocations( + allocations, + total_message_fees=underfunded_budget, + root_appeal_rounds=3, + ) + + allocations[0]["budget"] = one_emission * 4 + validate_message_allocations( + allocations, + total_message_fees=one_emission * 4, + root_appeal_rounds=3, + ) + + +def test_message_allocations_nested_parent_appeals_price_grandchild(): + parent_fee_params = _encode_internal_fee_params(appeals=2) + parent_primary = min_message_primary_fees( + decode_internal_message_fee_params(parent_fee_params) + ) + grandchild_budget = 55 * 3 + allocations = [ + _allocation( + budget=parent_primary + grandchild_budget, fee_params=parent_fee_params + ), + _allocation(parent_index=0, budget=grandchild_budget), + ] + + validate_message_allocations( + allocations, + total_message_fees=parent_primary + grandchild_budget, + root_appeal_rounds=0, + ) + + allocations[1]["budget"] -= 1 + with pytest.raises(AllocationLifecycleBudgetInsufficient): + validate_message_allocations( + allocations, + total_message_fees=parent_primary + grandchild_budget, + root_appeal_rounds=0, + ) def test_message_allocations_reject_duplicate_root_internal_keys(): @@ -2891,7 +3007,9 @@ def test_message_allocations_reject_duplicate_root_internal_keys(): ] with pytest.raises(AllocationDuplicateKey): - validate_message_allocations(allocations, total_message_fees=110) + validate_message_allocations( + allocations, total_message_fees=110, root_appeal_rounds=0 + ) def test_message_allocations_reject_duplicate_normalized_root_internal_keys(): @@ -2901,7 +3019,9 @@ def test_message_allocations_reject_duplicate_normalized_root_internal_keys(): ] with pytest.raises(AllocationDuplicateKey): - validate_message_allocations(allocations, total_message_fees=110) + validate_message_allocations( + allocations, total_message_fees=110, root_appeal_rounds=0 + ) def test_message_allocations_reject_duplicate_sibling_keys(): @@ -2912,7 +3032,9 @@ def test_message_allocations_reject_duplicate_sibling_keys(): ] with pytest.raises(AllocationDuplicateKey): - validate_message_allocations(allocations, total_message_fees=200) + validate_message_allocations( + allocations, total_message_fees=200, root_appeal_rounds=0 + ) def test_message_allocations_reject_depth_above_default_cap(): @@ -2926,7 +3048,9 @@ def test_message_allocations_reject_depth_above_default_cap(): ] with pytest.raises(AllocationTreeTooDeep): - validate_message_allocations(allocations, total_message_fees=330) + validate_message_allocations( + allocations, total_message_fees=330, root_appeal_rounds=0 + ) def test_internal_allocation_defers_zero_price_cap_rejection_until_reveal(): @@ -2937,7 +3061,9 @@ def test_internal_allocation_defers_zero_price_cap_rejection_until_reveal(): ) allocation = _allocation(budget=55, fee_params=fee_params) - validate_message_allocations([allocation], total_message_fees=55) + validate_message_allocations( + [allocation], total_message_fees=55, root_appeal_rounds=0 + ) accounting = create_fee_accounting( fees_distribution=_fees_distribution(total_message_fees=55), message_allocations=[allocation], @@ -2970,7 +3096,9 @@ def test_message_allocations_accept_valid_external_allocation(): fee_params=_encode_external_fee_params(gas_limit=21_000, max_gas_price=10), ) - validate_message_allocations([allocation], total_message_fees=210_000) + validate_message_allocations( + [allocation], total_message_fees=210_000, root_appeal_rounds=0 + ) def test_message_allocations_reject_invalid_external_allocation(): @@ -2982,7 +3110,9 @@ def test_message_allocations_reject_invalid_external_allocation(): ) with pytest.raises(ExternalAllocationInvalid): - validate_message_allocations([allocation], total_message_fees=210_001) + validate_message_allocations( + [allocation], total_message_fees=210_001, root_appeal_rounds=0 + ) def test_message_allocations_reject_external_allocation_invariants(): @@ -2999,6 +3129,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ) ], total_message_fees=210_000, + root_appeal_rounds=0, ) with pytest.raises(ExternalAllocationInvalid): @@ -3014,6 +3145,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ) ], total_message_fees=210_000, + root_appeal_rounds=0, ) with pytest.raises(ExternalAllocationInvalid): @@ -3026,12 +3158,14 @@ def test_message_allocations_reject_external_allocation_invariants(): ) ], total_message_fees=0, + root_appeal_rounds=0, ) with pytest.raises(InvalidFeeParams): validate_message_allocations( [_allocation(message_type=0, budget=210_000, fee_params=b"\x01")], total_message_fees=210_000, + root_appeal_rounds=0, ) with pytest.raises(ExternalAllocationInvalid): @@ -3049,6 +3183,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ), ], total_message_fees=420_000, + root_appeal_rounds=0, ) with pytest.raises(ExternalAllocationInvalid): @@ -3068,6 +3203,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ), ], total_message_fees=420_000, + root_appeal_rounds=0, ) with pytest.raises(AllocationTreeMalformed): @@ -3081,6 +3217,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ) ], total_message_fees=210_000, + root_appeal_rounds=0, ) with pytest.raises(AllocationTreeMalformed): @@ -3099,6 +3236,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ), ], total_message_fees=210_000, + root_appeal_rounds=0, ) @@ -3113,7 +3251,9 @@ def test_message_allocations_reject_external_on_acceptance(): with pytest.raises( ExternalAllocationInvalid, match="ExternalOnAcceptanceNotSupported" ): - validate_message_allocations([allocation], total_message_fees=210_000) + validate_message_allocations( + [allocation], total_message_fees=210_000, root_appeal_rounds=0 + ) def test_message_allocations_enforce_external_gas_limit_floor(): @@ -3129,6 +3269,7 @@ def test_message_allocations_enforce_external_gas_limit_floor(): [allocation], total_message_fees=209_990, policy=StudioFeePolicy(min_external_gas_limit=21_000), + root_appeal_rounds=0, ) @@ -3145,6 +3286,21 @@ def test_transaction_fee_validation_runs_message_allocation_checks(): ) +def test_transaction_fee_validation_uses_root_parent_appeals(): + fees_distribution = _fees_distribution( + appeals=3, rotations=[0, 0, 0, 0], total_message_fees=55 + ) + + with pytest.raises(AllocationLifecycleBudgetInsufficient): + validate_transaction_fee_deposit( + fees_distribution=fees_distribution, + message_allocations=[_allocation(budget=55)], + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + + def test_genvm_fee_context_uses_transaction_execution_budget_and_policy(): accounting = create_fee_accounting( fees_distribution=_fees_distribution(execution_budget_per_round=123), @@ -7504,6 +7660,38 @@ def test_fill_message_fee_payload_uses_per_occurrence_budget_for_shared_allocati assert updated["allocation_consumed"] == {"0": 110} +def test_fill_message_fee_payload_uses_one_child_primary_with_child_appeals(): + fee_params = _encode_internal_fee_params(appeals=2) + child_primary = min_message_primary_fees( + decode_internal_message_fee_params(fee_params) + ) + recipient = "0x2222222222222222222222222222222222222222" + fees_distribution = _fees_distribution(total_message_fees=child_primary) + accounting = create_fee_accounting( + fees_distribution=fees_distribution, + message_allocations=[ + _allocation( + recipient=recipient, budget=child_primary, fee_params=fee_params + ) + ], + num_of_validators=5, + submitted_value=required_fee_deposit(fees_distribution, 5), + user_value=0, + ) + + message = fill_message_fee_payload_from_allocation( + accounting, + { + "messageType": 1, + "recipient": recipient, + "onAcceptance": True, + "declaredBudget": 0, + "callKey": CALL_KEY_WILDCARD, + }, + ) + assert message["declaredBudget"] == child_primary + + def test_flat_array_message_fee_payload_ignores_mismatched_receipt_subtree(): fee_params = _encode_internal_fee_params() child_fee_params = _encode_internal_fee_params(leader_timeunits=6) @@ -9770,6 +9958,54 @@ def test_create_child_fee_accounting_installs_child_allocation_subtree(): assert updated["allocation_consumed"] == {"0": 55} +def test_create_child_fee_accounting_uses_child_appeals_for_its_root_subtree(): + child_fee_params = _encode_internal_fee_params(appeals=2) + child_primary = min_message_primary_fees( + decode_internal_message_fee_params(child_fee_params) + ) + recipient = "0x3333333333333333333333333333333333333333" + message = { + "messageType": 1, + "recipient": recipient, + "value": 0, + "onAcceptance": True, + "feeParams": child_fee_params, + "declaredBudget": child_primary + 55, + "callKey": EMPTY_CALL_KEY, + } + allocations = [ + _allocation( + recipient=recipient, + call_key=EMPTY_CALL_KEY, + budget=child_primary + 55, + fee_params=child_fee_params, + ), + _allocation(parent_index=0, budget=55), + ] + sender = "0x1111111111111111111111111111111111111111" + + with pytest.raises(AllocationLifecycleBudgetInsufficient): + create_child_fee_accounting( + message=message, + parent_fees_distribution=_fees_distribution(), + message_allocations=allocations, + sender=sender, + ) + + grandchild_budget = 55 * 3 + message["declaredBudget"] = child_primary + grandchild_budget + allocations[0]["budget"] = child_primary + grandchild_budget + allocations[1]["budget"] = grandchild_budget + child_fees, child_accounting = create_child_fee_accounting( + message=message, + parent_fees_distribution=_fees_distribution(), + message_allocations=allocations, + sender=sender, + ) + assert child_fees["totalMessageFees"] == grandchild_budget + assert child_accounting["message_fee_budget"] == grandchild_budget + + def test_create_child_fee_accounting_does_not_traverse_encoded_allocation_subtree(): child_fees, child_accounting = create_child_fee_accounting( message={