Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions backend/consensus/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion backend/consensus/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 20 additions & 5 deletions backend/database_handler/contract_snapshot.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -99,20 +103,22 @@ 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)
except Exception:
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]:
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 24 additions & 4 deletions backend/node/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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)

Expand Down
8 changes: 6 additions & 2 deletions backend/node/genvm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
37 changes: 37 additions & 0 deletions backend/node/genvm/executor_selection.py
Original file line number Diff line number Diff line change
@@ -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.")
)
5 changes: 4 additions & 1 deletion backend/protocol_rpc/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
29 changes: 28 additions & 1 deletion tests/integration/test_deploy_reroute_to.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -245,14 +246,17 @@ 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


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:
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_contract_code_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -111,13 +112,37 @@ 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")
assert _decode_code_payload(blob.decode()) == base64.b64encode(b"abc").decode()


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
Expand Down
Loading
Loading