From 9f2598ee23c5cfdde6d5ec8819cb259e9fed57f2 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 7 Oct 2026 00:58:53 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore(genvm):=20pin=20descendant-grants=20g?= =?UTF-8?q?envm=20and=20refresh=20runner=20hashes=20=E2=AC=86=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/genvm-lint.yml | 8 +- backend/consensus/base.py | 5 + backend/node/genvm/origin/fees.py | 16 ++ backend/protocol_rpc/fees.py | 130 +++++++++++-- examples/contracts/_hello_world.py | 2 +- examples/contracts/faucet.py | 2 +- .../contracts/football_prediction_market.py | 2 +- examples/contracts/llm_erc20.py | 2 +- examples/contracts/log_indexer.py | 2 +- examples/contracts/storage.py | 2 +- examples/contracts/tip_jar.py | 2 +- examples/contracts/user_storage.py | 2 +- examples/contracts/wizard_of_coin.py | 2 +- .../contracts/error_llm_contract_direct.py | 2 +- .../contracts/error_web_contract_direct.py | 2 +- tests/direct/storage_read_bench.py | 2 +- .../icontracts/contracts/company_naming.py | 2 +- .../contracts/error_execution_contract.py | 2 +- .../contracts/error_llm_contract.py | 2 +- .../contracts/error_web_contract.py | 2 +- .../icontracts/contracts/faucet.py | 2 +- .../icontracts/contracts/genvm_smoke_v1.py | 2 +- .../contracts/intelligent_oracle.py | 2 +- .../contracts/intelligent_oracle_factory.py | 2 +- .../contracts/multi_file_contract/other.py | 2 +- .../contracts/multi_file_contract/runner.json | 2 +- .../icontracts/contracts/multi_read_erc20.py | 2 +- .../contracts/multi_tenant_storage.py | 2 +- .../icontracts/contracts/payable_escrow.py | 2 +- .../icontracts/contracts/read_erc20.py | 2 +- .../contracts/utf8_roundtrip_contract.py | 2 +- tests/integration/test_deploy_reroute_to.py | 2 +- tests/integration/test_upgrade_contract.py | 10 +- tests/load/contracts/counter.py | 2 +- tests/test_linter_endpoint.py | 2 +- tests/unit/test_genvm_debug_mode_gate.py | 2 +- tests/unit/test_node_state_proxy_metrics.py | 6 +- tests/unit/test_studio_fees.py | 179 +++++++++++++++--- third_party/genvm/version | 2 +- 39 files changed, 325 insertions(+), 93 deletions(-) diff --git a/.github/workflows/genvm-lint.yml b/.github/workflows/genvm-lint.yml index 120b1c929..7e406e6f3 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 4zpkevgd...) 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|4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g)" "$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|4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g)" "$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..570e14464 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 @@ -4147,6 +4148,10 @@ def _attach_child_fee_accounting( ) -> None: if int(message_payload.get("declaredBudget", 0) or 0) <= 0: return + if not pending_transaction.use_balance and uses_unmetered_message_fee_pool( + parent_fee_accounting + ): + return try: child_fees, child_fee_accounting = create_child_fee_accounting( 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/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 4f59216ac..e88c9fc2c 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -18,6 +18,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, @@ -2050,14 +2051,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,9 +2072,10 @@ 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) if not accounting.get("message_allocations"): if int(accounting.get("message_fee_budget", 0) or 0) > 0: @@ -2080,7 +2083,7 @@ def genvm_message_fee_allocation( "Mode1MessageFeesRequireGenVMPerEmissionSupport: fee-bearing " "GenVM messages require a message allocation tree" ) - 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 +2115,22 @@ 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 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], *, @@ -2837,6 +2853,22 @@ def _consume_internal_message_fee( # 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 @@ -3008,6 +3040,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 +3073,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 +3097,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 @@ -4684,18 +4721,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 +4811,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"", }, diff --git a/examples/contracts/_hello_world.py b/examples/contracts/_hello_world.py index 273df4263..deb64e166 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } # 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..870791b8d 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..3a14131d2 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl from genlayer.types import * diff --git a/examples/contracts/llm_erc20.py b/examples/contracts/llm_erc20.py index 419794527..f37a67c7a 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import json diff --git a/examples/contracts/log_indexer.py b/examples/contracts/log_indexer.py index b36391db9..fdbda1758 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } # ] # } diff --git a/examples/contracts/storage.py b/examples/contracts/storage.py index 6affe74b3..7033fbba6 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl diff --git a/examples/contracts/tip_jar.py b/examples/contracts/tip_jar.py index b6c3e3226..870e78b81 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl from genlayer.types import * diff --git a/examples/contracts/user_storage.py b/examples/contracts/user_storage.py index 227c67ffb..f5d7cd62e 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..ac15d717d 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..a3ef0789e 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..74319523e 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl diff --git a/tests/direct/storage_read_bench.py b/tests/direct/storage_read_bench.py index a257f79e7..5af14bb07 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/company_naming.py b/tests/integration/icontracts/contracts/company_naming.py index 1c122fe7b..60abbd207 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import json import genlayer as gl diff --git a/tests/integration/icontracts/contracts/error_execution_contract.py b/tests/integration/icontracts/contracts/error_execution_contract.py index 29bc0dc63..9f45acbec 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..91c9d3ead 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..26772f8a0 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl diff --git a/tests/integration/icontracts/contracts/faucet.py b/tests/integration/icontracts/contracts/faucet.py index 538364d98..d3a4265d6 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..3e68c1213 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl diff --git a/tests/integration/icontracts/contracts/intelligent_oracle.py b/tests/integration/icontracts/contracts/intelligent_oracle.py index 17923a1ff..bb50cec5d 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..cbead32f9 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..4bdf87250 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..d07f43243 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:wgj773dkvfm4ec0p8pw2p0gvmd3jp2vsfpcer57gp3g2bdakjgbg" } diff --git a/tests/integration/icontracts/contracts/multi_read_erc20.py b/tests/integration/icontracts/contracts/multi_read_erc20.py index b33c9c616..b101d5c62 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..71d887e88 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..a10b19838 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..2d69a7fa9 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..f8f742d13 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl diff --git a/tests/integration/test_deploy_reroute_to.py b/tests/integration/test_deploy_reroute_to.py index 641ad4454..e7c9c5f7c 100644 --- a/tests/integration/test_deploy_reroute_to.py +++ b/tests/integration/test_deploy_reroute_to.py @@ -69,7 +69,7 @@ # Current SDK, current executor. CONTRACT_A = """# v0.3.0 -# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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..a02b5bbc9 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl from genlayer.types import * diff --git a/tests/load/contracts/counter.py b/tests/load/contracts/counter.py index 1447d8f9b..b3a1a1f46 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl from genlayer.types import * diff --git a/tests/test_linter_endpoint.py b/tests/test_linter_endpoint.py index 4b0b42936..e619ecc5e 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } import genlayer as gl from genlayer.types import * diff --git a/tests/unit/test_genvm_debug_mode_gate.py b/tests/unit/test_genvm_debug_mode_gate.py index e5c9d14d8..9311247af 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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g` / `: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..80b2ef324 100644 --- a/tests/unit/test_node_state_proxy_metrics.py +++ b/tests/unit/test_node_state_proxy_metrics.py @@ -17,6 +17,7 @@ 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 @@ -346,7 +347,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 +430,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) diff --git a/tests/unit/test_studio_fees.py b/tests/unit/test_studio_fees.py index 9312fde2c..c5745d816 100644 --- a/tests/unit/test_studio_fees.py +++ b/tests/unit/test_studio_fees.py @@ -147,6 +147,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 +167,7 @@ PendingTransaction, Receipt, ) +from backend.node.genvm.origin.fees import UNMATCHED_EXTERNAL_GUARD_ALLOC from backend.protocol_rpc.types import ( DecodedsubmitAppealDataArgs, DecodedRollupTransaction, @@ -3176,7 +3178,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 +3364,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 +3394,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 +3439,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 +3448,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 @@ -3500,17 +3505,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 +3532,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,6 +3580,54 @@ 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(): @@ -3651,7 +3718,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, @@ -10371,6 +10438,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..7f53f1f8a 100644 --- a/third_party/genvm/version +++ b/third_party/genvm/version @@ -1 +1 @@ -v0.6.0-rc7 +feat/descendant-grants:0d5d772fa4ab76a51108faf231b16c13e5465861 From b539866a4c019d4e646eb992eb7c9fae30be6e99 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 7 Oct 2026 00:58:59 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(fees):=20emulate=20descendant=20grants?= =?UTF-8?q?=20of=20balance-funded=20messages=20=E2=9C=A8=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio also read the emission budget from `declaredBudget`, a key the manager never sends (`message_fee`), so every balance-funded child was declared with budget 0. --- backend/node/genvm/base.py | 13 +- backend/node/genvm/origin/base_host.py | 10 +- backend/node/genvm/origin/public_abi.py | 22 +- backend/protocol_rpc/fees.py | 563 ++++++++++++++++-- tests/fixtures/descendant_grant_vectors.json | 88 +++ .../icontracts/contracts/descendant_grants.py | 89 +++ .../tests/test_descendant_grants.py | 57 ++ tests/unit/test_descendant_grants.py | 551 +++++++++++++++++ tests/unit/test_node_state_proxy_metrics.py | 51 +- tests/unit/test_studio_fees.py | 31 +- 10 files changed, 1374 insertions(+), 101 deletions(-) create mode 100644 tests/fixtures/descendant_grant_vectors.json create mode 100644 tests/integration/icontracts/contracts/descendant_grants.py create mode 100644 tests/integration/icontracts/tests/test_descendant_grants.py create mode 100644 tests/unit/test_descendant_grants.py 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/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 e88c9fc2c..7d92d1dfe 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 @@ -83,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. @@ -206,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): @@ -280,6 +289,20 @@ 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]] + legacy: bool = False + + @dataclass(frozen=True) class StudioFeePolicy: gen_per_time_unit: int = 0 @@ -1546,12 +1569,17 @@ def calculate_round_fees( policy: StudioFeePolicy | None = None, *, enforce_gen_price_cap: bool = False, + allow_incomplete_rotations: bool = False, ) -> int: fees = normalize_fees_distribution(fees_distribution) 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, + allow_incomplete_rotations=allow_incomplete_rotations, + ) appeal_profit_reserve = _calculate_appeal_profit_reserve( fees, gen_per_time_unit=int(fees["maxPriceGenPerTimeUnit"]), @@ -1917,6 +1945,274 @@ 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=[], + legacy=True, + ) + 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_balance_funded_internal_fee_params( + raw_fee_params: bytes, + grant: ContractDescendantGrant, + policy: StudioFeePolicy, +) -> dict[str, Any]: + if not grant.legacy: + return _validate_contract_internal_fee_params(raw_fee_params, policy) + + params = decode_internal_message_fee_params(raw_fee_params) + _validate_internal_message_price_caps(params) + _validate_internal_execution_budget_floor(params, policy) + return params + + +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], @@ -1927,43 +2223,43 @@ def create_child_fee_accounting( ) -> 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_balance_funded_internal_fee_params( + raw_fee_params, + grant, + 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, + legacy=bool(grant and grant.legacy), ) if declared_budget < child_primary: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") @@ -1976,26 +2272,52 @@ 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 = ( + declared_budget - child_primary if grant.legacy else 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: + 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, @@ -2007,6 +2329,7 @@ def create_child_fee_accounting( sender=sender, source="internal_message", policy=policy, + allocation_policy=allocation_policy, ) return child_fees, accounting @@ -2077,11 +2400,16 @@ def genvm_message_fee_allocation( if not accounting: 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: + return _genvm_open_message_fee_allocation( + accounting, + message_budget, ) return _genvm_unmetered_message_fee_allocation(accounting, policy) @@ -2818,6 +3146,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, @@ -2829,6 +3158,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) @@ -2840,15 +3171,44 @@ 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_balance_funded_internal_fee_params( + raw_fee_params, + grant, + 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, + legacy=bool(grant and grant.legacy), + ) + 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 @@ -2873,6 +3233,21 @@ def _consume_internal_message_fee( 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, @@ -3898,6 +4273,8 @@ def decode_external_message_fee_params(fee_params: bytes | str) -> dict[str, int def min_message_primary_fees( internal_fee_params: dict[str, Any], policy: StudioFeePolicy | None = None, + *, + legacy: bool = False, ) -> int: return calculate_round_fees( { @@ -3924,15 +4301,26 @@ def min_message_primary_fees( 0, policy, enforce_gen_price_cap=False, + allow_incomplete_rotations=legacy, ) def _calculate_initial_round_total( fees: dict[str, int | list[int]], num_of_validators: int, + *, + allow_incomplete_rotations: bool = False, ) -> 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 + if allow_incomplete_rotations + else appeal_rounds != len(rotations) - 1 + ): raise InvalidAppealRounds("InvalidAppealRounds") return _calculate_fees(fees, validator_index) @@ -4516,6 +4904,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 @@ -4535,6 +4924,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, @@ -4603,11 +4997,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": [], } @@ -4844,6 +5237,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/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/descendant_grants.py b/tests/integration/icontracts/contracts/descendant_grants.py new file mode 100644 index 000000000..430f569a0 --- /dev/null +++ b/tests/integration/icontracts/contracts/descendant_grants.py @@ -0,0 +1,89 @@ +# v0.3.0 +# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } + +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/tests/test_descendant_grants.py b/tests/integration/icontracts/tests/test_descendant_grants.py new file mode 100644 index 000000000..5eae57787 --- /dev/null +++ b/tests/integration/icontracts/tests/test_descendant_grants.py @@ -0,0 +1,57 @@ +from gltest import get_contract_factory +from gltest.assertions import tx_execution_succeeded +from gltest.types import TransactionStatus + + +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_triggered_transactions=True, + wait_triggered_transactions_status=TransactionStatus.FINALIZED, + ) + 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_triggered_transactions=True, + wait_triggered_transactions_status=TransactionStatus.FINALIZED, + ) + 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_triggered_transactions=True, + wait_triggered_transactions_status=TransactionStatus.FINALIZED, + ) + 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_triggered_transactions=True, + wait_triggered_transactions_status=TransactionStatus.FINALIZED, + ) + assert tx_execution_succeeded(pinned) + assert target.get_last(args=[]).call() == "pinned" diff --git a/tests/unit/test_descendant_grants.py b/tests/unit/test_descendant_grants.py new file mode 100644 index 000000000..5fb0e26d9 --- /dev/null +++ b/tests/unit/test_descendant_grants.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import json +from pathlib import Path + +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, + InvalidFeeParams, + MessageAllocationsNotEqualBudget, + MessageAllocationsRestricted, + MessageBudgetExceeded, + MessageDeclaredBudgetInsufficient, + MessageNoMatchingAllocation, + StudioFeePolicy, + _validate_contract_descendant_grant, + consume_message_fees, + create_child_fee_accounting, + decode_contract_descendant_grant, + genvm_message_fee_allocation, +) +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 + assert grant.legacy is (name == "closed-legacy-omitted") + + +@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: + legacy_fees, legacy = 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 legacy_fees["totalMessageFees"] == 5 + assert legacy["message_allocation_policy"] == "closed" + assert legacy["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(legacy) == [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, + } + ], + ) + + +def test_legacy_balance_message_skips_new_grant_term_validation() -> None: + fee_params = _internal_fee_params( + leader=1, + validator=1, + rotations=[0], + appeal_rounds=1, + ) + bytes(32) + message = _child_message(b"", 1_000_000) + message["feeParams"] = fee_params + policy = StudioFeePolicy( + min_propose_timeunits=2, + min_commit_timeunits=2, + ) + + _, accounting = create_child_fee_accounting( + message=message, + parent_fees_distribution=None, + policy=policy, + ) + updated = consume_message_fees(accounting, [message], policy=policy) + assert updated["message_fee_consumed"] == 0 + + message["allocationSubtree"] = "0x" + _grant(0, 0, []).hex() + with pytest.raises(InvalidFeeParams): + create_child_fee_accounting( + message=message, + parent_fees_distribution=None, + 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]) diff --git a/tests/unit/test_node_state_proxy_metrics.py b/tests/unit/test_node_state_proxy_metrics.py index 80b2ef324..ee34e4cb2 100644 --- a/tests/unit/test_node_state_proxy_metrics.py +++ b/tests/unit/test_node_state_proxy_metrics.py @@ -22,6 +22,8 @@ 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, @@ -168,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", @@ -181,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, }, @@ -194,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(), @@ -240,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, @@ -252,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() @@ -460,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"), @@ -507,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 c5745d816..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, @@ -3472,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, @@ -3630,7 +3629,7 @@ def test_zero_message_bucket_unmetered_params_report_zero_message_fee(): ) -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, @@ -3638,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(): @@ -7422,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, @@ -7679,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=[ @@ -9884,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(): From 1a8fe64c73811237d033f1c61efef471af4246f6 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 8 Oct 2026 17:33:07 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore(genvm):=20pin=20genvm=20v0.6.0-rc9=20?= =?UTF-8?q?and=20refresh=20runner=20hashes=20=E2=AC=86=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/genvm-lint.yml | 6 ++--- examples/contracts/_hello_world.py | 2 +- examples/contracts/faucet.py | 2 +- .../contracts/football_prediction_market.py | 2 +- examples/contracts/llm_erc20.py | 2 +- examples/contracts/log_indexer.py | 2 +- examples/contracts/storage.py | 2 +- examples/contracts/tip_jar.py | 2 +- examples/contracts/user_storage.py | 2 +- examples/contracts/wizard_of_coin.py | 2 +- .../contracts/error_llm_contract_direct.py | 2 +- .../contracts/error_web_contract_direct.py | 2 +- tests/direct/storage_read_bench.py | 2 +- .../icontracts/contracts/company_naming.py | 2 +- .../icontracts/contracts/descendant_grants.py | 2 +- .../contracts/error_execution_contract.py | 2 +- .../contracts/error_llm_contract.py | 2 +- .../contracts/error_web_contract.py | 2 +- .../icontracts/contracts/faucet.py | 2 +- .../icontracts/contracts/genvm_smoke_v1.py | 2 +- .../contracts/intelligent_oracle.py | 2 +- .../contracts/intelligent_oracle_factory.py | 2 +- .../contracts/multi_file_contract/other.py | 2 +- .../contracts/multi_file_contract/runner.json | 2 +- .../icontracts/contracts/multi_read_erc20.py | 2 +- .../contracts/multi_tenant_storage.py | 2 +- .../icontracts/contracts/payable_escrow.py | 2 +- .../icontracts/contracts/read_erc20.py | 2 +- .../contracts/utf8_roundtrip_contract.py | 2 +- tests/integration/test_deploy_reroute_to.py | 22 ++++++------------- tests/integration/test_upgrade_contract.py | 10 ++++----- tests/load/contracts/counter.py | 2 +- tests/test_linter_endpoint.py | 2 +- tests/unit/test_genvm_debug_mode_gate.py | 2 +- third_party/genvm/version | 2 +- 35 files changed, 47 insertions(+), 55 deletions(-) diff --git a/.github/workflows/genvm-lint.yml b/.github/workflows/genvm-lint.yml index 7e406e6f3..75044aea6 100644 --- a/.github/workflows/genvm-lint.yml +++ b/.github/workflows/genvm-lint.yml @@ -35,7 +35,7 @@ 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 runners (5jycge4q... and 4zpkevgd...) fail because their + # 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. @@ -44,7 +44,7 @@ jobs: failed=0 for f in examples/contracts/*.py; do echo "::group::$f" - if grep -qE "py-genlayer:(1zr6nqk597d97kg0dyxg0shhrykx5v02zjgnyrajapy4wlqvfvwh|5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng|4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g)" "$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|4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g)" "$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/examples/contracts/_hello_world.py b/examples/contracts/_hello_world.py index deb64e166..6761b4ba9 100644 --- a/examples/contracts/_hello_world.py +++ b/examples/contracts/_hello_world.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 870791b8d..48f050edd 100644 --- a/examples/contracts/faucet.py +++ b/examples/contracts/faucet.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 3a14131d2..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 f37a67c7a..d3f928699 100644 --- a/examples/contracts/llm_erc20.py +++ b/examples/contracts/llm_erc20.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import json diff --git a/examples/contracts/log_indexer.py b/examples/contracts/log_indexer.py index fdbda1758..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } # ] # } diff --git a/examples/contracts/storage.py b/examples/contracts/storage.py index 7033fbba6..4fa40adbb 100644 --- a/examples/contracts/storage.py +++ b/examples/contracts/storage.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/examples/contracts/tip_jar.py b/examples/contracts/tip_jar.py index 870e78b81..590e6182b 100644 --- a/examples/contracts/tip_jar.py +++ b/examples/contracts/tip_jar.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 f5d7cd62e..78e02e011 100644 --- a/examples/contracts/user_storage.py +++ b/examples/contracts/user_storage.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 ac15d717d..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 a3ef0789e..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 74319523e..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 5af14bb07..a77e9fa4a 100644 --- a/tests/direct/storage_read_bench.py +++ b/tests/direct/storage_read_bench.py @@ -1,4 +1,4 @@ -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/integration/icontracts/contracts/company_naming.py b/tests/integration/icontracts/contracts/company_naming.py index 60abbd207..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 index 430f569a0..c72bef3b5 100644 --- a/tests/integration/icontracts/contracts/descendant_grants.py +++ b/tests/integration/icontracts/contracts/descendant_grants.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.message_allocation import InternalAllocation diff --git a/tests/integration/icontracts/contracts/error_execution_contract.py b/tests/integration/icontracts/contracts/error_execution_contract.py index 9f45acbec..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 91c9d3ead..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 26772f8a0..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 d3a4265d6..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 3e68c1213..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 bb50cec5d..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 cbead32f9..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 4bdf87250..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 d07f43243..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:wgj773dkvfm4ec0p8pw2p0gvmd3jp2vsfpcer57gp3g2bdakjgbg" + "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 b101d5c62..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 71d887e88..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 a10b19838..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 2d69a7fa9..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 f8f742d13..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl diff --git a/tests/integration/test_deploy_reroute_to.py b/tests/integration/test_deploy_reroute_to.py index e7c9c5f7c..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 a02b5bbc9..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 b3a1a1f46..d8d7b77cc 100644 --- a/tests/load/contracts/counter.py +++ b/tests/load/contracts/counter.py @@ -1,5 +1,5 @@ # v0.3.0 -# { "Depends": "py-genlayer:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +# { "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 e619ecc5e..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g" } +VALID_CONTRACT = """# { "Depends": "py-genlayer:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } import genlayer as gl from genlayer.types import * diff --git a/tests/unit/test_genvm_debug_mode_gate.py b/tests/unit/test_genvm_debug_mode_gate.py index 9311247af..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:4zpkevgdn5yds0bnh0kdsjg7ak15c4jg3bdh8qk6cd7qj1wwty5g` / `: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/third_party/genvm/version b/third_party/genvm/version index 7f53f1f8a..facb544d2 100644 --- a/third_party/genvm/version +++ b/third_party/genvm/version @@ -1 +1 @@ -feat/descendant-grants:0d5d772fa4ab76a51108faf231b16c13e5465861 +v0.6.0-rc9 From 4a383a326156f301ad27d93b3b002f51b6fed0a6 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Fri, 9 Oct 2026 22:16:07 +0900 Subject: [PATCH 4/7] fix(fees): preserve descendant funding and grant restrictions Inherit the parent fee depositor and admitted grant rules when materializing descendants. Preserve metering for zero-budget grant descendants and wait through all triggered transaction levels in the integration test. Validation: 1669 unit tests passed, 7 skipped; Black and diff checks passed. Live integration was not rerun. --- backend/consensus/base.py | 20 ++- backend/protocol_rpc/fees.py | 11 +- .../tests/test_descendant_grants.py | 30 +++-- tests/unit/test_descendant_grant_wait.py | 47 +++++++ tests/unit/test_descendant_grants.py | 120 ++++++++++++++++++ 5 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_descendant_grant_wait.py diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 570e14464..0a7c0b340 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -4146,7 +4146,11 @@ 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 @@ -4165,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/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index 7d92d1dfe..b433269b5 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -2218,6 +2218,7 @@ 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]]: @@ -2312,7 +2313,7 @@ def create_child_fee_accounting( total_message_fees=child_message_budget, parent_fees_distribution=parent_fees, ) - if not use_balance: + if not use_balance and not message_allocations_from_grant: validate_message_allocations( child_message_allocations, total_message_fees=int(child_fees["totalMessageFees"]), @@ -2331,6 +2332,10 @@ def create_child_fee_accounting( policy=policy, allocation_policy=allocation_policy, ) + # Grant trees retain their admission rules when inherited by descendants. + accounting["message_allocations_from_grant"] = ( + not grant.legacy if grant is not None else message_allocations_from_grant + ) return child_fees, accounting @@ -2406,7 +2411,7 @@ def genvm_message_fee_allocation( message_budget = int(accounting.get("message_fee_budget", 0) or 0) if not accounting.get("message_allocations"): - if message_budget > 0: + if message_budget > 0 or accounting.get("message_allocations_from_grant"): return _genvm_open_message_fee_allocation( accounting, message_budget, @@ -2450,6 +2455,8 @@ def genvm_message_fee_allocation( 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"): diff --git a/tests/integration/icontracts/tests/test_descendant_grants.py b/tests/integration/icontracts/tests/test_descendant_grants.py index 5eae57787..4447e1cb8 100644 --- a/tests/integration/icontracts/tests/test_descendant_grants.py +++ b/tests/integration/icontracts/tests/test_descendant_grants.py @@ -1,6 +1,24 @@ 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): @@ -21,9 +39,8 @@ def test_balance_funded_descendant_grant_policies(setup_validators): closed = relay.emit_closed(args=[relay.address, target.address]).transact( wait_transaction_status=TransactionStatus.FINALIZED, - wait_triggered_transactions=True, - wait_triggered_transactions_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() == "" @@ -33,25 +50,22 @@ def test_balance_funded_descendant_grant_policies(setup_validators): args=[relay.address, target.address] ).transact( wait_transaction_status=TransactionStatus.FINALIZED, - wait_triggered_transactions=True, - wait_triggered_transactions_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_triggered_transactions=True, - wait_triggered_transactions_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_triggered_transactions=True, - wait_triggered_transactions_status=TransactionStatus.FINALIZED, ) + _wait_for_descendants(pinned) assert tx_execution_succeeded(pinned) assert target.get_last(args=[]).call() == "pinned" diff --git a/tests/unit/test_descendant_grant_wait.py b/tests/unit/test_descendant_grant_wait.py new file mode 100644 index 000000000..136ebfc2d --- /dev/null +++ b/tests/unit/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/unit/test_descendant_grants.py b/tests/unit/test_descendant_grants.py index 5fb0e26d9..193c5eb54 100644 --- a/tests/unit/test_descendant_grants.py +++ b/tests/unit/test_descendant_grants.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from types import SimpleNamespace import pytest from eth_abi import encode @@ -27,12 +28,19 @@ 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 @@ -549,3 +557,115 @@ def test_declared_budget_must_cover_primary_and_explicit_grant() -> None: 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]) From c5a7768b5768cf48362b41154df755e2aba7867e Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Fri, 9 Oct 2026 22:18:05 +0900 Subject: [PATCH 5/7] refactor(fees): treat omitted descendant grants as closed Remove the legacy grant flag and its permissive fee validation and rotation rules. Omitted grants now use the same validation and accounting as explicit Closed grants, with surplus retained in primary escrow. Validation: 1674 unit tests passed, 7 skipped; Black and diff checks passed. Independent review found no actionable issues. --- backend/protocol_rpc/fees.py | 43 +++-------------------- tests/unit/test_descendant_grants.py | 51 +++++++++++++++------------- 2 files changed, 32 insertions(+), 62 deletions(-) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py index b433269b5..108b2623a 100644 --- a/backend/protocol_rpc/fees.py +++ b/backend/protocol_rpc/fees.py @@ -300,7 +300,6 @@ class ContractDescendantGrant: policy: MessageAllocationPolicy budget: int allocations: list[dict[str, Any]] - legacy: bool = False @dataclass(frozen=True) @@ -1569,7 +1568,6 @@ def calculate_round_fees( policy: StudioFeePolicy | None = None, *, enforce_gen_price_cap: bool = False, - allow_incomplete_rotations: bool = False, ) -> int: fees = normalize_fees_distribution(fees_distribution) policy = policy or StudioFeePolicy() @@ -1578,7 +1576,6 @@ def calculate_round_fees( time_unit_work = _calculate_initial_round_total( fees, num_of_validators, - allow_incomplete_rotations=allow_incomplete_rotations, ) appeal_profit_reserve = _calculate_appeal_profit_reserve( fees, @@ -1962,7 +1959,6 @@ def decode_contract_descendant_grant(value: Any) -> ContractDescendantGrant: policy=MessageAllocationPolicy.CLOSED, budget=0, allocations=[], - legacy=True, ) if len(raw) > MAX_CONTRACT_DESCENDANT_GRANT_BYTES: raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") @@ -2128,20 +2124,6 @@ def _validate_contract_descendant_grant( raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") -def _validate_balance_funded_internal_fee_params( - raw_fee_params: bytes, - grant: ContractDescendantGrant, - policy: StudioFeePolicy, -) -> dict[str, Any]: - if not grant.legacy: - return _validate_contract_internal_fee_params(raw_fee_params, policy) - - params = decode_internal_message_fee_params(raw_fee_params) - _validate_internal_message_price_caps(params) - _validate_internal_execution_budget_floor(params, policy) - return params - - def _validate_contract_internal_fee_params( raw_fee_params: bytes, policy: StudioFeePolicy, @@ -2235,9 +2217,8 @@ def create_child_fee_accounting( grant: ContractDescendantGrant | None = None if use_balance: grant = decode_contract_descendant_grant(message.get("allocationSubtree")) - fee_params = _validate_balance_funded_internal_fee_params( + fee_params = _validate_contract_internal_fee_params( raw_fee_params, - grant, policy, ) else: @@ -2260,7 +2241,6 @@ def create_child_fee_accounting( child_primary = min_message_primary_fees( fee_params, policy, - legacy=bool(grant and grant.legacy), ) if declared_budget < child_primary: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") @@ -2284,9 +2264,7 @@ def create_child_fee_accounting( if declared_budget < minimum: raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") child_message_allocations = grant.allocations - child_message_budget = ( - declared_budget - child_primary if grant.legacy else grant.budget - ) + child_message_budget = grant.budget allocation_policy = grant.policy else: if _is_contract_descendant_grant(message.get("allocationSubtree")): @@ -2334,7 +2312,7 @@ def create_child_fee_accounting( ) # Grant trees retain their admission rules when inherited by descendants. accounting["message_allocations_from_grant"] = ( - not grant.legacy if grant is not None else message_allocations_from_grant + grant is not None or message_allocations_from_grant ) return child_fees, accounting @@ -3185,9 +3163,8 @@ def _consume_internal_message_fee( if use_balance: grant = decode_contract_descendant_grant(message.get("allocationSubtree")) - fee_params = _validate_balance_funded_internal_fee_params( + fee_params = _validate_contract_internal_fee_params( raw_fee_params, - grant, policy, ) _validate_contract_descendant_grant( @@ -3208,7 +3185,6 @@ def _consume_internal_message_fee( min_required = min_message_primary_fees( fee_params, policy, - legacy=bool(grant and grant.legacy), ) if use_balance: min_required = _u256_add(min_required, descendant_budget) @@ -4280,8 +4256,6 @@ def decode_external_message_fee_params(fee_params: bytes | str) -> dict[str, int def min_message_primary_fees( internal_fee_params: dict[str, Any], policy: StudioFeePolicy | None = None, - *, - legacy: bool = False, ) -> int: return calculate_round_fees( { @@ -4308,26 +4282,19 @@ def min_message_primary_fees( 0, policy, enforce_gen_price_cap=False, - allow_incomplete_rotations=legacy, ) def _calculate_initial_round_total( fees: dict[str, int | list[int]], num_of_validators: int, - *, - allow_incomplete_rotations: bool = False, ) -> int: validator_index = _validator_index(num_of_validators) 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 - if allow_incomplete_rotations - else appeal_rounds != len(rotations) - 1 - ): + if appeal_rounds != len(rotations) - 1: raise InvalidAppealRounds("InvalidAppealRounds") return _calculate_fees(fees, validator_index) diff --git a/tests/unit/test_descendant_grants.py b/tests/unit/test_descendant_grants.py index 193c5eb54..c903edb53 100644 --- a/tests/unit/test_descendant_grants.py +++ b/tests/unit/test_descendant_grants.py @@ -21,7 +21,9 @@ ArithmeticOverflow, ExternalAllocationInvalid, InvalidContractDescendantGrant, + InvalidAppealRounds, InvalidFeeParams, + PhaseTimeoutOutOfBounds, MessageAllocationsNotEqualBudget, MessageAllocationsRestricted, MessageBudgetExceeded, @@ -212,7 +214,6 @@ def test_portable_descendant_grant_vectors( ) assert grant.budget == int(expected["budget"]) assert grant.allocations == expected_allocations - assert grant.legacy is (name == "closed-legacy-omitted") @pytest.mark.parametrize( @@ -354,7 +355,7 @@ def _child_message(subtree: bytes, declared_budget: int) -> dict: def test_child_accounting_installs_closed_open_and_pinned_policies() -> None: - legacy_fees, legacy = create_child_fee_accounting( + omitted_fees, omitted = create_child_fee_accounting( message=_child_message(b"", 15), parent_fees_distribution=None, ) @@ -371,13 +372,13 @@ def test_child_accounting_installs_closed_open_and_pinned_policies() -> None: parent_fees_distribution=None, ) - assert legacy_fees["totalMessageFees"] == 5 - assert legacy["message_allocation_policy"] == "closed" - assert legacy["message_allocations_restricted"] is True + 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(legacy) == [UNMATCHED_EXTERNAL_GUARD_ALLOC] + 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 @@ -516,35 +517,37 @@ def test_closed_external_on_acceptance_keeps_phase_error_precedence() -> None: ) -def test_legacy_balance_message_skips_new_grant_term_validation() -> None: - fee_params = _internal_fee_params( - leader=1, - validator=1, - rotations=[0], - appeal_rounds=1, - ) + bytes(32) - message = _child_message(b"", 1_000_000) +@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, ) - _, accounting = create_child_fee_accounting( - message=message, - parent_fees_distribution=None, - policy=policy, - ) - updated = consume_message_fees(accounting, [message], policy=policy) - assert updated["message_fee_consumed"] == 0 - - message["allocationSubtree"] = "0x" + _grant(0, 0, []).hex() - with pytest.raises(InvalidFeeParams): + 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: From 63f9526d563630cddc5bd516dd6446603dc28db5 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 13 Oct 2026 17:41:10 +0900 Subject: [PATCH 6/7] test(fees): run the descendant wait test with the integration suite It imports the integration module, which needs gltest; the unit job does not install it Claude-Session: https://claude.ai/code/session_01N97Hvs14ckrioBoYJTD4Te --- .../icontracts/tests}/test_descendant_grant_wait.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{unit => integration/icontracts/tests}/test_descendant_grant_wait.py (100%) diff --git a/tests/unit/test_descendant_grant_wait.py b/tests/integration/icontracts/tests/test_descendant_grant_wait.py similarity index 100% rename from tests/unit/test_descendant_grant_wait.py rename to tests/integration/icontracts/tests/test_descendant_grant_wait.py From 2b94c337f8f1ab65247937121e77a7c73012d74f Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 13 Oct 2026 21:11:49 +0900 Subject: [PATCH 7/7] fix(docker): default the nix netrc secret to an empty file in the repo buildx bake refuses to read /dev/null without --allow=fs.read=/dev/null, which compose cannot pass, so builds with buildx 0.38 failed Claude-Session: https://claude.ai/code/session_01N97Hvs14ckrioBoYJTD4Te --- .env.example | 2 +- docker-compose.yml | 9 +++++---- docker/empty.netrc | 0 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 docker/empty.netrc 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/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