From 7c0bc132dbee0ee0731ebb2a872519303f2d6847 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 11 Sep 2026 18:14:29 +0100 Subject: [PATCH] fix(genvm): restore legacy v0.2 contract compatibility Select the retained v0.2 executor from the first-line source version for ordinary deployments, schema extraction and fee-estimate simulations. Persist the selection and honor existing explicit pins. Use the matching legacy code-storage slot when extracting schemas/code, bootstrapping finalized state and upgrading code; the modern slot aliases the legacy lock list. Validated with 1617 backend unit tests, pinned Ruff/Black checks, and the official GenVM runtime: unchanged legacy deployment, persisted snapshot, schema, read, write and read. Current v0.3 schema remains valid. Full RPC/consensus integration is left to E2E. --- backend/consensus/base.py | 36 +- backend/consensus/worker.py | 7 +- backend/database_handler/contract_snapshot.py | 25 +- backend/node/base.py | 28 +- backend/node/genvm/__init__.py | 8 +- backend/node/genvm/executor_selection.py | 37 ++ backend/protocol_rpc/endpoints.py | 5 +- tests/integration/test_deploy_reroute_to.py | 29 +- tests/unit/test_contract_code_fetch.py | 25 ++ tests/unit/test_legacy_executor_selection.py | 355 ++++++++++++++++++ 10 files changed, 536 insertions(+), 19 deletions(-) create mode 100644 backend/node/genvm/executor_selection.py create mode 100644 tests/unit/test_legacy_executor_selection.py diff --git a/backend/consensus/base.py b/backend/consensus/base.py index f64058960..a26bdeee7 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -15,6 +15,7 @@ from copy import deepcopy import json import base64 +import binascii from eth_utils import is_address, keccak, to_bytes, to_checksum_address from sqlalchemy import text @@ -41,6 +42,10 @@ Validator, ) from backend.node.base import Node, get_simulator_chain_id +from backend.node.genvm.executor_selection import ( + legacy_executor_selector_for_code, + uses_legacy_storage, +) from backend.node.types import ( ExecutionMode, Receipt, @@ -375,12 +380,25 @@ def node_factory( def transaction_genvm_executor_selector(transaction: Transaction) -> str | None: - """Studio-only GenVM executor override carried by the transaction.""" - return ( + """Select and persist the deployment's executor, honoring explicit pins.""" + explicit_selector = ( transaction.sim_config.genvm_executor_selector if transaction.sim_config else None ) + if explicit_selector: + return explicit_selector + if transaction.type != TransactionType.DEPLOY_CONTRACT or not transaction.data: + return None + code_b64 = transaction.data.get("contract_code") + if not isinstance(code_b64, (str, bytes)): + return None + try: + code = base64.b64decode(code_b64, validate=True) + except (binascii.Error, ValueError): + # Keep malformed payload handling on the existing execution path. + return None + return legacy_executor_selector_for_code(code) def contract_snapshot_factory( @@ -416,8 +434,8 @@ def contract_snapshot_factory( ret.contract_code = transaction.data["contract_code"] ret.balance = transaction.value or 0 ret.states = {"accepted": {}, "finalized": {}} - # The contract row is still empty at deploy time, so the executor - # override can only come from the deploy transaction itself. + # The contract row is still empty at deploy time. Honor its explicit + # pin or select the legacy line from the deployment's source header. ret.genvm_executor_selector = transaction_genvm_executor_selector(transaction) return ret @@ -3719,7 +3737,15 @@ async def handle(self, context): context.transaction.data.get("contract_address") if is_deploy else None ), code_slot_b64=( - base64.b64encode(get_code_slot()).decode("ascii") if is_deploy else None + base64.b64encode( + get_code_slot( + legacy=uses_legacy_storage( + transaction_genvm_executor_selector(context.transaction) + ) + ) + ).decode("ascii") + if is_deploy + else None ), to_address=context.transaction.to_address, leader_node_config=leader_receipt.node_config, diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index be7f52316..c4d0232b0 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -1820,6 +1820,7 @@ async def _process_upgrade_transaction( """ import base64 from backend.node.genvm import get_code_slot + from backend.node.genvm.executor_selection import uses_legacy_storage from backend.database_handler.models import CurrentState tx_hash = transaction_data["hash"] @@ -1866,7 +1867,11 @@ async def _process_upgrade_transaction( code_slot_value = base64.b64encode(code_len_prefix + code_bytes).decode( "ascii" ) - code_slot_key = base64.b64encode(get_code_slot()).decode("ascii") + code_slot_key = base64.b64encode( + get_code_slot( + legacy=uses_legacy_storage(contract.genvm_executor_selector) + ) + ).decode("ascii") # Update contract data - update BOTH accepted and finalized state # Since upgrade transactions bypass consensus and go directly to FINALIZED, diff --git a/backend/database_handler/contract_snapshot.py b/backend/database_handler/contract_snapshot.py index f113074e0..230323de2 100644 --- a/backend/database_handler/contract_snapshot.py +++ b/backend/database_handler/contract_snapshot.py @@ -1,11 +1,15 @@ # database_handler/contract_snapshot.py from .models import CurrentState from .errors import ContractNotFoundError -from sqlalchemy import func, select +from sqlalchemy import case, func, select from sqlalchemy.orm import Session from typing import Optional, Dict import base64 import json +from backend.node.genvm.executor_selection import ( + LEGACY_EXECUTOR_SELECTOR, + uses_legacy_storage, +) class ContractSnapshot: @@ -99,7 +103,9 @@ def extract_deployed_code_b64(self) -> Optional[str]: accepted = self.states.get("accepted") or {} try: - stored = accepted.get(_code_slot_b64()) + stored = accepted.get( + _code_slot_b64(legacy=uses_legacy_storage(self.genvm_executor_selector)) + ) if not stored: return None return _decode_code_payload(stored) @@ -107,12 +113,12 @@ def extract_deployed_code_b64(self) -> Optional[str]: return None -def _code_slot_b64() -> str: +def _code_slot_b64(*, legacy: bool = False) -> str: """Base64 of the deterministic storage slot the deployed code lives in.""" # Import here to avoid circular dependencies at module import time from backend.node.genvm import get_code_slot - return base64.b64encode(get_code_slot()).decode("ascii") + return base64.b64encode(get_code_slot(legacy=legacy)).decode("ascii") def _decode_code_payload(stored: str) -> Optional[str]: @@ -142,7 +148,16 @@ def fetch_deployed_code_b64(session: Session, contract_address: str) -> Optional Raises ContractNotFoundError when the contract is absent or undeployed, and returns None when the contract exists but holds no code. """ - slot = _code_slot_b64() + # Select only the correct code blob, retaining the narrow SQL read rather + # than fetching a large contract state to inspect its executor metadata. + slot = case( + ( + (CurrentState.genvm_executor_selector == LEGACY_EXECUTOR_SELECTOR) + | CurrentState.genvm_executor_selector.startswith("v0.2."), + _code_slot_b64(legacy=True), + ), + else_=_code_slot_b64(), + ) row = session.execute( select( diff --git a/backend/node/base.py b/backend/node/base.py index 545bd47d3..fb6b51f93 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -23,6 +23,10 @@ ) import backend.node.genvm.base as genvmbase import backend.node.genvm.origin.calldata as calldata +from backend.node.genvm.executor_selection import ( + legacy_executor_selector_for_code, + uses_legacy_storage, +) from backend.database_handler.contract_snapshot import ContractSnapshot from backend.node.types import Receipt, ExecutionMode, Vote, ExecutionResultStatus from backend.protocol_rpc.message_handler.base import IMessageHandler @@ -787,11 +791,13 @@ def _date_from_str( res = res.replace(tzinfo=datetime.UTC) return res - def _put_code_to(self, to: genvmbase.StateProxyWritable, code: bytes) -> None: + def _put_code_to( + self, to: genvmbase.StateProxyWritable, code: bytes, *, legacy: bool = False + ) -> None: """Write contract code directly to storage using the code slot.""" from backend.node.genvm import get_code_slot - code_slot = get_code_slot() + code_slot = get_code_slot(legacy=legacy) # Prefix with 4-byte little-endian length code_len_prefix = len(code).to_bytes(4, byteorder="little", signed=False) code_data = code_len_prefix + code @@ -810,6 +816,13 @@ async def deploy_contract( ) -> Receipt: assert self.contract_snapshot is not None + # Simulated deployments (including fee estimates) do not pass through + # consensus's snapshot factory. Select the same legacy line here too. + if not self.contract_snapshot.genvm_executor_selector: + self.contract_snapshot.genvm_executor_selector = ( + legacy_executor_selector_for_code(code_to_deploy) + ) + transaction_datetime = self._date_from_str(transaction_created_at) if transaction_datetime is None: transaction_datetime = datetime.datetime.now(datetime.UTC) @@ -911,7 +924,11 @@ async def _execution_finished( ) ) - async def get_contract_schema(self, code: bytes) -> str: + async def get_contract_schema( + self, code: bytes, *, genvm_executor_selector: str | None = None + ) -> str: + inferred_selector = legacy_executor_selector_for_code(code) + executor_selector = genvm_executor_selector or inferred_selector NO_ADDR = Address(b"\x00" * 20) message = { "is_init": False, @@ -923,7 +940,9 @@ async def get_contract_schema(self, code: bytes) -> str: "chain_id": 0, } state_proxy = _StateProxyNone(NO_ADDR) - self._put_code_to(state_proxy, code) + self._put_code_to( + state_proxy, code, legacy=uses_legacy_storage(executor_selector) + ) start_time = time.time() result = await genvmbase.run_genvm_host( @@ -944,6 +963,7 @@ async def get_contract_schema(self, code: bytes) -> str: logger=self.logger, timeout=30, manager_uri=self.manager.url, + genvm_executor_selector=executor_selector, ) result.processing_time = int((time.time() - start_time) * 1000) diff --git a/backend/node/genvm/__init__.py b/backend/node/genvm/__init__.py index b62ef97cb..b1bd26feb 100644 --- a/backend/node/genvm/__init__.py +++ b/backend/node/genvm/__init__.py @@ -3,6 +3,10 @@ from backend.node.genvm.origin.public_abi import root_offsets -def get_code_slot() -> bytes: - offset = root_offsets.CODE.to_bytes(4, byteorder="little", signed=False) +def get_code_slot(*, legacy: bool = False) -> bytes: + # The retained v0.2 ABI has CODE=1, LOCKED_SLOTS=2. The v0.3 ABI added a + # contract indirection and moved them to 2 and 3 respectively. + offset = (1 if legacy else root_offsets.CODE).to_bytes( + 4, byteorder="little", signed=False + ) return hashlib.sha3_256(b"\x00" * 32 + offset).digest() diff --git a/backend/node/genvm/executor_selection.py b/backend/node/genvm/executor_selection.py new file mode 100644 index 000000000..dbc57dc74 --- /dev/null +++ b/backend/node/genvm/executor_selection.py @@ -0,0 +1,37 @@ +"""Compatibility routing for the retained v0.2 text-contract executor.""" + +import re + +# Match the selector used to backfill pre-existing v0.2 contracts. Let the +# manager choose the newest available patch in this line, not a hardcoded +# directory or the newest semver-major-0 executor (which is v0.3). +LEGACY_EXECUTOR_SELECTOR = r"re:^v0\.2\." +_LEGACY_HEADER = re.compile( + rb"(?:#|//|--)\s*v0\.2\.(?:[0-9]+|\*)(?:-[0-9A-Za-z.-]+)?\s*" +) + + +def legacy_executor_selector_for_code(code: bytes) -> str | None: + """Honor an explicit v0.2 version in a text contract's first comment. + + GenVM recognizes #, // and -- comments only at the start of the source, + with the version on the first line. Do not infer a version from a runner + hash, imports, or later comments. Other formats/versions keep the normal + manager selection, and GenVM remains responsible for validating code. + """ + first_line = code.partition(b"\n")[0] + if _LEGACY_HEADER.fullmatch(first_line): + return LEGACY_EXECUTOR_SELECTOR + return None + + +def uses_legacy_storage(selector: str | None) -> bool: + """Recognize retained-line pins written by deployments and migration. + + Explicit version pins and the canonical line selector are stable storage + identities. Do not guess the result of an arbitrary user regex: it may + match several executor lines. + """ + return bool(selector) and ( + selector == LEGACY_EXECUTOR_SELECTOR or selector.startswith("v0.2.") + ) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 88f425c1e..94f89a9aa 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -1338,7 +1338,10 @@ async def get_contract_schema( contract_snapshot_factory=None, manager=genvm_manager, ) - schema = await node.get_contract_schema(base64.b64decode(code_b64)) + schema = await node.get_contract_schema( + base64.b64decode(code_b64), + genvm_executor_selector=contract_snapshot.genvm_executor_selector, + ) return json.loads(schema) diff --git a/tests/integration/test_deploy_reroute_to.py b/tests/integration/test_deploy_reroute_to.py index 1b0c322f7..08832de54 100644 --- a/tests/integration/test_deploy_reroute_to.py +++ b/tests/integration/test_deploy_reroute_to.py @@ -39,6 +39,7 @@ from genlayer_py.transactions.fees import normalize_transaction_fees from genlayer_py.types.transactions import TransactionHashVariant from web3.constants import ADDRESS_ZERO +from gltest.assertions import tx_execution_succeeded RPC_URL = os.environ.get("TEST_JSONRPC_URL", "http://localhost:4000/api") @@ -245,6 +246,7 @@ def _deploy(code: str, args: list, reroute_to: str | None = None) -> str: tx_hash = rpc_call("eth_sendRawTransaction", params) tx = _wait_for_tx(tx_hash) + assert tx_execution_succeeded(tx), f"deployment execution failed: {tx}" address = tx.get("contract_address") or tx.get("to_address") assert address and address != ADDRESS_ZERO, f"no contract address in {tx}" return address @@ -252,7 +254,9 @@ def _deploy(code: str, args: list, reroute_to: str | None = None) -> str: def _write(address: str, method: str, args: list) -> dict: tx_hash = CLIENT.write_contract(address=address, function_name=method, args=args) - return _wait_for_tx(tx_hash) + tx = _wait_for_tx(tx_hash) + assert tx_execution_succeeded(tx), f"{method} execution failed: {tx}" + return tx def _read(address: str, method: str, final: bool = True) -> str: @@ -283,6 +287,29 @@ def _wait_for_storage(address: str, expected: str, timeout: int = 240) -> None: ) +def test_legacy_contract_without_manual_executor_selection(): + """Unchanged v0.2 code works through schema, deploy, read and write RPCs.""" + schema = rpc_call( + "gen_getContractSchemaForCode", ["0x" + CONTRACT_B.encode().hex()] + ) + assert "get_storage" in schema["methods"] + + # No sim_config and no executor override: ordinary deployment must infer + # and persist the legacy line, just as preflight selected it from source. + address = _deploy(CONTRACT_B, args=["legacy_default"]) + assert _read(address, "get_storage") == "legacy_default" + assert rpc_call("gen_getContractSchema", [address]) == schema + + _write(address, "update_storage", ["legacy_updated"]) + assert _read(address, "get_storage") == "legacy_updated" + + +def test_schema_of_explicitly_pinned_legacy_contract(): + address = _deploy(CONTRACT_B, args=["legacy_pinned"], reroute_to=LEGACY_EXECUTOR) + schema = rpc_call("gen_getContractSchema", [address]) + assert "get_storage" in schema["methods"] + + @pytest.fixture(scope="module") def peers() -> tuple[str, str]: """Deploy both contracts once and introduce them to each other. diff --git a/tests/unit/test_contract_code_fetch.py b/tests/unit/test_contract_code_fetch.py index 64f2061a5..b7a054f83 100644 --- a/tests/unit/test_contract_code_fetch.py +++ b/tests/unit/test_contract_code_fetch.py @@ -10,6 +10,7 @@ _decode_code_payload, fetch_deployed_code_b64, ) +from backend.node.genvm.executor_selection import LEGACY_EXECUTOR_SELECTOR from backend.database_handler.errors import ContractNotFoundError ADDRESS = "0xabc" @@ -111,6 +112,13 @@ def test_code_slot_is_stable(self): assert _code_slot_b64() == _code_slot_b64() assert len(base64.b64decode(_code_slot_b64())) == 32 + def test_legacy_code_slot_uses_retained_abi_offset(self): + import hashlib + + legacy = hashlib.sha3_256(b"\x00" * 32 + (1).to_bytes(4, "little")).digest() + assert base64.b64decode(_code_slot_b64(legacy=True)) == legacy + assert _code_slot_b64(legacy=True) != _code_slot_b64() + def test_decode_respects_length_prefix(self): """Trailing bytes past the declared length must not leak into the code.""" blob = base64.b64encode((3).to_bytes(4, "little") + b"abc" + b"PADDING") @@ -118,6 +126,23 @@ def test_decode_respects_length_prefix(self): class TestGeneratedSQL: + def test_real_query_selects_code_slot_from_executor_metadata(self): + from sqlalchemy.dialects import postgresql + + session = _make_session(_make_row()) + assert fetch_deployed_code_b64(session, ADDRESS) is None + statement = session.execute.call_args.args[0] + compiled = statement.compile(dialect=postgresql.dialect()) + sql = str(compiled) + values = compiled.params.values() + assert "CASE WHEN" in sql + assert "genvm_executor_selector" in sql + assert LEGACY_EXECUTOR_SELECTOR in values + assert "v0.2." in values + assert _code_slot_b64(legacy=True) in values + assert _code_slot_b64() in values + assert "current_state.data \n" not in sql + def test_statement_compiles_against_postgres(self): """Guards the JSONB path expression, which mocks cannot validate.""" from sqlalchemy import func, select diff --git a/tests/unit/test_legacy_executor_selection.py b/tests/unit/test_legacy_executor_selection.py new file mode 100644 index 000000000..5a2130fff --- /dev/null +++ b/tests/unit/test_legacy_executor_selection.py @@ -0,0 +1,355 @@ +"""Legacy contracts must work through ordinary RPCs, without sim_config.""" + +import base64 +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from backend.consensus import base as consensus_base +from backend.consensus import worker as consensus_worker +from backend.consensus.base import ( + contract_snapshot_factory, + transaction_genvm_executor_selector, +) +from backend.consensus.effects import RegisterContractEffect +from backend.database_handler.contract_snapshot import ContractSnapshot +from backend.domain.types import ( + SimConfig, + Transaction, + TransactionStatus, + TransactionType, +) +from backend.node.base import Node +from backend.node.types import ExecutionResultStatus +from backend.node.genvm import base as genvm_base +from backend.node.genvm import get_code_slot +from backend.node.genvm.executor_selection import legacy_executor_selector_for_code +from backend.node.genvm.origin import calldata +from backend.node.genvm.origin.logger import NoLogger +from backend.protocol_rpc import endpoints + +LEGACY_SELECTOR = r"re:^v0\.2\." +LEGACY_CODE = b"""# v0.2.16 +# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +from genlayer import * + +class Legacy(gl.Contract): + value: str + + def __init__(self, value: str): + self.value = value + + @gl.public.view + def get_value(self) -> str: + return self.value +""" +ADDRESS = "0x" + "12" * 20 +SCHEMA = {"methods": {"get_value": {"readonly": True}}} + + +@pytest.mark.parametrize( + "header", + [ + b"# v0.2.16", + b"#v0.2.17", + b"#\tv0.2.*\t", + b"# v0.2.17-rc1\r", + b"// v0.2.16", + b"-- v0.2.16", + ], +) +def test_legacy_version_header_matches_genvm_text_comment_rules(header): + assert ( + legacy_executor_selector_for_code(header + b"\n# {}\ncode") == LEGACY_SELECTOR + ) + + +@pytest.mark.parametrize( + "code", + [ + b"", + b"\xff\x00", + b"# v0.3.0", + b"# v1.2.16", + b"# v0.20.16", + b"# v0.2.16 not-a-version", + b"# v0.2.16/../../", + b"# v0.*.*", + b" # v0.2.16", + b"\n# v0.2.16", + b"# {}\n# v0.2.16", + b'print("# v0.2.16")', + ], +) +def test_other_code_keeps_canonical_manager_selection(code): + assert legacy_executor_selector_for_code(code) is None + + +def _deployment(code=LEGACY_CODE, selector=None): + return Transaction( + hash="0xabc", + status=TransactionStatus.PENDING, + type=TransactionType.DEPLOY_CONTRACT, + from_address="0x" + "34" * 20, + to_address=ADDRESS, + data={"contract_code": base64.b64encode(code).decode("ascii")}, + value=0, + sim_config=( + SimConfig(validators=[], genvm_executor_selector=selector) + if selector + else None + ), + ) + + +@pytest.fixture +def schema_run(monkeypatch): + result = SimpleNamespace( + result=genvm_base.ExecutionReturn(calldata.encode(json.dumps(SCHEMA))), + genvm_log=[], + stdout="", + stderr="", + processing_time=0, + ) + run = AsyncMock(return_value=result) + monkeypatch.setattr(genvm_base, "run_genvm_host", run) + return run + + +def _node(): + node = Node.__new__(Node) + node.manager = SimpleNamespace(url="http://unused") + node.msg_handler = None + node.logger = NoLogger() + return node + + +@pytest.mark.asyncio +@pytest.mark.parametrize("debug", ["true", "false"]) +async def test_schema_for_legacy_code_selects_legacy_executor( + schema_run, monkeypatch, debug +): + monkeypatch.setenv("GENVM_DEBUG_MODE", debug) + assert json.loads(await _node().get_contract_schema(LEGACY_CODE)) == SCHEMA + kwargs = schema_run.await_args.kwargs + assert kwargs.get("genvm_executor_selector") == LEGACY_SELECTOR + assert kwargs["debug_mode"] == ("unsafe" if debug == "true" else "safe") + proxy = schema_run.await_args.args[0].keywords["state_proxy"] + assert bytes(proxy.data[get_code_slot(legacy=True)])[4:] == LEGACY_CODE + # v0.2 interprets the v0.3 code slot as its locked-slot list. Writing code + # there causes an OOM before Python starts, even with correct routing. + assert get_code_slot() not in proxy.data + + +@pytest.mark.asyncio +async def test_schema_explicit_selector_takes_precedence(schema_run): + await _node().get_contract_schema(LEGACY_CODE, genvm_executor_selector="v0.2.17") + assert schema_run.await_args.kwargs["genvm_executor_selector"] == "v0.2.17" + + +@pytest.mark.asyncio +async def test_current_schema_keeps_default_executor(schema_run): + await _node().get_contract_schema(LEGACY_CODE.replace(b"v0.2.16", b"v0.3.0")) + assert schema_run.await_args.kwargs.get("genvm_executor_selector") is None + proxy = schema_run.await_args.args[0].keywords["state_proxy"] + assert get_code_slot() in proxy.data + assert get_code_slot(legacy=True) not in proxy.data + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "code, explicit_selector, expected_selector", + [ + (LEGACY_CODE, None, LEGACY_SELECTOR), + (LEGACY_CODE, "v0.2.17", "v0.2.17"), + (LEGACY_CODE, "v0.3.0-rc7", "v0.3.0-rc7"), + (LEGACY_CODE.replace(b"v0.2.16", b"v0.3.0"), None, None), + ], +) +async def test_simulated_deployment_selects_executor_before_execution( + code, explicit_selector, expected_selector +): + # gen_call / fee estimation create an empty snapshot without using the + # consensus factory. Node must select the same line before executing it. + node = _node() + node.contract_snapshot = SimpleNamespace(genvm_executor_selector=explicit_selector) + node._run_genvm = AsyncMock() + await node.deploy_contract(ADDRESS, code, calldata.encode({"args": []})) + node._run_genvm.assert_awaited_once() + assert node.contract_snapshot.genvm_executor_selector == expected_selector + + +@pytest.mark.parametrize("selector", [LEGACY_SELECTOR, "v0.2.17"]) +def test_deployed_legacy_code_uses_legacy_slot_not_locked_slots(selector): + snapshot = ContractSnapshot.from_dict( + { + "contract_address": ADDRESS, + "genvm_executor_selector": selector, + "states": { + "accepted": { + base64.b64encode(get_code_slot(legacy=True)) + .decode(): base64.b64encode( + len(LEGACY_CODE).to_bytes(4, "little") + LEGACY_CODE + ) + .decode(), + # A nonempty v0.2 lock list is not contract code. + base64.b64encode(get_code_slot()) + .decode(): base64.b64encode((1).to_bytes(4, "little") + b"x" * 32) + .decode(), + }, + "finalized": {}, + }, + } + ) + assert base64.b64decode(snapshot.extract_deployed_code_b64()) == LEGACY_CODE + + +def test_legacy_deployment_infers_and_persists_selector_without_sim_config(): + transaction = _deployment() + assert transaction.sim_config is None + # Both the deploy snapshot and the accepted-contract effect use this helper. + assert transaction_genvm_executor_selector(transaction) == LEGACY_SELECTOR + snapshot = contract_snapshot_factory(ADDRESS, None, transaction) + assert snapshot.genvm_executor_selector == LEGACY_SELECTOR + + +def test_explicit_deployment_selector_is_preserved(): + transaction = _deployment(selector="v0.2.17") + assert transaction_genvm_executor_selector(transaction) == "v0.2.17" + assert ( + contract_snapshot_factory(ADDRESS, None, transaction).genvm_executor_selector + == "v0.2.17" + ) + + +def test_current_deployment_keeps_default_executor(): + transaction = _deployment(LEGACY_CODE.replace(b"v0.2.16", b"v0.3.0")) + assert transaction_genvm_executor_selector(transaction) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("legacy", [True, False]) +async def test_accepted_deployment_registers_matching_code_slot_and_selector( + monkeypatch, legacy +): + code = LEGACY_CODE if legacy else LEGACY_CODE.replace(b"v0.2.16", b"v0.3.0") + transaction = _deployment(code) + transaction.data["contract_address"] = ADDRESS + code_slot = base64.b64encode(get_code_slot(legacy=legacy)).decode() + code_blob = base64.b64encode(len(code).to_bytes(4, "little") + code).decode() + receipt = SimpleNamespace( + execution_result=ExecutionResultStatus.SUCCESS, + contract_state={code_slot: code_blob, "other-storage": "preserved"}, + node_config={}, + ) + context = SimpleNamespace( + transaction=transaction, + contract_snapshot=contract_snapshot_factory(ADDRESS, None, transaction), + consensus_data=SimpleNamespace( + leader_receipt=[receipt], to_dict=lambda **kwargs: {} + ), + validation_results=[], + ) + for name in ( + "_apply_external_message_freeze_check", + "_sync_reveal_message_fee_accounting", + "_dispatch_messages_for_phase", + ): + monkeypatch.setattr(consensus_base, name, Mock()) + executor = SimpleNamespace(execute=AsyncMock()) + monkeypatch.setattr(consensus_base, "EffectExecutor", lambda _: executor) + + await consensus_base.AcceptedState().handle(context) + + pre_effects = executor.execute.await_args_list[0].args[0] + registration = next(e for e in pre_effects if isinstance(e, RegisterContractEffect)) + stored = registration.contract_data + assert stored["genvm_executor_selector"] == (LEGACY_SELECTOR if legacy else None) + assert stored["data"]["state"]["accepted"] == receipt.contract_state + assert stored["data"]["state"]["finalized"] == {code_slot: code_blob} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("selector", [LEGACY_SELECTOR, "v0.2.17", None]) +async def test_code_upgrade_preserves_executor_and_other_storage(monkeypatch, selector): + legacy = selector is not None + code_slot = base64.b64encode(get_code_slot(legacy=legacy)).decode() + other_slot = base64.b64encode(get_code_slot(legacy=not legacy)).decode() + # In v0.2, the modern code slot contains the lock list and must not change. + contract = SimpleNamespace( + genvm_executor_selector=selector, + data={ + "state": {s: {other_slot: "preserved"} for s in ("accepted", "finalized")} + }, + ) + session = MagicMock() + session.query.return_value.filter_by.return_value.one_or_none.return_value = ( + contract + ) + tx = session.query.return_value.filter_by.return_value.one.return_value + dispatch = AsyncMock() + monkeypatch.setattr( + consensus_worker.ConsensusAlgorithm, + "dispatch_transaction_status_update", + dispatch, + ) + monkeypatch.setattr(consensus_worker, "TransactionsProcessor", Mock()) + worker = consensus_worker.ConsensusWorker.__new__(consensus_worker.ConsensusWorker) + worker.worker_id = "test" + worker.msg_handler = Mock() + worker.current_transactions = {} + worker.get_session = MagicMock() + worker.release_transaction = Mock() + code = LEGACY_CODE.decode() + + await worker._process_upgrade_transaction( + {"hash": "0xabc", "to_address": ADDRESS, "data": {"new_code": code}}, session + ) + + assert tx.consensus_data["upgrade_result"] == "success" + assert contract.genvm_executor_selector == selector + for state in ("accepted", "finalized"): + assert contract.data["state"][state][other_slot] == "preserved" + assert ( + base64.b64decode(contract.data["state"][state][code_slot])[4:] + == code.encode() + ) + session.rollback.assert_not_called() + assert dispatch.await_args.args[2] == TransactionStatus.FINALIZED + + +def test_non_deployment_does_not_infer_selector_from_transaction_data(): + transaction = _deployment() + transaction.type = TransactionType.RUN_CONTRACT + assert transaction_genvm_executor_selector(transaction) is None + + +@pytest.mark.parametrize("code_b64", [None, "contract code", "\u2603", 123]) +def test_malformed_deployment_data_still_uses_normal_error_handling(code_b64): + transaction = _deployment() + transaction.data["contract_code"] = code_b64 + assert transaction_genvm_executor_selector(transaction) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stored_selector", ["v0.2.17", LEGACY_SELECTOR, None]) +async def test_address_schema_preserves_stored_executor(monkeypatch, stored_selector): + snapshot = Mock() + snapshot.extract_deployed_code_b64.return_value = base64.b64encode(LEGACY_CODE) + snapshot.genvm_executor_selector = stored_selector + monkeypatch.setattr(endpoints, "ContractSnapshot", Mock(return_value=snapshot)) + node = Mock() + node.get_contract_schema = AsyncMock(return_value=json.dumps(SCHEMA)) + monkeypatch.setattr(endpoints, "Node", Mock(return_value=node)) + msg_handler = Mock() + msg_handler.with_client_session.return_value = msg_handler + + assert ( + await endpoints.get_contract_schema(None, Mock(), msg_handler, ADDRESS) + == SCHEMA + ) + node.get_contract_schema.assert_awaited_once_with( + LEGACY_CODE, genvm_executor_selector=stored_selector + )