From 8c040d8b94a54fedeab4358e2b8f802de3121738 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 29 Sep 2026 18:00:03 +0100 Subject: [PATCH] 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={