diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 108b2623a..d148db2d1 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -1814,6 +1814,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, ) @@ -2295,6 +2296,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) @@ -2999,11 +3001,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"]) ) @@ -4064,16 +4065,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( @@ -4083,13 +4088,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) @@ -4101,6 +4111,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) @@ -4113,8 +4126,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) @@ -4136,16 +4165,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" @@ -4170,18 +4202,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( @@ -4201,13 +4234,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") @@ -6013,8 +6051,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 4c6fd14d4..e82658c88 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -2213,9 +2213,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) @@ -2791,63 +2840,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, ) @@ -2872,6 +2920,7 @@ def test_message_allocations_do_not_multiply_on_finalization_budget(): ) ], total_message_fees=min_primary, + root_appeal_rounds=3, ) @@ -2882,7 +2931,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(): @@ -2892,7 +3008,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(): @@ -2902,7 +3020,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(): @@ -2913,7 +3033,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(): @@ -2927,7 +3049,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(): @@ -2938,7 +3062,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], @@ -2971,7 +3097,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(): @@ -2983,7 +3111,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(): @@ -3000,6 +3130,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ) ], total_message_fees=210_000, + root_appeal_rounds=0, ) with pytest.raises(ExternalAllocationInvalid): @@ -3015,6 +3146,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ) ], total_message_fees=210_000, + root_appeal_rounds=0, ) with pytest.raises(ExternalAllocationInvalid): @@ -3027,12 +3159,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): @@ -3050,6 +3184,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ), ], total_message_fees=420_000, + root_appeal_rounds=0, ) with pytest.raises(ExternalAllocationInvalid): @@ -3069,6 +3204,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ), ], total_message_fees=420_000, + root_appeal_rounds=0, ) with pytest.raises(AllocationTreeMalformed): @@ -3082,6 +3218,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ) ], total_message_fees=210_000, + root_appeal_rounds=0, ) with pytest.raises(AllocationTreeMalformed): @@ -3100,6 +3237,7 @@ def test_message_allocations_reject_external_allocation_invariants(): ), ], total_message_fees=210_000, + root_appeal_rounds=0, ) @@ -3114,7 +3252,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(): @@ -3130,6 +3270,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, ) @@ -3146,6 +3287,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), @@ -7583,6 +7739,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) @@ -9849,6 +10037,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={