From f48c4d7121d9109c5709a065ef15a91e1fe379b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Mon, 8 Jun 2026 19:58:28 +0100 Subject: [PATCH 01/16] fix: support GenVM v0.3 direct SDK layout (#90) * fix: support genvm v0.3 direct sdk layout * fix: migrate fixtures to genvm v0.3 sdk --- CLAUDE.md | 4 +- README.md | 6 +- docs/api-references/index.md | 6 +- docs/direct-runner.md | 4 +- docs/studio-runner.md | 8 +- glsim/README.md | 2 +- glsim/consensus.py | 2 +- glsim/engine.py | 133 ++++++++---------- gltest/artifacts/contract.py | 34 ++--- gltest/direct/loader.py | 51 ++++--- gltest/direct/sdk_compat.py | 80 +++++++++++ gltest/direct/vm.py | 46 +++--- gltest/direct/wasi_mock.py | 8 +- .../contracts/football_prediction_market.py | 8 +- .../examples/contracts/intelligent_oracle.py | 14 +- .../contracts/intelligent_oracle_factory.py | 10 +- tests/examples/contracts/invalid_deploy.py | 4 +- tests/examples/contracts/llm_erc20.py | 14 +- tests/examples/contracts/log_indexer.py | 14 +- .../contracts/multi_file_contract/__init__.py | 14 +- .../contracts/multi_file_contract/other.py | 6 +- .../contracts/multi_file_contract/runner.json | 2 +- tests/examples/contracts/multi_read_erc20.py | 14 +- .../contracts/multi_tenant_storage.py | 22 +-- tests/examples/contracts/read_erc20.py | 12 +- .../contracts/simple_time_contract.py | 7 +- tests/examples/contracts/storage.py | 4 +- tests/examples/contracts/user_storage.py | 8 +- tests/examples/contracts/wizard_of_coin.py | 4 +- .../examples/contracts/x_username_storage.py | 7 +- tests/glsim/deterministic_factory_contract.py | 13 +- tests/glsim/disagree_contract.py | 4 +- tests/glsim/web_contract.py | 4 +- .../contracts/duplicate_ic_contract_1.py | 6 +- .../contracts/duplicate_ic_contract_2.py | 6 +- .../artifact/contracts/not_ic_contract.py | 4 +- tests/gltest_direct/test_direct_runner.py | 22 +-- 37 files changed, 337 insertions(+), 270 deletions(-) create mode 100644 gltest/direct/sdk_compat.py diff --git a/CLAUDE.md b/CLAUDE.md index 05616f6..f13d265 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ GenLayer contracts are Python classes: ```python import genlayer as gl -class MyContract(gl.Contract): +class MyContract(gl.contract.Contract): def __init__(self, initial_value: int): self.value = initial_value @@ -168,4 +168,4 @@ Multi-file contracts use a `runner.json` manifest: 2. Contracts are discovered automatically from the contracts directory 3. Transaction receipts include consensus information and triggered transactions 4. LLM-based contract methods use `gl.eq_principle_prompt_non_comparative()` -5. Version is managed automatically via semantic release (no manual updates) \ No newline at end of file +5. Version is managed automatically via semantic release (no manual updates) diff --git a/README.md b/README.md index dac8d5f..75b166f 100644 --- a/README.md +++ b/README.md @@ -367,9 +367,9 @@ print(f"Unique states: {analysis.unique_states}") ## Example Contract ```python -from genlayer import * +import genlayer as gl -class Storage(gl.Contract): +class Storage(gl.contract.Contract): storage: str def __init__(self, initial_storage: str): @@ -400,7 +400,7 @@ For more examples, see the [contracts directory](tests/examples/contracts). ## Troubleshooting -**Contract not found**: Ensure contracts are in `contracts/` or specify `--contracts-dir`. Contracts must inherit from `gl.Contract`. +**Contract not found**: Ensure contracts are in `contracts/` or specify `--contracts-dir`. Contracts must inherit from `gl.contract.Contract`. **Transaction timeouts** (Studio mode): Increase `wait_interval` and `wait_retries` in `.transact()`. diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 27696a9..445584b 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -362,9 +362,9 @@ print(f"Unique states: {analysis.unique_states}") ## Example Contract ```python -from genlayer import * +import genlayer as gl -class Storage(gl.Contract): +class Storage(gl.contract.Contract): storage: str def __init__(self, initial_storage: str): @@ -395,7 +395,7 @@ For more examples, see the [contracts directory](tests/examples/contracts). ## Troubleshooting -**Contract not found**: Ensure contracts are in `contracts/` or specify `--contracts-dir`. Contracts must inherit from `gl.Contract`. +**Contract not found**: Ensure contracts are in `contracts/` or specify `--contracts-dir`. Contracts must inherit from `gl.contract.Contract`. **Transaction timeouts** (Studio mode): Increase `wait_interval` and `wait_retries` in `.transact()`. diff --git a/docs/direct-runner.md b/docs/direct-runner.md index 4e30b07..97ba33a 100644 --- a/docs/direct-runner.md +++ b/docs/direct-runner.md @@ -359,9 +359,9 @@ Direct mode automatically downloads and caches the correct GenLayer SDK version # Contract with version header # { "Depends": "py-genlayer:abc123..." } -from genlayer import * +import genlayer as gl -class MyContract(gl.Contract): +class MyContract(gl.contract.Contract): ... ``` diff --git a/docs/studio-runner.md b/docs/studio-runner.md index 81efeaf..0e0b267 100644 --- a/docs/studio-runner.md +++ b/docs/studio-runner.md @@ -972,16 +972,16 @@ gltest --contracts-dir /path/to/contracts ### Contract Structure Issues -Contracts must inherit from `gl.Contract`: +Contracts must inherit from `gl.contract.Contract`: ```python # Correct -from genlayer import * +import genlayer as gl -class MyContract(gl.Contract): +class MyContract(gl.contract.Contract): pass -# Wrong — missing gl.Contract inheritance +# Wrong — missing gl.contract.Contract inheritance class MyContract: pass ``` diff --git a/glsim/README.md b/glsim/README.md index c75c6fc..4053253 100644 --- a/glsim/README.md +++ b/glsim/README.md @@ -144,7 +144,7 @@ Contracts that read sibling files via `open("/contract/OtherModule.py")` work ### Cross-Contract Calls -Contracts using `gl.deploy_contract()`, `gl.contract_at().view()`, and `gl.contract_at().emit()` work. glsim handles: +Contracts using `gl.contract.deploy()`, `gl.contract.get_at().view()`, and `gl.contract.get_at().emit()` work. glsim handles: - **DeployContract** — deploys child contract with isolated storage - **CallContract** — calls method on deployed contract, returns result - **PostMessage** — fire-and-forget call (no return value) diff --git a/glsim/consensus.py b/glsim/consensus.py index cd9cd22..411a6fc 100644 --- a/glsim/consensus.py +++ b/glsim/consensus.py @@ -101,7 +101,7 @@ def run_consensus( def _run_validators(vm, captured, num_validators): """Run captured validator_fns for each validator. Returns list of votes.""" - import genlayer.gl.vm as gl_vm + import genlayer.vm as gl_vm votes = [] for _ in range(num_validators): diff --git a/glsim/engine.py b/glsim/engine.py index 870d456..828f6e0 100644 --- a/glsim/engine.py +++ b/glsim/engine.py @@ -28,6 +28,7 @@ _allocate_contract, _patch_run_nondet_for_direct_mode, ) +from gltest.direct.sdk_compat import import_address, import_calldata, sync_message_context from .state import StateStore from .tx_decoder import decode_calldata_bytes, encode_calldata_result @@ -193,7 +194,7 @@ def deploy( self._storages[addr_key] = storage self.vm._storage = storage - # Set gl.message so __init__ can read contract_address if needed + # Set genlayer.message so __init__ can read contract_address if needed self._set_message_context( contract_address=addr_bytes, sender=self.vm.sender, @@ -209,9 +210,9 @@ def deploy( instance = deploy_contract(path, self.vm, *args, sdk_version=None, **kwargs) # deploy_contract() may clobber vm._contract_address with sha256(path) — - # restore the real address and sync gl.message. + # restore the real address and sync genlayer.message. self.vm._contract_address = addr_bytes - self._sync_gl_message_contract_address(addr_bytes) + self._sync_message_contract_address(addr_bytes) self._instances[addr_key] = instance @@ -269,12 +270,12 @@ def call_method( if storage is not None: self.vm._storage = storage - # Update gl.message so contract code can read contract_address/sender + # Update genlayer.message so contract code can read contract_address/sender self._set_message_context( contract_address=addr_bytes, sender=self.vm.sender, ) - self._sync_gl_message_contract_address(addr_bytes) + self._sync_message_contract_address(addr_bytes) method = getattr(instance, method_name, None) if method is None: @@ -592,16 +593,13 @@ def _extract_sdk_schema(self, cls: type) -> Dict: def _reset_contract_registry() -> None: """Reset the genlayer SDK's global contract class registry. - The SDK only allows one Contract subclass. Different SDK versions - use different variable names (``__known_contact__`` vs - ``__known_contract__``). We clear whichever exists. + The SDK only allows one Contract subclass per loaded module. """ - mod = sys.modules.get("genlayer.gl.genvm_contracts") + mod = sys.modules.get("genlayer.contract") if mod is None: return - for attr in ("__known_contact__", "__known_contract__"): - if hasattr(mod, attr): - setattr(mod, attr, None) + if hasattr(mod, "__known_contract__"): + setattr(mod, "__known_contract__", None) def _install_live_handlers(self) -> None: """Install live web/LLM handlers on the VM context.""" @@ -626,10 +624,10 @@ def hook(vm, request): self.vm._gl_call_hook = hook def _handle_deploy_in_contract(self, vm: Any, data: Dict) -> bytes: - """Handle gl.deploy_contract() from within a running contract.""" - from genlayer.py import calldata - from genlayer.py.types import Address - from genlayer.py._internal import create2_address + """Handle gl.contract.deploy() from within a running contract.""" + calldata = import_calldata() + Address = import_address() + from genlayer._internal import create2_address self._ensure_direct_mode_runtime_patches() code = data.get('code', b'') @@ -672,14 +670,14 @@ def _handle_deploy_in_contract(self, vm: Any, data: Dict) -> bytes: vm._storage = child_storage vm._contract_address = child_addr_bytes - # Swap gl.message to child context (like _handle_call_in_contract does) + # Swap genlayer.message to child context (like _handle_call_in_contract does) # so that child's __init__ sees the correct contract_address & sender. saved_message = self._swap_message_context( vm, sender=parent_contract_address, contract_address=child_addr_bytes, ) - self._sync_gl_message_contract_address(child_addr_bytes) + self._sync_message_contract_address(child_addr_bytes) try: # Deploy child contract. @@ -731,9 +729,9 @@ def _handle_deploy_in_contract(self, vm: Any, data: Dict) -> bytes: return calldata.encode(Address(child_addr_bytes)) def _handle_call_in_contract(self, vm: Any, data: Dict) -> bytes: - """Handle gl.contract_at().view().method() from within a running contract.""" - from genlayer.py import calldata - from genlayer.py.types import Address + """Handle gl.contract.get_at().view().method() from within a running contract.""" + calldata = import_calldata() + Address = import_address() self._ensure_direct_mode_runtime_patches() address = data.get('address') @@ -767,7 +765,7 @@ def _handle_call_in_contract(self, vm: Any, data: Dict) -> bytes: vm._storage = target_storage vm._contract_address = bytes.fromhex(addr_key[2:]) - # Swap gl.message context + # Swap genlayer.message context saved_message = self._swap_message_context( vm, sender=parent_contract_address, @@ -790,8 +788,8 @@ def _handle_call_in_contract(self, vm: Any, data: Dict) -> bytes: self._restore_message_context(saved_message) def _handle_post_in_contract(self, vm: Any, data: Dict) -> Dict: - """Handle gl.contract_at().emit().method() — enqueue for after current call.""" - from genlayer.py.types import Address + """Handle gl.contract.get_at().emit().method() — enqueue for after current call.""" + Address = import_address() address = data.get('address') calldata_obj = data.get('calldata', {}) @@ -826,67 +824,66 @@ def _handle_post_in_contract(self, vm: Any, data: Dict) -> Dict: @staticmethod def _swap_message_context(vm: Any, sender: Any, contract_address: Any) -> Optional[Dict]: - """Swap gl.message for cross-contract calls. Returns saved state.""" - if 'genlayer.gl' not in sys.modules: + """Swap genlayer.message for cross-contract calls. Returns saved state.""" + message = sys.modules.get('genlayer.message') + if message is None: return None try: - gl = sys.modules['genlayer.gl'] - from genlayer.py.types import Address + Address = import_address() if isinstance(sender, bytes): sender = Address(sender) if isinstance(contract_address, bytes): contract_address = Address(contract_address) - saved = {} - if hasattr(gl, 'message') and gl.message is not None: - saved['message'] = gl.message - gl.message = gl.MessageType( - contract_address=contract_address, - sender_address=sender, - origin_address=gl.message.origin_address, - value=gl.message.value, - chain_id=gl.message.chain_id, - ) + fields = ( + "contract_address", + "sender_address", + "origin_address", + "value", + "chain_id", + ) + saved = { + field: getattr(message, field) + for field in fields + if hasattr(message, field) + } + sync_message_context( + contract_address=contract_address, + sender_address=sender, + ) return saved - except (ImportError, AttributeError): + except ImportError: return None @staticmethod def _set_message_context(contract_address: Any, sender: Any) -> None: - """Set gl.message for top-level calls (call_method / deploy).""" - if 'genlayer.gl' not in sys.modules: + """Set genlayer.message for top-level calls (call_method / deploy).""" + if 'genlayer.message' not in sys.modules: return try: - gl = sys.modules['genlayer.gl'] - from genlayer.py.types import Address + Address = import_address() if isinstance(contract_address, bytes): contract_address = Address(contract_address) if isinstance(sender, bytes): sender = Address(sender) - if hasattr(gl, 'message') and gl.message is not None: - gl.message = gl.MessageType( - contract_address=contract_address, - sender_address=sender, - origin_address=gl.message.origin_address, - value=gl.message.value, - chain_id=gl.message.chain_id, - ) - except (ImportError, AttributeError): + sync_message_context( + contract_address=contract_address, + sender_address=sender, + ) + except ImportError: pass @staticmethod def _restore_message_context(saved: Optional[Dict]) -> None: - """Restore gl.message after cross-contract call.""" + """Restore genlayer.message after cross-contract call.""" if saved is None: return - gl = sys.modules.get('genlayer.gl') - if gl is None: + if sys.modules.get('genlayer.message') is None: return - if 'message' in saved: - gl.message = saved['message'] + sync_message_context(**saved) @staticmethod def _install_cloudpickle_bypass() -> None: @@ -920,25 +917,15 @@ def _bypass_dumps(obj, protocol=None, buffer_callback=None): cloudpickle.dumps = _bypass_dumps @staticmethod - def _sync_gl_message_contract_address(addr_bytes: bytes) -> None: - """Update gl.message.contract_address to match vm._contract_address.""" - if 'genlayer.gl' not in sys.modules: + def _sync_message_contract_address(addr_bytes: bytes) -> None: + """Update genlayer.message.contract_address to match vm._contract_address.""" + if 'genlayer.message' not in sys.modules: return try: - gl = sys.modules['genlayer.gl'] - from genlayer.py.types import Address + Address = import_address() new_addr = Address(addr_bytes) - if hasattr(gl, 'message') and gl.message is not None: - gl.message = gl.MessageType( - contract_address=new_addr, - sender_address=gl.message.sender_address, - origin_address=gl.message.origin_address, - value=gl.message.value, - chain_id=gl.message.chain_id, - ) - if hasattr(gl, 'message_raw') and gl.message_raw is not None: - gl.message_raw['contract_address'] = new_addr - except (ImportError, AttributeError): + sync_message_context(contract_address=new_addr) + except ImportError: pass @staticmethod diff --git a/gltest/artifacts/contract.py b/gltest/artifacts/contract.py index f1acb6f..e332a1c 100644 --- a/gltest/artifacts/contract.py +++ b/gltest/artifacts/contract.py @@ -37,16 +37,11 @@ def search_path_by_class_name(contracts_dir: Path, contract_name: str) -> Path: # Search for class definitions for node in ast.walk(tree): if isinstance(node, ast.ClassDef) and node.name == contract_name: - # Check if the class directly inherits from gl.Contract + # Check if the class directly inherits from gl.contract.Contract for base in node.bases: - if isinstance(base, ast.Attribute): - if ( - isinstance(base.value, ast.Name) - and base.value.id == "gl" - and base.attr == "Contract" - ): - matching_files.append(file_path) - break + if _is_genlayer_contract_base(base): + matching_files.append(file_path) + break break except Exception as e: raise ValueError(f"Error reading file {file_path}: {e}") from e @@ -97,16 +92,11 @@ def _extract_contract_name_from_file(file_path: Path) -> str: content = f.read() tree = ast.parse(content) - # Search for class definitions that inherit from gl.Contract + # Search for class definitions that inherit from gl.contract.Contract for node in ast.walk(tree): if isinstance(node, ast.ClassDef): for base in node.bases: - if ( - isinstance(base, ast.Attribute) - and isinstance(base.value, ast.Name) - and base.value.id == "gl" - and base.attr == "Contract" - ): + if _is_genlayer_contract_base(base): return node.name except Exception as e: raise ValueError(f"Error parsing contract file {file_path}: {e}") from e @@ -114,6 +104,18 @@ def _extract_contract_name_from_file(file_path: Path) -> str: raise ValueError(f"No valid contract class found in {file_path}") +def _is_genlayer_contract_base(base: ast.expr) -> bool: + """Return True for ``gl.contract.Contract`` inheritance.""" + return ( + isinstance(base, ast.Attribute) + and base.attr == "Contract" + and isinstance(base.value, ast.Attribute) + and base.value.attr == "contract" + and isinstance(base.value.value, ast.Name) + and base.value.value.id == "gl" + ) + + def _create_contract_definition( main_file_path: Path, contract_name: str ) -> ContractDefinition: diff --git a/gltest/direct/loader.py b/gltest/direct/loader.py index cf0430d..59c0149 100644 --- a/gltest/direct/loader.py +++ b/gltest/direct/loader.py @@ -21,6 +21,12 @@ if TYPE_CHECKING: from .vm import VMContext +from .sdk_compat import ( + import_address, + import_calldata, + import_lazy, +) + def load_contract_class( contract_path: Path, @@ -124,7 +130,7 @@ def _patched(obj, globalns=None, localns=None, include_extras=False): # type: i def _patch_run_nondet_for_direct_mode() -> None: - """Replace gl.vm.run_nondet with a direct-mode version. + """Replace genlayer.vm.run_nondet with direct-mode versions. The SDK's run_nondet pickles leader_fn via cloudpickle to pass through the WASM boundary. In direct mode there's no WASM, and the closure @@ -132,7 +138,7 @@ def _patch_run_nondet_for_direct_mode() -> None: We bypass pickling by calling leader_fn() directly. """ try: - import genlayer.gl.vm as gl_vm + import genlayer.vm as gl_vm except ImportError: return @@ -153,9 +159,12 @@ def _direct_run_nondet(leader_fn, validator_fn, /, **kwargs): vm._captured_validators.append((result, leader_fn, validator_fn)) return result - def _direct_run_nondet_unsafe(leader_fn, validator_fn, /): + def _direct_run_nondet_default(leader_fn, validator_fn, /, **kwargs): from . import wasi_mock vm = wasi_mock.get_vm() + if vm._check_pickling: + _validate_pickling(leader_fn, "leader_fn") + _validate_pickling(validator_fn, "validator_fn") vm._in_nondet = True try: result = leader_fn() @@ -164,24 +173,24 @@ def _direct_run_nondet_unsafe(leader_fn, validator_fn, /): vm._captured_validators.append((result, leader_fn, validator_fn)) return result - # lazy-api compat: eq_principle.strict_eq calls vm.run_nondet_unsafe.lazy() # The SDK uses @_lazy_api which attaches .lazy to the eager function. # .lazy must return a Lazy[T] wrapper instead of the raw value. - from genlayer.py.types import Lazy + Lazy = import_lazy() def _lazy_run_nondet(leader_fn, validator_fn, /, **kwargs): return Lazy(lambda: _direct_run_nondet(leader_fn, validator_fn, **kwargs)) - def _lazy_run_nondet_unsafe(leader_fn, validator_fn, /): - return Lazy(lambda: _direct_run_nondet_unsafe(leader_fn, validator_fn)) + def _lazy_run_nondet_default(leader_fn, validator_fn, /, **kwargs): + return Lazy( + lambda: _direct_run_nondet_default(leader_fn, validator_fn, **kwargs) + ) _direct_run_nondet.lazy = _lazy_run_nondet - _direct_run_nondet_unsafe.lazy = _lazy_run_nondet_unsafe + _direct_run_nondet_default.lazy = _lazy_run_nondet_default gl_vm.run_nondet = _direct_run_nondet - gl_vm.run_nondet_unsafe = _direct_run_nondet_unsafe + gl_vm.run_nondet_default = _direct_run_nondet_default gl_vm._direct_mode_patched = True - gl_vm._direct_mode_unsafe_patched = True # Also mock embeddings (ONNX model not available in direct mode) _mock_embeddings_for_direct_mode() @@ -240,8 +249,8 @@ def _inject_message_to_fd0(vm: "VMContext") -> None: import tempfile try: - from genlayer.py import calldata - from genlayer.py.types import Address + calldata = import_calldata() + Address = import_address() except ImportError: return @@ -342,7 +351,7 @@ def _find_contract_class(module: Any) -> Optional[Type[Any]]: # Second priority: inherits from Contract for base in obj.__mro__: - if base.__name__ in ('Contract', 'gl.Contract'): + if base.__name__ == 'Contract': return obj # Third priority: has storage-like annotations @@ -375,7 +384,7 @@ def _calldata_roundtrip_args( at deploy time. """ try: - from genlayer.py import calldata + calldata = import_calldata() except ImportError: return args, kwargs @@ -461,16 +470,14 @@ def _allocate_contract( ) -> Any: """Allocate and initialize a contract instance.""" try: - from genlayer.py.storage import Root, ROOT_SLOT_ID - from genlayer.py.storage._internal.generate import ( + from genlayer.storage import ROOT_SLOT_ID + from genlayer.storage._internal.generate import ( ORIGINAL_INIT_ATTR, + _BuilderCtx, _storage_build, - Lit, ) - # Build the storage type descriptor - td = _storage_build(contract_cls, {}) - assert not isinstance(td, Lit) + td = _storage_build(_BuilderCtx.empty(), contract_cls) # Use the VM's storage manager slot = vm._storage.get_store_slot(ROOT_SLOT_ID) @@ -493,7 +500,7 @@ def _allocate_contract( pass try: - from genlayer.py.storage import Root + from genlayer.storage import Root Root.MANAGER = vm._storage @@ -520,7 +527,7 @@ def create_address(seed: str) -> Any: addr_bytes = hashlib.sha256(seed.encode()).digest()[:20] try: - from genlayer.py.types import Address + Address = import_address() return Address(addr_bytes) except ImportError: return addr_bytes diff --git a/gltest/direct/sdk_compat.py b/gltest/direct/sdk_compat.py new file mode 100644 index 0000000..61c5d17 --- /dev/null +++ b/gltest/direct/sdk_compat.py @@ -0,0 +1,80 @@ +"""Helpers for the GenVM v0.3 SDK layout.""" + +from __future__ import annotations + +import sys +from typing import Any + +_UNSET = object() + + +def import_calldata() -> Any: + """Return the v0.3 calldata module from the active SDK path.""" + from genlayer import calldata + + return calldata + + +def import_types() -> Any: + """Return the v0.3 types module from the active SDK path.""" + from genlayer import types as sdk_types + + return sdk_types + + +def import_address() -> type: + return import_types().Address + + +def import_address_u256() -> tuple[type, Any]: + sdk_types = import_types() + return sdk_types.Address, sdk_types.u256 + + +def import_lazy() -> Any: + return import_types().Lazy + + +def _coerce_address(value: Any) -> Any: + if value is _UNSET or value is None: + return value + Address = import_address() + if isinstance(value, Address): + return value + if isinstance(value, bytes): + return Address(value) + if hasattr(value, "as_bytes"): + return Address(value.as_bytes) + return value + + +def sync_message_context( + *, + contract_address: Any = _UNSET, + sender_address: Any = _UNSET, + origin_address: Any = _UNSET, + value: Any = _UNSET, + chain_id: Any = _UNSET, +) -> None: + """Synchronize the v0.3 message module without triggering a fresh import.""" + contract_address = _coerce_address(contract_address) + sender_address = _coerce_address(sender_address) + origin_address = _coerce_address(origin_address) + + message_mod = sys.modules.get("genlayer.message") + raw = getattr(message_mod, "raw", None) if message_mod is not None else None + + updates = { + "contract_address": contract_address, + "sender_address": sender_address, + "origin_address": origin_address, + "value": value, + "chain_id": chain_id, + } + for name, next_value in updates.items(): + if next_value is _UNSET: + continue + if message_mod is not None: + setattr(message_mod, name, next_value) + if isinstance(raw, dict): + raw[name] = next_value diff --git a/gltest/direct/vm.py b/gltest/direct/vm.py index 063e60e..a88f5af 100644 --- a/gltest/direct/vm.py +++ b/gltest/direct/vm.py @@ -21,6 +21,7 @@ from unittest.mock import patch from ..types import MockedWebResponseData +from .sdk_compat import import_address_u256, sync_message_context _sentinel = object() @@ -51,7 +52,7 @@ class Snapshot: class InmemManager: """ - In-memory storage manager compatible with genlayer.py.storage. + In-memory storage manager compatible with genlayer.storage. """ def __init__(self): @@ -112,7 +113,7 @@ def restore(self, data: Dict[bytes, bytes]) -> None: class Slot: - """Storage slot compatible with genlayer.py.storage.""" + """Storage slot compatible with genlayer.storage.""" __slots__ = ('id', 'manager', '_indir_cache') @@ -371,10 +372,10 @@ def run_validator( stored_result, leader_fn, validator_fn = self._captured_validators[index] - import genlayer.gl.vm as gl_vm + import genlayer.vm as gl_vm if leader_error is not None: - wrapped = gl_vm.UserError(message=str(leader_error)) + wrapped = gl_vm.UserError(str(leader_error)) elif leader_result is not _sentinel: wrapped = gl_vm.Return(calldata=leader_result) else: @@ -583,21 +584,20 @@ def _to_bytes(self, addr: Any) -> bytes: def _refresh_gl_message(self) -> None: """ - Refresh gl.message and gl.message_raw to reflect current sender. + Refresh genlayer.message to reflect current sender. - GenLayer SDK caches gl.message at import time. This method updates - the cached values so contracts see the current vm.sender. + The SDK reads genlayer.message from stdin at import time. This method + updates the loaded module so contracts see the current vm.sender. - Only updates if genlayer.gl is already imported - we must not trigger - a fresh import as that would read from stdin before message is injected. + Only updates if genlayer.message is already imported - we must not + trigger a fresh import as that would read from stdin before message is + injected. """ - # Only proceed if genlayer.gl is already loaded - if 'genlayer.gl' not in sys.modules: + if 'genlayer.message' not in sys.modules: return try: - gl = sys.modules['genlayer.gl'] - from genlayer.py.types import Address, u256 + Address, u256 = import_address_u256() # Convert sender to Address if needed sender = self.sender @@ -614,20 +614,12 @@ def _refresh_gl_message(self) -> None: elif hasattr(origin, 'as_bytes'): origin = Address(origin.as_bytes) - # Update message_raw dict (mutable) - if hasattr(gl, 'message_raw') and gl.message_raw is not None: - gl.message_raw['sender_address'] = sender - gl.message_raw['origin_address'] = origin - - # Replace gl.message with new NamedTuple (immutable, must recreate) - if hasattr(gl, 'message') and gl.message is not None: - gl.message = gl.MessageType( - contract_address=gl.message.contract_address, - sender_address=sender, - origin_address=origin, - value=u256(self._value), - chain_id=u256(self._chain_id), - ) + sync_message_context( + sender_address=sender, + origin_address=origin, + value=u256(self._value), + chain_id=u256(self._chain_id), + ) except ImportError: # genlayer not loaded yet, nothing to update pass diff --git a/gltest/direct/wasi_mock.py b/gltest/direct/wasi_mock.py index 6c04c9d..5c490e2 100644 --- a/gltest/direct/wasi_mock.py +++ b/gltest/direct/wasi_mock.py @@ -21,6 +21,8 @@ if TYPE_CHECKING: from .vm import VMContext +from .sdk_compat import import_calldata + # Thread-local VM context for parallel test safety _local = threading.local() @@ -95,7 +97,7 @@ def gl_call(data: bytes, /) -> int: fd_buffers = getattr(_local, 'fd_buffers', {}) try: - from genlayer.py import calldata + calldata = import_calldata() request = calldata.decode(data) except Exception as e: vm._trace(f"gl_call decode error: {e}") @@ -126,7 +128,7 @@ def gl_call(data: bytes, /) -> int: # Regular responses (web, llm, etc) are just calldata-encoded # The SDK's _decode_nondet expects plain {"ok": ...} format try: - from genlayer.py import calldata + calldata = import_calldata() encoded = calldata.encode(response) except Exception as e: vm._trace(f"gl_call encode error: {e}") @@ -340,7 +342,7 @@ def _handle_run_nondet(vm: "VMContext", data: Any) -> Any: the leader function, returning its result. """ import cloudpickle - from genlayer.py import calldata + calldata = import_calldata() data_leader = data.get("data_leader") if not data_leader: diff --git a/tests/examples/contracts/football_prediction_market.py b/tests/examples/contracts/football_prediction_market.py index a8c6ed8..2e33279 100644 --- a/tests/examples/contracts/football_prediction_market.py +++ b/tests/examples/contracts/football_prediction_market.py @@ -1,18 +1,18 @@ # v0.1.0 # { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl import json import typing -class PredictionMarket(gl.Contract): +class PredictionMarket(gl.contract.Contract): has_resolved: bool team1: str team2: str resolution_url: str - winner: u256 + winner: gl.u256 score: str def __init__(self, game_date: str, team1: str, team2: str): @@ -37,7 +37,7 @@ def __init__(self, game_date: str, team1: str, team2: str): ) self.team1 = team1 self.team2 = team2 - self.winner = u256(0) + self.winner = gl.u256(0) self.score = "" @gl.public.write diff --git a/tests/examples/contracts/intelligent_oracle.py b/tests/examples/contracts/intelligent_oracle.py index e839243..8e9cd06 100644 --- a/tests/examples/contracts/intelligent_oracle.py +++ b/tests/examples/contracts/intelligent_oracle.py @@ -1,11 +1,11 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } import json from enum import Enum from datetime import datetime, timezone from urllib.parse import urlparse -from genlayer import * +import genlayer as gl class Status(Enum): @@ -14,15 +14,15 @@ class Status(Enum): ERROR = "Error" -class IntelligentOracle(gl.Contract): +class IntelligentOracle(gl.contract.Contract): # Declare persistent storage fields prediction_market_id: str title: str description: str - potential_outcomes: DynArray[str] - rules: DynArray[str] - data_source_domains: DynArray[str] - resolution_urls: DynArray[str] + potential_outcomes: gl.DynArray[str] + rules: gl.DynArray[str] + data_source_domains: gl.DynArray[str] + resolution_urls: gl.DynArray[str] earliest_resolution_date: str # Store as ISO format string status: str # Store as string since Enum isn't supported analysis: str # Store analysis results diff --git a/tests/examples/contracts/intelligent_oracle_factory.py b/tests/examples/contracts/intelligent_oracle_factory.py index e17dca4..0d147b8 100644 --- a/tests/examples/contracts/intelligent_oracle_factory.py +++ b/tests/examples/contracts/intelligent_oracle_factory.py @@ -1,12 +1,12 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl -class Registry(gl.Contract): +class Registry(gl.contract.Contract): # Declare persistent storage fields - contract_addresses: DynArray[str] + contract_addresses: gl.DynArray[str] intelligent_oracle_code: str def __init__(self, intelligent_oracle_code: str): @@ -25,7 +25,7 @@ def create_new_prediction_market( earliest_resolution_date: str, ) -> None: registered_contracts = len(self.contract_addresses) - contract_address = gl.deploy_contract( + contract_address = gl.contract.deploy( code=self.intelligent_oracle_code.encode("utf-8"), args=[ prediction_market_id, diff --git a/tests/examples/contracts/invalid_deploy.py b/tests/examples/contracts/invalid_deploy.py index 8a04b58..029b6b1 100644 --- a/tests/examples/contracts/invalid_deploy.py +++ b/tests/examples/contracts/invalid_deploy.py @@ -1,9 +1,9 @@ -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } import genlayer as gl -class InvalidDeploy(gl.Contract): +class InvalidDeploy(gl.contract.Contract): """Contract that always fails during deployment""" def __init__(self): diff --git a/tests/examples/contracts/llm_erc20.py b/tests/examples/contracts/llm_erc20.py index 0ae6efe..85561e4 100644 --- a/tests/examples/contracts/llm_erc20.py +++ b/tests/examples/contracts/llm_erc20.py @@ -3,14 +3,14 @@ import json -from genlayer import * +import genlayer as gl -class LlmErc20(gl.Contract): - balances: TreeMap[Address, u256] +class LlmErc20(gl.contract.Contract): + balances: gl.TreeMap[gl.Address, gl.u256] def __init__(self, total_supply: int) -> None: - self.balances[gl.message.sender_address] = u256(total_supply) + self.balances[gl.message.sender_address] = gl.u256(total_supply) @gl.public.write def transfer(self, amount: int, to_address: str) -> None: @@ -20,7 +20,7 @@ def transfer(self, amount: int, to_address: str) -> None: {json.dumps(self.get_balances())} The transaction to compute is: {{ sender: "{gl.message.sender_address.as_hex}", -recipient: "{Address(to_address).as_hex}", +recipient: "{gl.Address(to_address).as_hex}", amount: {amount}, }} @@ -59,7 +59,7 @@ def transfer(self, amount: int, to_address: str) -> None: print("final_result: ", final_result) result_json = json.loads(final_result) for k, v in result_json["updated_balances"].items(): - self.balances[Address(k)] = v + self.balances[gl.Address(k)] = v @gl.public.view def get_balances(self) -> dict[str, int]: @@ -67,4 +67,4 @@ def get_balances(self) -> dict[str, int]: @gl.public.view def get_balance_of(self, address: str) -> int: - return self.balances.get(Address(address), 0) + return self.balances.get(gl.Address(address), 0) diff --git a/tests/examples/contracts/log_indexer.py b/tests/examples/contracts/log_indexer.py index 44a0945..a3eed4f 100644 --- a/tests/examples/contracts/log_indexer.py +++ b/tests/examples/contracts/log_indexer.py @@ -1,28 +1,28 @@ # v0.1.0 # { # "Seq": [ -# { "Depends": "py-lib-genlayer-embeddings:09h0i209wrzh4xzq86f79c60x0ifs7xcjwl53ysrnw06i54ddxyi" }, +# { "Depends": "py-lib-genlayer-embeddings:latest" }, # { "Depends": "py-genlayer:latest" } # ] # } import numpy as np -from genlayer import * +import genlayer as gl import genlayer_embeddings as gle from dataclasses import dataclass import typing -@allow_storage +@gl.storage.allow @dataclass class StoreValue: - log_id: u256 + log_id: gl.u256 text: str # contract class -class LogIndexer(gl.Contract): +class LogIndexer(gl.contract.Contract): vector_store: gle.VecDB[np.float32, typing.Literal[384], StoreValue] def __init__(self): @@ -53,14 +53,14 @@ def get_closest_vector(self, text: str) -> dict | None: @gl.public.write def add_log(self, log: str, log_id: int) -> None: emb = self.get_embedding(log) - self.vector_store.insert(emb, StoreValue(text=log, log_id=u256(log_id))) + self.vector_store.insert(emb, StoreValue(text=log, log_id=gl.u256(log_id))) @gl.public.write def update_log(self, log_id: int, log: str) -> None: emb = self.get_embedding(log) for elem in self.vector_store.knn(emb, 2): if elem.value.text == log: - elem.value.log_id = u256(log_id) + elem.value.log_id = gl.u256(log_id) @gl.public.write def remove_log(self, id: int) -> None: diff --git a/tests/examples/contracts/multi_file_contract/__init__.py b/tests/examples/contracts/multi_file_contract/__init__.py index b574f5c..a2c75cd 100644 --- a/tests/examples/contracts/multi_file_contract/__init__.py +++ b/tests/examples/contracts/multi_file_contract/__init__.py @@ -1,17 +1,17 @@ -from genlayer import * +import genlayer as gl -class MultiFileContract(gl.Contract): - other_addr: Address +class MultiFileContract(gl.contract.Contract): + other_addr: gl.Address def __init__(self): with open("/contract/other.py", "rt") as f: text = f.read() - self.other_addr = gl.deploy_contract( + self.other_addr = gl.contract.deploy( code=text.encode("utf-8"), args=["123"], - salt_nonce=u256(1), - value=u256(0), + salt_nonce=gl.u256(1), + value=gl.u256(0), on="accepted", ) @@ -21,4 +21,4 @@ def wait(self) -> None: @gl.public.view def test(self) -> str: - return gl.get_contract_at(self.other_addr).view().test() + return gl.contract.get_at(self.other_addr).view().test() diff --git a/tests/examples/contracts/multi_file_contract/other.py b/tests/examples/contracts/multi_file_contract/other.py index 330de34..bc27803 100644 --- a/tests/examples/contracts/multi_file_contract/other.py +++ b/tests/examples/contracts/multi_file_contract/other.py @@ -1,9 +1,9 @@ -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl -class Other(gl.Contract): +class Other(gl.contract.Contract): data: str def __init__(self, data: str): diff --git a/tests/examples/contracts/multi_file_contract/runner.json b/tests/examples/contracts/multi_file_contract/runner.json index c3e448f..f3278ff 100644 --- a/tests/examples/contracts/multi_file_contract/runner.json +++ b/tests/examples/contracts/multi_file_contract/runner.json @@ -1,3 +1,3 @@ { - "Depends": "py-genlayer-multi:test" + "Depends": "py-genlayer-multi:latest" } diff --git a/tests/examples/contracts/multi_read_erc20.py b/tests/examples/contracts/multi_read_erc20.py index 1958302..3bdf346 100644 --- a/tests/examples/contracts/multi_read_erc20.py +++ b/tests/examples/contracts/multi_read_erc20.py @@ -1,11 +1,11 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl -class multi_read_erc20(gl.Contract): - balances: TreeMap[Address, TreeMap[Address, u256]] +class multi_read_erc20(gl.contract.Contract): + balances: gl.TreeMap[gl.Address, gl.TreeMap[gl.Address, gl.u256]] def __init__(self): pass @@ -15,10 +15,10 @@ def update_token_balances( self, account_address: str, token_contracts: list[str] ) -> None: for token_contract in token_contracts: - contract = gl.get_contract_at(Address(token_contract)) + contract = gl.contract.get_at(gl.Address(token_contract)) balance = contract.view().get_balance_of(account_address) - self.balances.get_or_insert_default(Address(account_address))[ - Address(token_contract) + self.balances.get_or_insert_default(gl.Address(account_address))[ + gl.Address(token_contract) ] = balance @gl.public.view diff --git a/tests/examples/contracts/multi_tenant_storage.py b/tests/examples/contracts/multi_tenant_storage.py index 0b3760c..6aa37e9 100644 --- a/tests/examples/contracts/multi_tenant_storage.py +++ b/tests/examples/contracts/multi_tenant_storage.py @@ -1,10 +1,10 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl -class MultiTentantStorage(gl.Contract): +class MultiTentantStorage(gl.contract.Contract): """ Same functionality as UserStorage, but implemented with multiple storage contracts. Each user is assigned to a storage contract, and all storage contracts are managed by this same contract. @@ -12,16 +12,16 @@ class MultiTentantStorage(gl.Contract): This is done to test contract calls between different contracts. """ - all_storage_contracts: DynArray[Address] - available_storage_contracts: DynArray[Address] - mappings: TreeMap[ - Address, Address + all_storage_contracts: gl.DynArray[gl.Address] + available_storage_contracts: gl.DynArray[gl.Address] + mappings: gl.TreeMap[ + gl.Address, gl.Address ] # mapping of user address to storage contract address def __init__(self, storage_contracts: list[str]): for el in storage_contracts: - self.all_storage_contracts.append(Address(el)) - self.available_storage_contracts.append(Address(el)) + self.all_storage_contracts.append(gl.Address(el)) + self.available_storage_contracts.append(gl.Address(el)) @gl.public.view def get_available_contracts(self) -> list[str]: @@ -30,7 +30,7 @@ def get_available_contracts(self) -> list[str]: @gl.public.view def get_all_storages(self) -> dict[str, str]: return { - storage_contract.as_hex: gl.get_contract_at(storage_contract) + storage_contract.as_hex: gl.contract.get_at(storage_contract) .view() .get_storage() for storage_contract in self.all_storage_contracts @@ -46,6 +46,6 @@ def update_storage(self, new_storage: str) -> None: self.available_storage_contracts.pop() contract_to_use = self.mappings[gl.message.sender_address] - gl.get_contract_at(contract_to_use).emit(on="accepted").update_storage( + gl.contract.get_at(contract_to_use).emit(on="accepted").update_storage( new_storage ) diff --git a/tests/examples/contracts/read_erc20.py b/tests/examples/contracts/read_erc20.py index 9703af1..44485d0 100644 --- a/tests/examples/contracts/read_erc20.py +++ b/tests/examples/contracts/read_erc20.py @@ -1,19 +1,19 @@ # v0.1.0 -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl -class read_erc20(gl.Contract): - token_contract: Address +class read_erc20(gl.contract.Contract): + token_contract: gl.Address def __init__(self, token_contract: str): - self.token_contract = Address(token_contract) + self.token_contract = gl.Address(token_contract) @gl.public.view def get_balance_of(self, account_address: str) -> int: return ( - gl.get_contract_at(self.token_contract) + gl.contract.get_at(self.token_contract) .view() .get_balance_of(account_address) ) diff --git a/tests/examples/contracts/simple_time_contract.py b/tests/examples/contracts/simple_time_contract.py index 5248615..cced722 100644 --- a/tests/examples/contracts/simple_time_contract.py +++ b/tests/examples/contracts/simple_time_contract.py @@ -1,15 +1,14 @@ # { # "Seq": [ -# { "Depends": "py-lib-genlayer-embeddings:09h0i209wrzh4xzq86f79c60x0ifs7xcjwl53ysrnw06i54ddxyi" }, -# { "Depends": "py-genlayer:1j12s63yfjpva9ik2xgnffgrs6v44y1f52jvj9w7xvdn7qckd379" } +# { "Depends": "py-genlayer:latest" } # ] # } from datetime import datetime, timezone -from genlayer import * +import genlayer as gl -class SimpleTimeContract(gl.Contract): +class SimpleTimeContract(gl.contract.Contract): """ A simple contract that demonstrates time-based function availability. """ diff --git a/tests/examples/contracts/storage.py b/tests/examples/contracts/storage.py index 98f53ce..6be7bb8 100644 --- a/tests/examples/contracts/storage.py +++ b/tests/examples/contracts/storage.py @@ -1,11 +1,11 @@ # v0.1.0 # { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl # contract class -class Storage(gl.Contract): +class Storage(gl.contract.Contract): storage: str # constructor diff --git a/tests/examples/contracts/user_storage.py b/tests/examples/contracts/user_storage.py index 1babb48..a37a902 100644 --- a/tests/examples/contracts/user_storage.py +++ b/tests/examples/contracts/user_storage.py @@ -1,11 +1,11 @@ # v0.1.0 # { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl -class UserStorage(gl.Contract): - storage: TreeMap[Address, str] +class UserStorage(gl.contract.Contract): + storage: gl.TreeMap[gl.Address, str] # constructor def __init__(self): @@ -18,7 +18,7 @@ def get_complete_storage(self) -> dict[str, str]: @gl.public.view def get_account_storage(self, account_address: str) -> str: - return self.storage[Address(account_address)] + return self.storage[gl.Address(account_address)] @gl.public.write def update_storage(self, new_storage: str) -> None: diff --git a/tests/examples/contracts/wizard_of_coin.py b/tests/examples/contracts/wizard_of_coin.py index 08de5cd..fff3d70 100644 --- a/tests/examples/contracts/wizard_of_coin.py +++ b/tests/examples/contracts/wizard_of_coin.py @@ -1,11 +1,11 @@ # v0.1.0 # { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl import json -class WizardOfCoin(gl.Contract): +class WizardOfCoin(gl.contract.Contract): have_coin: bool def __init__(self, have_coin: bool): diff --git a/tests/examples/contracts/x_username_storage.py b/tests/examples/contracts/x_username_storage.py index 0a77812..74ae26a 100644 --- a/tests/examples/contracts/x_username_storage.py +++ b/tests/examples/contracts/x_username_storage.py @@ -1,18 +1,17 @@ # { # "Seq": [ -# { "Depends": "py-lib-genlayer-embeddings:09h0i209wrzh4xzq86f79c60x0ifs7xcjwl53ysrnw06i54ddxyi" }, -# { "Depends": "py-genlayer:1j12s63yfjpva9ik2xgnffgrs6v44y1f52jvj9w7xvdn7qckd379" } +# { "Depends": "py-genlayer:latest" } # ] # } -from genlayer import * +import genlayer as gl import json import typing import urllib.parse -class XUsernameStorage(gl.Contract): +class XUsernameStorage(gl.contract.Contract): username: str tweet_api_url: str diff --git a/tests/glsim/deterministic_factory_contract.py b/tests/glsim/deterministic_factory_contract.py index d59370c..3b65b96 100644 --- a/tests/glsim/deterministic_factory_contract.py +++ b/tests/glsim/deterministic_factory_contract.py @@ -1,19 +1,18 @@ -import genlayer.gl as gl -from genlayer.py.types import u256 +import genlayer as gl CHILD_CODE = """ -import genlayer.gl as gl +import genlayer as gl -class Child(gl.Contract): +class Child(gl.contract.Contract): @gl.public.view def ping(self) -> str: return "pong" """ -class DeterministicFactory(gl.Contract): +class DeterministicFactory(gl.contract.Contract): child_address: str def __init__(self): @@ -21,11 +20,11 @@ def __init__(self): @gl.public.write def deploy_child(self, salt: int) -> str: - child_address = gl.deploy_contract( + child_address = gl.contract.deploy( code=CHILD_CODE.encode("utf-8"), args=[], kwargs={}, - salt_nonce=u256(salt), + salt_nonce=gl.u256(salt), on="accepted", ) self.child_address = child_address.as_hex diff --git a/tests/glsim/disagree_contract.py b/tests/glsim/disagree_contract.py index 9c514d5..ba4e846 100644 --- a/tests/glsim/disagree_contract.py +++ b/tests/glsim/disagree_contract.py @@ -1,11 +1,11 @@ # v0.1.0 # { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl import json -class DisagreeContract(gl.Contract): +class DisagreeContract(gl.contract.Contract): result: str def __init__(self): diff --git a/tests/glsim/web_contract.py b/tests/glsim/web_contract.py index 668332d..2898f11 100644 --- a/tests/glsim/web_contract.py +++ b/tests/glsim/web_contract.py @@ -1,11 +1,11 @@ # v0.1.0 # { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl import json -class WebContract(gl.Contract): +class WebContract(gl.contract.Contract): result: str def __init__(self): diff --git a/tests/gltest/artifact/contracts/duplicate_ic_contract_1.py b/tests/gltest/artifact/contracts/duplicate_ic_contract_1.py index d4f6bd0..abc0349 100644 --- a/tests/gltest/artifact/contracts/duplicate_ic_contract_1.py +++ b/tests/gltest/artifact/contracts/duplicate_ic_contract_1.py @@ -1,10 +1,10 @@ -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl # contract class -class DuplicateContract(gl.Contract): +class DuplicateContract(gl.contract.Contract): storage: str # constructor diff --git a/tests/gltest/artifact/contracts/duplicate_ic_contract_2.py b/tests/gltest/artifact/contracts/duplicate_ic_contract_2.py index d4f6bd0..abc0349 100644 --- a/tests/gltest/artifact/contracts/duplicate_ic_contract_2.py +++ b/tests/gltest/artifact/contracts/duplicate_ic_contract_2.py @@ -1,10 +1,10 @@ -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl # contract class -class DuplicateContract(gl.Contract): +class DuplicateContract(gl.contract.Contract): storage: str # constructor diff --git a/tests/gltest/artifact/contracts/not_ic_contract.py b/tests/gltest/artifact/contracts/not_ic_contract.py index 3609110..4c114dd 100644 --- a/tests/gltest/artifact/contracts/not_ic_contract.py +++ b/tests/gltest/artifact/contracts/not_ic_contract.py @@ -1,6 +1,6 @@ -# { "Depends": "py-genlayer:test" } +# { "Depends": "py-genlayer:latest" } -from genlayer import * +import genlayer as gl # contract class that is not an IC contract diff --git a/tests/gltest_direct/test_direct_runner.py b/tests/gltest_direct/test_direct_runner.py index 4991210..42f0036 100644 --- a/tests/gltest_direct/test_direct_runner.py +++ b/tests/gltest_direct/test_direct_runner.py @@ -260,8 +260,8 @@ def test_call_contract_forbidden_in_nondet(self, direct_vm, direct_deploy): # Deploy contract to get SDK loaded and run_nondet patched direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") - import genlayer.gl.vm as gl_vm - from genlayer.py import calldata + import genlayer.vm as gl_vm + from genlayer import calldata from gltest.direct import wasi_mock def bad_leader(): @@ -281,8 +281,8 @@ def test_deploy_contract_forbidden_in_nondet(self, direct_vm, direct_deploy): """DeployContract inside run_nondet raises RuntimeError.""" direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") - import genlayer.gl.vm as gl_vm - from genlayer.py import calldata + import genlayer.vm as gl_vm + from genlayer import calldata from gltest.direct import wasi_mock def bad_leader(): @@ -297,8 +297,8 @@ def test_post_message_forbidden_in_nondet(self, direct_vm, direct_deploy): """PostMessage inside run_nondet raises RuntimeError.""" direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") - import genlayer.gl.vm as gl_vm - from genlayer.py import calldata + import genlayer.vm as gl_vm + from genlayer import calldata from gltest.direct import wasi_mock def bad_leader(): @@ -318,8 +318,8 @@ def test_non_cross_contract_ops_allowed_in_nondet(self, direct_vm, direct_deploy """Trace and other non-cross-contract ops work inside run_nondet.""" direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") - import genlayer.gl.vm as gl_vm - from genlayer.py import calldata + import genlayer.vm as gl_vm + from genlayer import calldata from gltest.direct import wasi_mock def good_leader(): @@ -334,7 +334,7 @@ def test_cross_contract_allowed_outside_nondet(self, direct_vm, direct_deploy): """Cross-contract calls outside run_nondet do not raise.""" direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") - from genlayer.py import calldata + from genlayer import calldata from gltest.direct import wasi_mock request = { @@ -351,8 +351,8 @@ def test_flag_cleared_after_nondet_exception(self, direct_vm, direct_deploy): """_in_nondet flag is cleared even when leader_fn raises.""" direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") - import genlayer.gl.vm as gl_vm - from genlayer.py import calldata + import genlayer.vm as gl_vm + from genlayer import calldata from gltest.direct import wasi_mock vm = wasi_mock.get_vm() From 60f850f0dabd06ae019f19bda3634864c0ab7575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 13:07:44 +0100 Subject: [PATCH 02/16] feat(gltest): fee-aware transactions and backend-agnostic success assertions (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gltest): fee-aware transactions and backend-agnostic success assertions - contract.transact_method / factory.deploy accept fees / fee_value / wait_until and thread them verbatim to genlayer-py (SDK resolves defaults; gltest does not re-implement estimation); deprecated status= kwarg dropped in favor of wait_until 'decided'/'finalized' - direct/sim mode ignores fee kwargs gracefully (gasless by design) - tx_execution_succeeded now requires consensus acceptance and recognizes both receipt shapes: Studio (leader_receipt.execution_result == SUCCESS) and testnet (tx_execution_result_name == FINISHED_WITH_RETURN), preferring genlayer_py.transactions.is_successful — an UNDETERMINED tx with a leader FINISHED_WITH_RETURN result is now correctly False - genlayer-py dependency relaxed to <0.20.0 (fee-aware 0.19 line) * ci: run glsim suite; add dev-branch push trigger The glsim suite (71 tests incl. the fee-RPC compatibility layer) was not in CI at all, and tests.yml had no push trigger — merges to v0.30-dev got no post-merge runs. * ci: install the sim extra — glsim's fastapi/starlette deps live there --- .github/workflows/tests.yml | 7 +- gltest/assertions.py | 77 ++++++++++--- gltest/contracts/contract.py | 40 ++++++- gltest/contracts/contract_factory.py | 40 ++++++- gltest/contracts/contract_functions.py | 8 +- gltest/direct/loader.py | 23 ++++ pyproject.toml | 2 +- tests/gltest/assertions/test_assertions.py | 57 +++++++++- tests/gltest/contracts/test_fee_params.py | 105 ++++++++++++++++++ .../gltest_direct/test_contract_deployment.py | 19 ++++ 10 files changed, 348 insertions(+), 30 deletions(-) create mode 100644 tests/gltest/contracts/test_fee_params.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2522a8d..e9af11b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,9 @@ name: Tests on: + push: + branches: + - 'v*-dev' workflow_call: pull_request: types: [opened, reopened, synchronize, ready_for_review] @@ -24,13 +27,15 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Setup | Install dependencies - run: uv sync --python ${{ matrix.python-version }} + run: uv sync --extra sim --python ${{ matrix.python-version }} - name: Action | Run gltest cli tests run: uv run gltest tests/gltest_cli/ - name: Action | Run gltest tests run: uv run gltest tests/gltest/ + - name: Run glsim tests + run: uv run gltest tests/glsim/ # Unit tests only — integration tests here download the GenVM SDK. - name: Action | Run direct runner unit tests diff --git a/gltest/assertions.py b/gltest/assertions.py index d6e1123..14a66bd 100644 --- a/gltest/assertions.py +++ b/gltest/assertions.py @@ -1,32 +1,75 @@ import re -from typing import Optional +from typing import Any, Optional from genlayer_py.types import GenLayerTransaction +try: + from genlayer_py.transactions import is_successful +except ImportError: + is_successful = None + + +ACCEPTED_STATUSES = {"ACCEPTED", "FINALIZED", "5", "7"} +SUCCESS_RESULTS = {"FINISHED_WITH_RETURN", "1"} -def tx_execution_succeeded( - result: GenLayerTransaction, - match_std_out: Optional[str] = None, - match_std_err: Optional[str] = None, -) -> bool: - if "consensus_data" not in result: - return False - if "leader_receipt" not in result["consensus_data"]: - return False - if len(result["consensus_data"]["leader_receipt"]) == 0: - return False - leader_receipt = result["consensus_data"]["leader_receipt"][0] +def _string_value(value: Any) -> Optional[str]: + if value is None: + return None + enum_value = getattr(value, "value", value) + return str(enum_value) - if "execution_result" not in leader_receipt: + +def _has_accepted_status(result: GenLayerTransaction) -> bool: + status = _string_value(result.get("status_name", result.get("status"))) + return status in ACCEPTED_STATUSES + + +def _leader_receipt(result: GenLayerTransaction) -> Optional[dict]: + consensus_data = result.get("consensus_data") + if not isinstance(consensus_data, dict): + return None + leader_receipt = consensus_data.get("leader_receipt") + if isinstance(leader_receipt, dict): + return leader_receipt + if not isinstance(leader_receipt, list) or len(leader_receipt) == 0: + return None + if not isinstance(leader_receipt[0], dict): + return None + return leader_receipt[0] + + +def _has_successful_execution(result: GenLayerTransaction) -> bool: + if not _has_accepted_status(result): return False + if is_successful is not None: + try: + if is_successful(result): + return True + except Exception: + pass + execution_result = _string_value( + result.get("tx_execution_result_name", result.get("tx_execution_result")) + ) + if execution_result in SUCCESS_RESULTS: + return True + leader_receipt = _leader_receipt(result) + return ( + leader_receipt is not None + and leader_receipt.get("execution_result") == "SUCCESS" + ) - execution_result = leader_receipt["execution_result"] - if execution_result != "SUCCESS": +def tx_execution_succeeded( + result: GenLayerTransaction, + match_std_out: Optional[str] = None, + match_std_err: Optional[str] = None, +) -> bool: + if not _has_successful_execution(result): return False if match_std_out is not None or match_std_err is not None: - if "genvm_result" not in leader_receipt: + leader_receipt = _leader_receipt(result) + if leader_receipt is None or "genvm_result" not in leader_receipt: return False genvm_result = leader_receipt["genvm_result"] diff --git a/gltest/contracts/contract.py b/gltest/contracts/contract.py index 0beae04..fd2c7dd 100644 --- a/gltest/contracts/contract.py +++ b/gltest/contracts/contract.py @@ -1,3 +1,4 @@ +import inspect import types from eth_account.signers.local import LocalAccount from dataclasses import dataclass @@ -10,12 +11,36 @@ TransactionContext, ) from genlayer_py.types import SimConfig -from typing import List, Any, Optional, Dict, Callable +from typing import List, Any, Optional, Dict, Callable, Literal from gltest_cli.config.general import get_general_config from .contract_functions import ContractFunction from .stats_collector import StatsCollector, SimulationConfig +def _fees_with_value(fees: Optional[Dict[str, Any]], fee_value: Optional[int]): + if fee_value is None: + return fees + return {**(fees or {}), "feeValue": fee_value} + + +def _fee_kwargs( + call: Callable, + fees: Optional[Dict[str, Any]], + fee_value: Optional[int], +): + if "fee_value" in inspect.signature(call).parameters: + return {"fees": fees, "fee_value": fee_value} + return {"fees": _fees_with_value(fees, fee_value)} + + +def _wait_until_from_status( + status: TransactionStatus, +) -> Literal["decided", "finalized"]: + if status == TransactionStatus.FINALIZED: + return "finalized" + return "decided" + + def read_contract_wrapper( self, method_name: str, @@ -66,6 +91,9 @@ def write_contract_wrapper( def transact_method( value: int = 0, consensus_max_rotations: Optional[int] = None, + fees: Optional[Dict[str, Any]] = None, + fee_value: Optional[int] = None, + wait_until: Optional[Literal["decided", "finalized"]] = None, wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, @@ -109,11 +137,12 @@ def transact_method( consensus_max_rotations=consensus_max_rotations, leader_only=leader_only, args=args, + **_fee_kwargs(client.write_contract, fees, fee_value), sim_config=sim_config, ) receipt = client.wait_for_transaction_receipt( transaction_hash=tx_hash, - status=wait_transaction_status, + wait_until=wait_until or _wait_until_from_status(wait_transaction_status), interval=actual_wait_interval, retries=actual_wait_retries, ) @@ -122,7 +151,9 @@ def transact_method( for triggered_transaction in triggered_transactions: client.wait_for_transaction_receipt( transaction_hash=triggered_transaction, - status=wait_triggered_transactions_status, + wait_until=_wait_until_from_status( + wait_triggered_transactions_status + ), interval=actual_wait_interval, retries=actual_wait_retries, ) @@ -223,6 +254,7 @@ def appeal( tx_hash: str, value: int = 0, wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_until: Optional[Literal["decided", "finalized"]] = None, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, ): @@ -258,7 +290,7 @@ def appeal( ) return client.wait_for_transaction_receipt( transaction_hash=tx_hash, - status=wait_transaction_status, + wait_until=wait_until or _wait_until_from_status(wait_transaction_status), interval=actual_wait_interval, retries=actual_wait_retries, ) diff --git a/gltest/contracts/contract_factory.py b/gltest/contracts/contract_factory.py index 75e8971..d8b530e 100644 --- a/gltest/contracts/contract_factory.py +++ b/gltest/contracts/contract_factory.py @@ -1,5 +1,6 @@ +import inspect from dataclasses import dataclass -from typing import Type, Union, Optional, List, Any +from typing import Type, Union, Optional, List, Any, Dict, Literal from pathlib import Path from eth_typing import ( Address, @@ -26,6 +27,26 @@ from gltest.types import TransactionContext +def _fees_with_value(fees: Optional[Dict[str, Any]], fee_value: Optional[int]): + if fee_value is None: + return fees + return {**(fees or {}), "feeValue": fee_value} + + +def _fee_kwargs(call, fees: Optional[Dict[str, Any]], fee_value: Optional[int]): + if "fee_value" in inspect.signature(call).parameters: + return {"fees": fees, "fee_value": fee_value} + return {"fees": _fees_with_value(fees, fee_value)} + + +def _wait_until_from_status( + status: TransactionStatus, +) -> Literal["decided", "finalized"]: + if status == TransactionStatus.FINALIZED: + return "finalized" + return "decided" + + @dataclass class ContractFactory: """ @@ -110,6 +131,9 @@ def deploy( args: Optional[List[CalldataEncodable]] = None, account: Optional[LocalAccount] = None, consensus_max_rotations: Optional[int] = None, + fees: Optional[Dict[str, Any]] = None, + fee_value: Optional[int] = None, + wait_until: Optional[Literal["decided", "finalized"]] = None, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, @@ -127,6 +151,9 @@ def deploy( args=args, account=account, consensus_max_rotations=consensus_max_rotations, + fees=fees, + fee_value=fee_value, + wait_until=wait_until, wait_interval=wait_interval, wait_retries=wait_retries, wait_transaction_status=wait_transaction_status, @@ -146,6 +173,9 @@ def deploy_contract_tx( args: Optional[List[CalldataEncodable]] = None, account: Optional[LocalAccount] = None, consensus_max_rotations: Optional[int] = None, + fees: Optional[Dict[str, Any]] = None, + fee_value: Optional[int] = None, + wait_until: Optional[Literal["decided", "finalized"]] = None, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, @@ -189,11 +219,13 @@ def deploy_contract_tx( account=account, consensus_max_rotations=consensus_max_rotations, leader_only=leader_only, + **_fee_kwargs(client.deploy_contract, fees, fee_value), sim_config=sim_config, ) tx_receipt = client.wait_for_transaction_receipt( transaction_hash=tx_hash, - status=wait_transaction_status, + wait_until=wait_until + or _wait_until_from_status(wait_transaction_status), interval=actual_wait_interval, retries=actual_wait_retries, ) @@ -202,7 +234,9 @@ def deploy_contract_tx( for triggered_transaction in triggered_transactions: client.wait_for_transaction_receipt( transaction_hash=triggered_transaction, - status=wait_triggered_transactions_status, + wait_until=_wait_until_from_status( + wait_triggered_transactions_status + ), interval=actual_wait_interval, retries=actual_wait_retries, ) diff --git a/gltest/contracts/contract_functions.py b/gltest/contracts/contract_functions.py index 176321e..5965087 100644 --- a/gltest/contracts/contract_functions.py +++ b/gltest/contracts/contract_functions.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, Optional, Dict, Any +from typing import Callable, Optional, Dict, Any, Literal from gltest.types import TransactionStatus, TransactionHashVariant, TransactionContext @@ -28,6 +28,9 @@ def transact( self, value: int = 0, consensus_max_rotations: Optional[int] = None, + fees: Optional[Dict[str, Any]] = None, + fee_value: Optional[int] = None, + wait_until: Optional[Literal["decided", "finalized"]] = None, wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, @@ -41,6 +44,9 @@ def transact( return self.transact_method( value=value, consensus_max_rotations=consensus_max_rotations, + fees=fees, + fee_value=fee_value, + wait_until=wait_until, wait_transaction_status=wait_transaction_status, wait_interval=wait_interval, wait_retries=wait_retries, diff --git a/gltest/direct/loader.py b/gltest/direct/loader.py index 59c0149..09f8c7f 100644 --- a/gltest/direct/loader.py +++ b/gltest/direct/loader.py @@ -27,6 +27,19 @@ import_lazy, ) +_DIRECT_IGNORED_TX_KWARGS = { + "fees", + "fee_value", + "wait_until", + "wait_transaction_status", + "wait_interval", + "wait_retries", + "wait_triggered_transactions", + "wait_triggered_transactions_status", + "transaction_context", + "consensus_max_rotations", +} + def load_contract_class( contract_path: Path, @@ -79,6 +92,7 @@ def deploy_contract( ) -> Any: """Deploy a contract and return an instance.""" contract_path = Path(contract_path).resolve() + kwargs = _drop_direct_transaction_kwargs(kwargs) addr_hash = hashlib.sha256(str(contract_path).encode()).digest()[:20] vm._contract_address = addr_hash @@ -100,6 +114,14 @@ def deploy_contract( return _make_contract_proxy(instance) +def _drop_direct_transaction_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in kwargs.items() + if key not in _DIRECT_IGNORED_TX_KWARGS + } + + def _patch_get_type_hints_for_pep695() -> None: """Patch typing.get_type_hints to handle PEP 695 scoped TypeVars. @@ -437,6 +459,7 @@ def _proxy_getattr(self: Any, name: str) -> Any: if not name.startswith('_') and callable(attr): @functools.wraps(attr) def _wrapped(*args: Any, **kwargs: Any) -> Any: + kwargs = _drop_direct_transaction_kwargs(kwargs) args, kwargs = _calldata_roundtrip_args(args, kwargs) return attr(*args, **kwargs) return _wrapped diff --git a/pyproject.toml b/pyproject.toml index 6938113..bea205d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "pytest", - "genlayer-py>=0.18.0,<0.19.0", + "genlayer-py>=0.18.0,<0.20.0", "colorama>=0.4.6", "pyyaml", "python-dotenv" diff --git a/tests/gltest/assertions/test_assertions.py b/tests/gltest/assertions/test_assertions.py index 7e5002d..9317a3b 100644 --- a/tests/gltest/assertions/test_assertions.py +++ b/tests/gltest/assertions/test_assertions.py @@ -1,16 +1,22 @@ from gltest.assertions import tx_execution_succeeded, tx_execution_failed GENLAYER_SUCCESS_TRANSACTION = { - "consensus_data": {"leader_receipt": [{"execution_result": "SUCCESS"}]} + "status": "ACCEPTED", + "consensus_data": {"leader_receipt": [{"execution_result": "SUCCESS"}]}, } GENLAYER_FAILED_TRANSACTION = { - "consensus_data": {"leader_receipt": [{"execution_result": "ERROR"}]} + "status": "ACCEPTED", + "consensus_data": {"leader_receipt": [{"execution_result": "ERROR"}]}, } -GENLAYER_EMPTY_LEADER_RECEIPT = {"consensus_data": {"leader_receipt": []}} +GENLAYER_EMPTY_LEADER_RECEIPT = { + "status": "ACCEPTED", + "consensus_data": {"leader_receipt": []}, +} GENLAYER_GENVM_TRANSACTION = { + "status": "ACCEPTED", "consensus_data": { "leader_receipt": [ { @@ -25,6 +31,7 @@ } GENLAYER_GENVM_EMPTY_STDERR = { + "status": "ACCEPTED", "consensus_data": { "leader_receipt": [ { @@ -39,6 +46,7 @@ } GENLAYER_GENVM_NO_STDOUT = { + "status": "ACCEPTED", "consensus_data": { "leader_receipt": [ { @@ -50,6 +58,7 @@ } GENLAYER_GENVM_FAILED = { + "status": "ACCEPTED", "consensus_data": { "leader_receipt": [ { @@ -75,6 +84,48 @@ def test_with_successful_transaction(): assert tx_execution_failed(GENLAYER_SUCCESS_TRANSACTION) is False +def test_with_successful_testnet_transaction(): + transaction = { + "status": "ACCEPTED", + "tx_execution_result_name": "FINISHED_WITH_RETURN", + } + + assert tx_execution_succeeded(transaction) is True + assert tx_execution_failed(transaction) is False + + +def test_with_successful_numeric_testnet_transaction(): + transaction = { + "status": 5, + "tx_execution_result": 1, + } + + assert tx_execution_succeeded(transaction) is True + assert tx_execution_failed(transaction) is False + + +def test_undetermined_transaction_is_not_successful(): + transaction = { + "status": "UNDETERMINED", + "tx_execution_result_name": "FINISHED_WITH_RETURN", + "consensus_data": {"leader_receipt": [{"execution_result": "SUCCESS"}]}, + } + + assert tx_execution_succeeded(transaction) is False + assert tx_execution_failed(transaction) is True + + +def test_error_transaction_is_not_successful(): + transaction = { + "status": "ACCEPTED", + "tx_execution_result_name": "FINISHED_WITH_ERROR", + "consensus_data": {"leader_receipt": [{"execution_result": "ERROR"}]}, + } + + assert tx_execution_succeeded(transaction) is False + assert tx_execution_failed(transaction) is True + + def test_with_failed_transaction(): """Test assertion functions with a basic failed transaction. diff --git a/tests/gltest/contracts/test_fee_params.py b/tests/gltest/contracts/test_fee_params.py new file mode 100644 index 0000000..e159487 --- /dev/null +++ b/tests/gltest/contracts/test_fee_params.py @@ -0,0 +1,105 @@ +from gltest.contracts.contract import Contract +from gltest.contracts.contract_factory import ContractFactory + + +class FakeGeneralConfig: + def get_default_wait_interval(self): + return 1 + + def get_default_wait_retries(self): + return 1 + + def get_leader_only(self): + return False + + def check_studio_based_rpc(self): + return False + + +class FakeClient: + def __init__(self): + self.write_contract_calls = [] + self.deploy_contract_calls = [] + self.wait_for_transaction_receipt_calls = [] + + def write_contract(self, **kwargs): + self.write_contract_calls.append(kwargs) + return "0xwrite" + + def deploy_contract(self, **kwargs): + self.deploy_contract_calls.append(kwargs) + return "0xdeploy" + + def wait_for_transaction_receipt(self, **kwargs): + self.wait_for_transaction_receipt_calls.append(kwargs) + return { + "status": "ACCEPTED", + "consensus_data": {"leader_receipt": [{"execution_result": "SUCCESS"}]}, + } + + +def test_transact_threads_fee_params_to_sdk(monkeypatch): + client = FakeClient() + monkeypatch.setattr("gltest.contracts.contract.get_gl_client", lambda: client) + monkeypatch.setattr( + "gltest.contracts.contract.get_general_config", lambda: FakeGeneralConfig() + ) + contract = Contract.new( + address="0x123", + schema={"methods": {"set_value": {"readonly": False}}}, + ) + fees = { + "distribution": {"leaderTimeunitsAllocation": 1}, + "messageAllocations": [], + } + + receipt = contract.set_value([1]).transact( + fees=fees, + fee_value=123, + wait_until="finalized", + ) + + assert receipt["status"] == "ACCEPTED" + assert client.write_contract_calls[0]["fees"] == { + **fees, + "feeValue": 123, + } + assert client.wait_for_transaction_receipt_calls[0] == { + "transaction_hash": "0xwrite", + "wait_until": "finalized", + "interval": 1, + "retries": 1, + } + + +def test_deploy_threads_fee_params_to_sdk(monkeypatch): + client = FakeClient() + monkeypatch.setattr("gltest.contracts.contract_factory.get_gl_client", lambda: client) + monkeypatch.setattr( + "gltest.contracts.contract_factory.get_general_config", + lambda: FakeGeneralConfig(), + ) + factory = ContractFactory(contract_name="Example", contract_code="class Example: pass") + fees = { + "distribution": {"leaderTimeunitsAllocation": 1}, + "messageAllocations": [], + "feeValue": 100, + } + + receipt = factory.deploy_contract_tx( + args=["hello"], + fees=fees, + fee_value=250, + ) + + assert receipt["status"] == "ACCEPTED" + assert client.deploy_contract_calls[0]["fees"] == { + **fees, + "feeValue": 250, + } + assert client.wait_for_transaction_receipt_calls[0] == { + "transaction_hash": "0xdeploy", + "wait_until": "decided", + "interval": 1, + "retries": 1, + } diff --git a/tests/gltest_direct/test_contract_deployment.py b/tests/gltest_direct/test_contract_deployment.py index fb4e31f..0ca00a3 100644 --- a/tests/gltest_direct/test_contract_deployment.py +++ b/tests/gltest_direct/test_contract_deployment.py @@ -28,6 +28,25 @@ def test_deploy_storage_contract(self, direct_vm, direct_deploy): storage.update_storage("new value") assert storage.get_storage() == "new value" + def test_direct_mode_ignores_fee_kwargs(self, direct_vm, direct_deploy): + """Fee-aware dev-env tests do not crash in gasless direct mode.""" + fees = { + "distribution": {"leaderTimeunitsAllocation": 1}, + "messageAllocations": [], + "feeValue": 10, + } + storage = direct_deploy( + str(CONTRACTS_DIR / "storage.py"), + "initial value", + fees=fees, + fee_value=10, + wait_until="decided", + ) + + storage.update_storage("new value", fees=fees, fee_value=10) + + assert storage.get_storage() == "new value" + def test_deploy_user_storage_with_sender(self, direct_vm, direct_deploy): """UserStorage respects gl.message.sender_address.""" user_storage = direct_deploy(str(CONTRACTS_DIR / "user_storage.py")) From 343e3a358f9e235a93b49c60721ce7676585ff07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 15:45:52 +0100 Subject: [PATCH 03/16] ci: keep main forwarded to active dev branch (#93) --- .github/scripts/validate-branch-policy.sh | 107 ++++++++++++++++++++++ .github/workflows/branch-policy.yml | 24 +++++ .github/workflows/fast-forward-main.yaml | 57 ++++++++++++ .github/workflows/retarget-main-prs.yaml | 53 +++++++++++ support/ci/ACTIVE_DEV_BRANCH | 1 + 5 files changed, 242 insertions(+) create mode 100755 .github/scripts/validate-branch-policy.sh create mode 100644 .github/workflows/branch-policy.yml create mode 100644 .github/workflows/fast-forward-main.yaml create mode 100644 .github/workflows/retarget-main-prs.yaml create mode 100644 support/ci/ACTIVE_DEV_BRANCH diff --git a/.github/scripts/validate-branch-policy.sh b/.github/scripts/validate-branch-policy.sh new file mode 100755 index 0000000..0fb7ee2 --- /dev/null +++ b/.github/scripts/validate-branch-policy.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +failed=0 + +error() { + echo "::error::$*" + failed=1 +} + +warning() { + echo "::warning::$*" +} + +active_branch_file="support/ci/ACTIVE_DEV_BRANCH" +if [[ ! -f "${active_branch_file}" ]]; then + error "${active_branch_file} is required." + active_branch="" +else + active_branch="$(tr -d '[:space:]' < "${active_branch_file}")" +fi + +if [[ -z "${active_branch}" ]]; then + error "${active_branch_file} must not be empty." +elif [[ "${active_branch}" == "main" ]]; then + error "${active_branch_file} must point to a dev branch, not main." +elif [[ "${active_branch}" != *-dev ]]; then + warning "${active_branch_file} should normally point to a -dev branch; got ${active_branch}." +fi + +release_branch="${active_branch%-dev}" +default_branch="${GITHUB_DEFAULT_BRANCH:-}" +event_name="${GITHUB_EVENT_NAME:-local}" +base_ref="${GITHUB_BASE_REF:-}" +head_ref="${GITHUB_HEAD_REF:-}" +ref_name="${GITHUB_REF_NAME:-}" +actor="${GITHUB_ACTOR:-}" + +if [[ -n "${default_branch}" && "${default_branch}" != "main" ]]; then + warning "Repository default branch should be main after branch-policy rollout; currently ${default_branch}." +fi + +if [[ -n "${base_ref}" && "${base_ref}" == "main" ]]; then + warning "PR targets main; retarget-main-prs should move it to ${active_branch}." +fi + +if [[ -n "${base_ref}" && -n "${active_branch}" ]]; then + if [[ "${base_ref}" == "${release_branch}" && "${head_ref}" != "${active_branch}" && "${ALLOW_DIRECT_RELEASE_PR:-false}" != "true" ]]; then + error "PRs into ${release_branch} must come from ${active_branch}. Merge feature work into ${active_branch}, then promote ${active_branch} -> ${release_branch}." + fi +fi + +if [[ "${event_name}" == "push" && "${ref_name}" == "main" ]]; then + case "${actor}" in + github-actions[bot]|ci-core-e2e-runner[bot]) + ;; + *) + error "main should only move by automation from ${active_branch}; direct push actor was ${actor:-unknown}." + ;; + esac +fi + +if [[ ! -f ".github/workflows/fast-forward-main.yaml" ]]; then + error ".github/workflows/fast-forward-main.yaml is required." +fi + +if [[ ! -f ".github/workflows/retarget-main-prs.yaml" ]]; then + error ".github/workflows/retarget-main-prs.yaml is required." +fi + +if [[ -f ".github/workflows/release-from-main.yml" ]]; then + error ".github/workflows/release-from-main.yml is forbidden. Releases must be tag/version-branch driven." +fi + +if [[ -f "release.config.js" ]]; then + error "release.config.js is forbidden in versioned tooling branches; semantic-release-on-main must not be restored." +fi + +if [[ -f ".github/workflows/release-from-tag.yml" ]]; then + if ! grep -Fq 'v*.*.*' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must trigger only from version tags matching v*.*.*." + fi + if ! grep -Fq 'refs/remotes/origin/${version_branch}' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'tag_commit' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'branch_head' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must verify the tag commit is the current matching version branch head." + fi +fi + +if [[ -f ".github/workflows/manual-docker-release.yml" ]]; then + if ! grep -Fq 'expected_branch=' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must derive and enforce the expected version branch from the tag." + fi + if ! grep -Fq './.github/workflows/release-from-tag.yml' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must delegate image promotion to release-from-tag.yml." + fi +fi + +if [[ "${failed}" -ne 0 ]]; then + exit 1 +fi + +if [[ -n "${base_ref}" ]]; then + echo "Branch policy ok for PR ${head_ref} -> ${base_ref}; active dev branch is ${active_branch}." +else + echo "Branch policy ok for ${event_name} on ${ref_name:-detached ref}; active dev branch is ${active_branch}." +fi diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 0000000..7759870 --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,24 @@ +name: Branch Policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + push: + branches: + - "**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + branch-policy: + name: Validate branch policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate branch policy + env: + GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: ./.github/scripts/validate-branch-policy.sh diff --git a/.github/workflows/fast-forward-main.yaml b/.github/workflows/fast-forward-main.yaml new file mode 100644 index 0000000..688e997 --- /dev/null +++ b/.github/workflows/fast-forward-main.yaml @@ -0,0 +1,57 @@ +name: Fast-forward main + +# main is the static/default branch for GitHub UX and tools that assume a +# stable default branch. It is not the integration target. On each push to the +# configured active dev branch, fast-forward main to that commit. + +on: + push: + branches: ["**"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: fast-forward-main-${{ github.repository }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + fast-forward: + if: github.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fast-forward main to active dev branch + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + if [[ "${GITHUB_REF_NAME}" != "${active_branch}" ]]; then + echo "Push was to ${GITHUB_REF_NAME}; active dev branch is ${active_branch}. Nothing to do." + exit 0 + fi + + if git ls-remote --exit-code --heads origin main >/dev/null 2>&1; then + git fetch origin main + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "::error::main has diverged from ${active_branch}; refusing non-fast-forward update" + exit 1 + fi + else + echo "main does not exist yet; creating it at ${GITHUB_SHA}." + fi + + git push origin "HEAD:refs/heads/main" diff --git a/.github/workflows/retarget-main-prs.yaml b/.github/workflows/retarget-main-prs.yaml new file mode 100644 index 0000000..37a066f --- /dev/null +++ b/.github/workflows/retarget-main-prs.yaml @@ -0,0 +1,53 @@ +name: Retarget main PRs + +# main is a static/default alias of the active dev branch. Contributions should +# target the active dev branch directly; PRs opened against main are retargeted +# automatically so required checks and release-train rules run in the right +# branch context. +# +# pull_request_target is used for the write-scoped token. This workflow never +# checks out or executes PR head code; it reads only trusted base-branch files. + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +defaults: + run: + shell: bash + +jobs: + retarget: + if: github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - name: Retarget PR to active dev branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --base "${active_branch}" + + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body "$(cat < Date: Thu, 11 Jun 2026 17:09:06 +0100 Subject: [PATCH 04/16] docs: add branching guide (#95) --- CONTRIBUTING.md | 20 +++++----------- docs/BRANCHING.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 docs/BRANCHING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c265477..d81e6cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,19 +29,11 @@ Have ideas for new features or use cases? We're eager to hear them! But first: ## Branch model -This repo uses a branch-per-major release model. There is no `main`. - -- **`v0.29`** — current stable major (semver-zero, so 0.29 IS the major; 0.30 would be a major bump that gets its own branch). -- **`v-dev`** — when next-major work is in progress. -- Default branch on github.com is the current stable. - -If you have a `main` branch from a previous checkout: - -```sh -git checkout v0.29 -git branch -D main -git remote prune origin -``` +See [docs/BRANCHING.md](docs/BRANCHING.md) for the current release-train model. +In short: independently releasable work may target the stable branch directly; +multi-feature or cross-repo train work uses the active `*-dev` integration +branch and is promoted to the matching stable branch when ready. `main` is only +the default/static GitHub branch. ## Releases @@ -161,7 +153,7 @@ The project uses automated semantic versioning based on commit messages: | `feat!:`, `fix!:`, or `BREAKING CHANGE:` | **Major** version bump | 1.0.0 → 2.0.0 | | `docs:`, `style:`, `refactor:`, `test:`, `chore:`, `build:`, `ci:` | **No** version bump | Version stays the same | -**Important**: Never manually edit version numbers in `pyproject.toml` or other files. The release automation will handle all version updates automatically when PRs are merged to the main branch. +**Important**: Never manually edit version numbers in `pyproject.toml` or other files. Releases are cut from the stable branch using the release automation described above. ### Improving Documentation diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md new file mode 100644 index 0000000..3d5ba44 --- /dev/null +++ b/docs/BRANCHING.md @@ -0,0 +1,58 @@ +# Branching and Release Trains + +This repo follows the GenLayer release-train model. + +## Current Train + +- Current stable branch: `v0.29` +- Active integration branch: `v0.30-dev` +- Next stable target: `v0.30` +- `main`: default/static branch alias for the active integration branch + +## Stable Branches + +Stable branches are long-lived release lines. For semver-zero packages, each +minor line is treated as the release line, for example `v0.29` or `v0.30`. + +PRs may target a stable branch directly when the merged result should be +releasable immediately. This is appropriate for bug fixes, small non-breaking +features, isolated release fixes, or a breaking change that is intentionally +shipping as the next version by itself. + +Stable branches must remain releasable. PRs into stable branches are expected to +pass the required cross-repo `E2E Tests` gate before merge. + +## Integration Branches + +Integration branches are optional. Use one when multiple changes need to +accumulate before release, especially for cross-repo work, dependent features, +breaking changes that must ship together, or a train that needs advisory E2E +while still expected to be red. + +Integration branches are named after the target stable branch plus `-dev`, for +example `v0.30-dev`. Feature PRs for that train target the integration branch. + +PRs into integration branches may run `E2E Tests` as advisory checks. They are +not the release gate. + +## Promotion and Release + +When an integration train is ready, open a promotion PR from the integration +branch to the matching stable branch, for example `v0.30-dev` to `v0.30`. + +That promotion PR is the release-readiness gate and must pass required +cross-repo `E2E Tests`. The actual package release is cut from the stable branch +using a version tag after the stable branch is ready. + +## `main` + +`main` exists for GitHub UX and tools that require a stable default branch. It is +not a release branch and it is not the integration target. + +This repo keeps `main` forwarded to the active integration branch using +automation. PRs opened against `main` are automatically retargeted to the branch +listed in `support/ci/ACTIVE_DEV_BRANCH`. + +When changing the active integration branch, update +`support/ci/ACTIVE_DEV_BRANCH`, the repo docs, and the corresponding +`genlayer-e2e` release-train matrix in the same change set. From 9da7c61d912ed7490e01906dfaaf188f14eadb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 11 Jun 2026 22:44:57 +0100 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20--fee-profile=20reporter=20?= =?UTF-8?q?=E2=80=94=20measure=20per-method=20fees=20for=20developer=20sug?= =?UTF-8?q?gestions=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in pytest options --fee-profile=PATH and --fee-profile-headroom (default 1.25). Every deploy/write executed through gltest records the consumed fee data from Studio receipts (fees.consumed.executionConsumed / messageFeesConsumed); at session end PATH is written as a JSON fee profile (max observed x headroom, decimal strings) that frontends pass verbatim as 'suggestions' to createTransactionKit from @genlayer/transaction-kit. Time-unit allocations are omitted: the backend reports consumed fee amounts only, not time-unit usage. Testnet receipts expose no consumed-fee data yet, so profiles measured there come out empty (warned at session end). Also catches uv.lock up with the genlayer-py<0.20.0 pin relax from #91. --- README.md | 41 +++++ gltest/contracts/contract.py | 4 + gltest/contracts/contract_factory.py | 2 + gltest/fees/__init__.py | 15 ++ gltest/fees/profile.py | 126 ++++++++++++++++ gltest_cli/config/constants.py | 2 +- gltest_cli/config/plugin.py | 52 +++++++ gltest_cli/config/types.py | 11 ++ tests/gltest/fees/__init__.py | 1 + tests/gltest/fees/test_fee_profile.py | 201 +++++++++++++++++++++++++ tests/gltest_cli/config/test_plugin.py | 162 +++++++++++--------- uv.lock | 2 +- 12 files changed, 549 insertions(+), 70 deletions(-) create mode 100644 gltest/fees/__init__.py create mode 100644 gltest/fees/profile.py create mode 100644 tests/gltest/fees/__init__.py create mode 100644 tests/gltest/fees/test_fee_profile.py diff --git a/README.md b/README.md index 75b166f..1e01eb3 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,47 @@ tx_receipt = contract.update_storage(args=["new_value"]).transact( assert tx_execution_succeeded(tx_receipt) ``` +### Fee Profiling + +Generate a frontend-ready fee profile from the deploys and write transactions +executed during a gltest session: + +```bash +gltest --fee-profile artifacts/fee-profile.json +gltest --fee-profile artifacts/fee-profile.json --fee-profile-headroom 1.5 +``` + +`--fee-profile` writes JSON that can be passed verbatim as `suggestions` to +`createTransactionKit` from `@genlayer/transaction-kit`. The optional +`--fee-profile-headroom` multiplier defaults to `1.25`. Each value is the +maximum observed consumed fee across the session multiplied by headroom, rounded +up, and emitted as a decimal string. + +```json +{ + "version": 1, + "network": "localnet", + "measuredAt": "2026-06-10T12:00:00Z", + "deploy": { + "executionBudgetPerRound": "625000", + "totalMessageFees": "0" + }, + "methods": { + "create_bet": { + "executionBudgetPerRound": "312500", + "totalMessageFees": "12500" + } + } +} +``` + +Fee profiling is currently measurable on Studio-based networks whose receipts +include consumed fee data. Testnet receipts do not expose consumed fees yet, and +direct/sim mode does not go through these receipt paths. Time-unit allocations +are not measurable from backend receipts, so `leaderTimeunitsAllocation` and +`validatorTimeunitsAllocation` are intentionally omitted; downstream presets or +network defaults should supply them. + ### Assertions ```python diff --git a/gltest/contracts/contract.py b/gltest/contracts/contract.py index fd2c7dd..90d6041 100644 --- a/gltest/contracts/contract.py +++ b/gltest/contracts/contract.py @@ -13,6 +13,7 @@ from genlayer_py.types import SimConfig from typing import List, Any, Optional, Dict, Callable, Literal from gltest_cli.config.general import get_general_config +from gltest.fees import maybe_record_fee_observation from .contract_functions import ContractFunction from .stats_collector import StatsCollector, SimulationConfig @@ -146,6 +147,9 @@ def transact_method( interval=actual_wait_interval, retries=actual_wait_retries, ) + maybe_record_fee_observation( + kind="method", method_name=method_name, receipt=receipt + ) if wait_triggered_transactions: triggered_transactions = receipt.get("triggered_transactions", []) for triggered_transaction in triggered_transactions: diff --git a/gltest/contracts/contract_factory.py b/gltest/contracts/contract_factory.py index d8b530e..fa818f0 100644 --- a/gltest/contracts/contract_factory.py +++ b/gltest/contracts/contract_factory.py @@ -23,6 +23,7 @@ from gltest.assertions import tx_execution_failed from gltest.exceptions import DeploymentError from gltest_cli.config.general import get_general_config +from gltest.fees import maybe_record_fee_observation from gltest.utils import extract_contract_address from gltest.types import TransactionContext @@ -229,6 +230,7 @@ def deploy_contract_tx( interval=actual_wait_interval, retries=actual_wait_retries, ) + maybe_record_fee_observation(kind="deploy", receipt=tx_receipt) if wait_triggered_transactions: triggered_transactions = tx_receipt.get("triggered_transactions", []) for triggered_transaction in triggered_transactions: diff --git a/gltest/fees/__init__.py b/gltest/fees/__init__.py new file mode 100644 index 0000000..ca8bed7 --- /dev/null +++ b/gltest/fees/__init__.py @@ -0,0 +1,15 @@ +from .profile import ( + FeeProfileCollector, + fee_profile_enabled, + get_fee_profile_collector, + maybe_record_fee_observation, + reset_fee_profile_collector, +) + +__all__ = [ + "FeeProfileCollector", + "fee_profile_enabled", + "get_fee_profile_collector", + "maybe_record_fee_observation", + "reset_fee_profile_collector", +] diff --git a/gltest/fees/profile.py b/gltest/fees/profile.py new file mode 100644 index 0000000..d7c2b1d --- /dev/null +++ b/gltest/fees/profile.py @@ -0,0 +1,126 @@ +import json +import math +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path +from typing import Any, Dict, Optional + +from gltest.logging import logger +from gltest_cli.config.general import get_general_config + +FEE_KEYS = ("executionBudgetPerRound", "totalMessageFees") +CONSUMED_KEY_MAPPING = { + "executionBudgetPerRound": "executionConsumed", + "totalMessageFees": "messageFeesConsumed", +} + + +class FeeProfileCollector: + def __init__(self): + self._deploy: Dict[str, int] = {} + self._methods: Dict[str, Dict[str, int]] = {} + self._warned_malformed = False + + def record_deploy(self, receipt: Dict[str, Any]) -> None: + observation = self._extract_observation(receipt) + if observation is not None: + self._record_max(self._deploy, observation) + + def record_method(self, method_name: str, receipt: Dict[str, Any]) -> None: + observation = self._extract_observation(receipt) + if observation is not None: + method_values = self._methods.setdefault(method_name, {}) + self._record_max(method_values, observation) + + def build_profile(self, network: str, headroom: float) -> Dict[str, Any]: + profile: Dict[str, Any] = { + "version": 1, + "network": network, + "measuredAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + if self._deploy: + profile["deploy"] = self._apply_headroom(self._deploy, headroom) + profile["methods"] = { + method_name: self._apply_headroom(values, headroom) + for method_name, values in sorted(self._methods.items()) + } + return profile + + def write(self, path: Path, network: str, headroom: float) -> Dict[str, Any]: + profile = self.build_profile(network=network, headroom=headroom) + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(profile, indent=2) + "\n", encoding="utf-8") + return profile + + def has_observations(self) -> bool: + return bool(self._deploy or self._methods) + + def _extract_observation( + self, receipt: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, int]]: + try: + if not receipt: + return None + fees = receipt.get("fees") + if not fees: + return None + consumed = fees.get("consumed") + if not consumed: + return None + return { + output_key: int(consumed[consumed_key]) + for output_key, consumed_key in CONSUMED_KEY_MAPPING.items() + } + except Exception as e: + if not self._warned_malformed: + logger.warning("Failed to record fee profile observation: %s", e) + self._warned_malformed = True + return None + + @staticmethod + def _record_max(current: Dict[str, int], observation: Dict[str, int]) -> None: + for key in FEE_KEYS: + current[key] = max(current.get(key, 0), observation[key]) + + @staticmethod + def _apply_headroom(values: Dict[str, int], headroom: float) -> Dict[str, str]: + multiplier = Decimal(str(headroom)) + # The backend only reports consumed fee amounts, not consumed time-unit + # allocations, so leader/validator allocation suggestions are omitted. + return { + key: str(math.ceil(Decimal(value) * multiplier)) + for key, value in values.items() + } + + +_fee_profile_collector = FeeProfileCollector() + + +def get_fee_profile_collector() -> FeeProfileCollector: + return _fee_profile_collector + + +def reset_fee_profile_collector() -> FeeProfileCollector: + global _fee_profile_collector + _fee_profile_collector = FeeProfileCollector() + return _fee_profile_collector + + +def fee_profile_enabled() -> bool: + return get_general_config().get_fee_profile_path() is not None + + +def maybe_record_fee_observation( + kind: str, receipt: Dict[str, Any], method_name: Optional[str] = None +) -> None: + try: + if not fee_profile_enabled(): + return + collector = get_fee_profile_collector() + if kind == "deploy": + collector.record_deploy(receipt) + elif method_name is not None: + collector.record_method(method_name, receipt) + except Exception as e: + logger.warning("Failed to record fee profile observation: %s", e) diff --git a/gltest_cli/config/constants.py b/gltest_cli/config/constants.py index d047b76..8316567 100644 --- a/gltest_cli/config/constants.py +++ b/gltest_cli/config/constants.py @@ -1,7 +1,6 @@ from genlayer_py.chains.localnet import SIMULATOR_JSON_RPC_URL from pathlib import Path - GLTEST_CONFIG_FILE = "gltest.config.yaml" DEFAULT_NETWORK = "localnet" PRECONFIGURED_NETWORKS = ["localnet", "studionet", "testnet_asimov", "testnet_bradbury"] @@ -16,3 +15,4 @@ DEFAULT_WAIT_INTERVAL = 3000 DEFAULT_WAIT_RETRIES = 50 DEFAULT_LEADER_ONLY = False +DEFAULT_FEE_PROFILE_HEADROOM = 1.25 diff --git a/gltest_cli/config/plugin.py b/gltest_cli/config/plugin.py index c723246..90d2f6a 100644 --- a/gltest_cli/config/plugin.py +++ b/gltest_cli/config/plugin.py @@ -16,6 +16,7 @@ DEFAULT_LEADER_ONLY, CHAINS, ) +from gltest.fees import get_fee_profile_collector, reset_fee_profile_collector def pytest_addoption(parser): @@ -75,6 +76,18 @@ def pytest_addoption(parser): default=None, help=f"Chain type (possible values: {', '.join(CHAINS)})", ) + group.addoption( + "--fee-profile", + action="store", + default=None, + help="Path to write a JSON fee profile for observed deploys and writes", + ) + group.addoption( + "--fee-profile-headroom", + action="store", + default=None, + help="Multiplier applied to observed fee maxima in --fee-profile output", + ) def pytest_configure(config): @@ -112,6 +125,8 @@ def pytest_configure(config): network = config.getoption("--network") leader_only = config.getoption("--leader-only") chain_type = config.getoption("--chain-type") + fee_profile = config.getoption("--fee-profile") + fee_profile_headroom = config.getoption("--fee-profile-headroom") plugin_config = PluginConfig() plugin_config.contracts_dir = ( @@ -130,6 +145,14 @@ def pytest_configure(config): plugin_config.network_name = network plugin_config.leader_only = leader_only plugin_config.chain_type = chain_type + plugin_config.fee_profile_path = ( + Path(fee_profile) if fee_profile is not None else None + ) + if fee_profile_headroom is not None: + parsed_headroom = float(fee_profile_headroom) + if parsed_headroom <= 0: + raise ValueError("--fee-profile-headroom must be greater than 0") + plugin_config.fee_profile_headroom = parsed_headroom general_config.plugin_config = plugin_config except Exception as e: @@ -139,6 +162,7 @@ def pytest_configure(config): def pytest_sessionstart(session): try: + reset_fee_profile_collector() general_config = get_general_config() artifacts_dir = general_config.get_artifacts_dir() if artifacts_dir and artifacts_dir.exists(): @@ -167,6 +191,10 @@ def pytest_sessionstart(session): logger.info( f" Default wait retries: {general_config.get_default_wait_retries()}" ) + if general_config.get_fee_profile_path() is not None: + logger.info( + f" Fee profile output: {general_config.get_fee_profile_path()}" + ) if ( general_config.get_leader_only() @@ -182,6 +210,30 @@ def pytest_sessionstart(session): pytest.exit("gltest session start error") +def pytest_sessionfinish(session, exitstatus): + try: + general_config = get_general_config() + fee_profile_path = general_config.get_fee_profile_path() + if fee_profile_path is None: + return + + collector = get_fee_profile_collector() + profile = collector.write( + path=fee_profile_path, + network=general_config.get_network_name(), + headroom=general_config.get_fee_profile_headroom(), + ) + logger.info(f"Wrote fee profile to {fee_profile_path}") + if not collector.has_observations(): + logger.warning( + "Fee profile is empty; this backend may not expose consumed fee data on receipts yet" + ) + elif "deploy" not in profile and not profile["methods"]: + logger.warning("Fee profile contains no deploy or method observations") + except Exception as e: + logger.error(f"Failed to write fee profile: {e}") + + def pytest_runtest_setup(item): _pytest_context.current_item = item diff --git a/gltest_cli/config/types.py b/gltest_cli/config/types.py index 3f414a6..9438b0d 100644 --- a/gltest_cli/config/types.py +++ b/gltest_cli/config/types.py @@ -8,6 +8,7 @@ DEFAULT_WAIT_INTERVAL, DEFAULT_WAIT_RETRIES, DEFAULT_LEADER_ONLY, + DEFAULT_FEE_PROFILE_HEADROOM, CHAINS, ) @@ -22,6 +23,8 @@ class PluginConfig: network_name: Optional[str] = None leader_only: bool = False chain_type: Optional[str] = None + fee_profile_path: Optional[Path] = None + fee_profile_headroom: Optional[float] = None @dataclass @@ -228,6 +231,14 @@ def get_leader_only(self) -> bool: return network_config.leader_only return DEFAULT_LEADER_ONLY + def get_fee_profile_path(self) -> Optional[Path]: + return self.plugin_config.fee_profile_path + + def get_fee_profile_headroom(self) -> float: + if self.plugin_config.fee_profile_headroom is not None: + return self.plugin_config.fee_profile_headroom + return DEFAULT_FEE_PROFILE_HEADROOM + def check_local_rpc(self) -> bool: return self.get_chain_type() == "localnet" diff --git a/tests/gltest/fees/__init__.py b/tests/gltest/fees/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/gltest/fees/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/gltest/fees/test_fee_profile.py b/tests/gltest/fees/test_fee_profile.py new file mode 100644 index 0000000..325794d --- /dev/null +++ b/tests/gltest/fees/test_fee_profile.py @@ -0,0 +1,201 @@ +import json + +import pytest + +from gltest.contracts.contract import Contract +from gltest.contracts.contract_factory import ContractFactory +from gltest.fees import ( + FeeProfileCollector, + get_fee_profile_collector, + reset_fee_profile_collector, +) + + +def fee_receipt(execution_consumed, message_fees_consumed): + return { + "status": "ACCEPTED", + "fees": { + "consumed": { + "executionConsumed": str(execution_consumed), + "messageFeesConsumed": str(message_fees_consumed), + "storageFeeUsed": "999999", + }, + "distribution": { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", + "executionBudgetPerRound": "1", + "totalMessageFees": "1", + }, + }, + } + + +class FakeGeneralConfig: + def __init__(self, fee_profile_path=None): + self.fee_profile_path = fee_profile_path + + def get_default_wait_interval(self): + return 1 + + def get_default_wait_retries(self): + return 1 + + def get_leader_only(self): + return False + + def check_studio_based_rpc(self): + return False + + def get_fee_profile_path(self): + return self.fee_profile_path + + +class FakeClient: + def __init__(self, receipt): + self.receipt = receipt + self.write_contract_calls = [] + self.deploy_contract_calls = [] + self.wait_for_transaction_receipt_calls = [] + + def write_contract(self, **kwargs): + self.write_contract_calls.append(kwargs) + return "0xwrite" + + def deploy_contract(self, **kwargs): + self.deploy_contract_calls.append(kwargs) + return "0xdeploy" + + def wait_for_transaction_receipt(self, **kwargs): + self.wait_for_transaction_receipt_calls.append(kwargs) + return self.receipt + + +@pytest.fixture(autouse=True) +def reset_collector(): + reset_fee_profile_collector() + yield + reset_fee_profile_collector() + + +def test_collector_records_max_and_applies_headroom_with_big_int(): + collector = FeeProfileCollector() + big_value = 10**20 + collector.record_deploy(fee_receipt(100, 0)) + collector.record_deploy(fee_receipt(101, 10)) + collector.record_method("create_bet", fee_receipt(big_value, 0)) + + profile = collector.build_profile(network="localnet", headroom=1.25) + + assert profile["version"] == 1 + assert profile["network"] == "localnet" + assert profile["deploy"] == { + "executionBudgetPerRound": "127", + "totalMessageFees": "13", + } + assert profile["methods"]["create_bet"] == { + "executionBudgetPerRound": "125000000000000000000", + "totalMessageFees": "0", + } + + +def test_receipts_without_fee_data_are_ignored(): + collector = FeeProfileCollector() + collector.record_deploy({"status": "ACCEPTED"}) + collector.record_method("create_bet", {"fees": None}) + + profile = collector.build_profile(network="localnet", headroom=1.25) + + assert "deploy" not in profile + assert profile["methods"] == {} + + +def test_message_fee_zero_is_recorded(): + collector = FeeProfileCollector() + collector.record_method("create_bet", fee_receipt(10, 0)) + + profile = collector.build_profile(network="localnet", headroom=1.0) + + assert profile["methods"]["create_bet"]["totalMessageFees"] == "0" + + +def test_profile_shape_omits_deploy_when_unobserved_and_time_unit_keys(): + collector = FeeProfileCollector() + collector.record_method("create_bet", fee_receipt(10, 5)) + + profile = collector.build_profile(network="localnet", headroom=1.0) + + assert set(profile) == {"version", "network", "measuredAt", "methods"} + assert profile["methods"] == { + "create_bet": { + "executionBudgetPerRound": "10", + "totalMessageFees": "5", + } + } + assert "leaderTimeunitsAllocation" not in profile["methods"]["create_bet"] + assert "validatorTimeunitsAllocation" not in profile["methods"]["create_bet"] + + +def test_write_creates_parent_dirs_and_round_trips_json(tmp_path): + collector = FeeProfileCollector() + collector.record_deploy(fee_receipt(10, 0)) + output_path = tmp_path / "profiles" / "fees.json" + + profile = collector.write(output_path, network="localnet", headroom=1.25) + + assert output_path.exists() + assert json.loads(output_path.read_text(encoding="utf-8")) == profile + + +def test_transact_records_fee_profile_observation(monkeypatch, tmp_path): + client = FakeClient(fee_receipt(312500, 12500)) + monkeypatch.setattr("gltest.contracts.contract.get_gl_client", lambda: client) + monkeypatch.setattr( + "gltest.contracts.contract.get_general_config", + lambda: FakeGeneralConfig(), + ) + monkeypatch.setattr( + "gltest.fees.profile.get_general_config", + lambda: FakeGeneralConfig(tmp_path / "fees.json"), + ) + contract = Contract.new( + address="0x123", + schema={"methods": {"create_bet": {"readonly": False}}}, + ) + + contract.create_bet([1]).transact() + + profile = get_fee_profile_collector().build_profile( + network="localnet", headroom=1.0 + ) + assert profile["methods"]["create_bet"] == { + "executionBudgetPerRound": "312500", + "totalMessageFees": "12500", + } + + +def test_deploy_records_fee_profile_observation(monkeypatch, tmp_path): + client = FakeClient(fee_receipt(625000, 0)) + monkeypatch.setattr( + "gltest.contracts.contract_factory.get_gl_client", lambda: client + ) + monkeypatch.setattr( + "gltest.contracts.contract_factory.get_general_config", + lambda: FakeGeneralConfig(), + ) + monkeypatch.setattr( + "gltest.fees.profile.get_general_config", + lambda: FakeGeneralConfig(tmp_path / "fees.json"), + ) + factory = ContractFactory( + contract_name="Example", contract_code="class Example: pass" + ) + + factory.deploy_contract_tx(args=["hello"]) + + profile = get_fee_profile_collector().build_profile( + network="localnet", headroom=1.0 + ) + assert profile["deploy"] == { + "executionBudgetPerRound": "625000", + "totalMessageFees": "0", + } diff --git a/tests/gltest_cli/config/test_plugin.py b/tests/gltest_cli/config/test_plugin.py index 75c4d45..92b10c2 100644 --- a/tests/gltest_cli/config/test_plugin.py +++ b/tests/gltest_cli/config/test_plugin.py @@ -21,21 +21,25 @@ def test_help_message(pytester): " --chain-type=CHAIN_TYPE", " Chain type (possible values: localnet, studionet,", " testnet_asimov, testnet_bradbury)", + " --fee-profile=FEE_PROFILE", + " Path to write a JSON fee profile for observed deploys", + " and writes", + " --fee-profile-headroom=FEE_PROFILE_HEADROOM", + " Multiplier applied to observed fee maxima in --fee-", + " profile output", ] ) def test_default_wait_interval(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_default_wait_interval(): general_config = get_general_config() assert general_config.get_default_wait_interval() == 5000 - """ - ) + """) result = pytester.runpytest("--default-wait-interval=5000", "-v") @@ -48,15 +52,13 @@ def test_default_wait_interval(): def test_default_wait_retries(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_default_wait_retries(): general_config = get_general_config() assert general_config.get_default_wait_retries() == 4000 - """ - ) + """) result = pytester.runpytest("--default-wait-retries=4000", "-v") @@ -69,15 +71,13 @@ def test_default_wait_retries(): def test_rpc_url(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_rpc_url(): general_config = get_general_config() assert general_config.get_rpc_url() == 'http://custom-rpc-url:8545' - """ - ) + """) result = pytester.runpytest("--rpc-url=http://custom-rpc-url:8545", "-v") @@ -90,15 +90,13 @@ def test_rpc_url(): def test_network_localnet(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_network(): general_config = get_general_config() assert general_config.get_network_name() == "localnet" - """ - ) + """) result = pytester.runpytest("--network=localnet", "-v") @@ -111,15 +109,13 @@ def test_network(): def test_network_testnet(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_network(): general_config = get_general_config() assert general_config.get_network_name() == "testnet_asimov" - """ - ) + """) result = pytester.runpytest( "--network=testnet_asimov", "--rpc-url=http://test.example.com:9151", "-v" @@ -130,15 +126,13 @@ def test_network(): def test_network_testnet_bradbury(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_network(): general_config = get_general_config() assert general_config.get_network_name() == "testnet_bradbury" - """ - ) + """) result = pytester.runpytest( "--network=testnet_bradbury", "--rpc-url=http://test.example.com:9151", "-v" @@ -150,16 +144,14 @@ def test_network(): def test_artifacts_dir(pytester): """Test that artifacts directory CLI parameter works correctly.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config from pathlib import Path def test_artifacts_dir(): general_config = get_general_config() assert general_config.get_artifacts_dir() == Path("custom/artifacts") - """ - ) + """) result = pytester.runpytest("--artifacts-dir=custom/artifacts", "-v") @@ -173,8 +165,7 @@ def test_artifacts_dir(): def test_contracts_and_artifacts_dirs(pytester): """Test that both contracts and artifacts directories can be set via CLI.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config from pathlib import Path @@ -182,8 +173,7 @@ def test_both_dirs(): general_config = get_general_config() assert general_config.get_contracts_dir() == Path("src/contracts") assert general_config.get_artifacts_dir() == Path("build/artifacts") - """ - ) + """) result = pytester.runpytest( "--contracts-dir=src/contracts", "--artifacts-dir=build/artifacts", "-v" @@ -199,8 +189,7 @@ def test_both_dirs(): def test_artifacts_dir_default_fallback(pytester): """Test that artifacts directory falls back to config file default when CLI not provided.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config from pathlib import Path @@ -212,8 +201,7 @@ def test_artifacts_default(): # Default should be 'artifacts' assert str(artifacts_dir) == "artifacts" - """ - ) + """) result = pytester.runpytest("-v") @@ -226,15 +214,13 @@ def test_artifacts_default(): def test_leader_only_true(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_leader_only(): general_config = get_general_config() assert general_config.get_leader_only() == True - """ - ) + """) result = pytester.runpytest("--leader-only", "-v") @@ -247,15 +233,13 @@ def test_leader_only(): def test_leader_only_false(pytester): - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_leader_only(): general_config = get_general_config() assert general_config.get_leader_only() == False - """ - ) + """) result = pytester.runpytest("-v") @@ -269,15 +253,13 @@ def test_leader_only(): def test_chain_localnet(pytester): """Test that --chain=localnet sets the chain correctly.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_chain(): general_config = get_general_config() assert general_config.get_chain_type() == "localnet" - """ - ) + """) result = pytester.runpytest("--chain-type=localnet", "-v") @@ -291,15 +273,13 @@ def test_chain(): def test_chain_studionet(pytester): """Test that --chain=studionet sets the chain correctly.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_chain(): general_config = get_general_config() assert general_config.get_chain_type() == "studionet" - """ - ) + """) result = pytester.runpytest("--chain-type=studionet", "-v") @@ -313,15 +293,13 @@ def test_chain(): def test_chain_testnet_asimov(pytester): """Test that --chain=testnet_asimov sets the chain correctly.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_chain_type(): general_config = get_general_config() assert general_config.get_chain_type() == "testnet_asimov" - """ - ) + """) result = pytester.runpytest("--chain-type=testnet_asimov", "-v") @@ -335,8 +313,7 @@ def test_chain_type(): def test_chain_invalid(pytester): """Test that an invalid chain name raises an error.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" import pytest from gltest_cli.config.general import get_general_config @@ -344,8 +321,7 @@ def test_chain_type(): general_config = get_general_config() with pytest.raises(ValueError, match="Unknown chain type"): general_config.get_chain_type() - """ - ) + """) result = pytester.runpytest("--chain-type=invalid_chain", "-v") @@ -355,8 +331,7 @@ def test_chain_type(): def test_chain_none_default(pytester): """Test that when --chain is not provided, it defaults to None.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_chain_type(): @@ -366,8 +341,7 @@ def test_chain_type(): # get_chain_type should still work using network config chain_type = general_config.get_chain_type() assert chain_type in ["localnet", "studionet", "testnet_asimov", "testnet_bradbury"] - """ - ) + """) result = pytester.runpytest("-v") @@ -381,8 +355,7 @@ def test_chain_type(): def test_chain_overrides_network_config(pytester): """Test that --chain overrides the network's configured chain.""" - pytester.makepyfile( - """ + pytester.makepyfile(""" from gltest_cli.config.general import get_general_config def test_chain_override(): @@ -390,8 +363,7 @@ def test_chain_override(): # Even though network is localnet, chain should be studionet assert general_config.get_network_name() == "localnet" assert general_config.get_chain_type() == "studionet" - """ - ) + """) result = pytester.runpytest("--network=localnet", "--chain-type=studionet", "-v") @@ -401,3 +373,57 @@ def test_chain_override(): ] ) assert result.ret == 0 + + +def test_fee_profile_options(pytester, tmp_path): + from gltest_cli.config.general import get_general_config + + profile_path = tmp_path / "fees.json" + pytester.makepyfile(f""" + from gltest_cli.config.general import get_general_config + from pathlib import Path + + def test_fee_profile_options(): + general_config = get_general_config() + assert general_config.get_fee_profile_path() == Path({str(profile_path)!r}) + assert general_config.get_fee_profile_headroom() == 1.5 + """) + + try: + result = pytester.runpytest( + f"--fee-profile={profile_path}", + "--fee-profile-headroom=1.5", + "-v", + ) + finally: + # The in-process run mutates the shared config singleton; reset it so + # the outer gltest session does not write a fee profile of its own. + general_config = get_general_config() + general_config.plugin_config.fee_profile_path = None + general_config.plugin_config.fee_profile_headroom = None + + result.stdout.fnmatch_lines( + [ + "*::test_fee_profile_options PASSED*", + ] + ) + assert result.ret == 0 + assert profile_path.exists() + + +def test_fee_profile_headroom_must_be_positive(pytester): + from gltest_cli.config.general import get_general_config + + pytester.makepyfile(""" + def test_placeholder(): + assert True + """) + + try: + result = pytester.runpytest("--fee-profile-headroom=0", "-v") + finally: + general_config = get_general_config() + general_config.plugin_config.fee_profile_path = None + general_config.plugin_config.fee_profile_headroom = None + + assert result.ret != 0 diff --git a/uv.lock b/uv.lock index b8776aa..f85bc87 100644 --- a/uv.lock +++ b/uv.lock @@ -724,7 +724,7 @@ requires-dist = [ { name = "colorama", specifier = ">=0.4.6" }, { name = "eth-account", marker = "extra == 'sim'", specifier = ">=0.10" }, { name = "fastapi", marker = "extra == 'sim'", specifier = ">=0.100" }, - { name = "genlayer-py", specifier = ">=0.18.0,<0.19.0" }, + { name = "genlayer-py", specifier = ">=0.18.0,<0.20.0" }, { name = "httpx", marker = "extra == 'sim'", specifier = ">=0.24" }, { name = "numpy", marker = "extra == 'sim'", specifier = ">=1.26" }, { name = "pytest" }, From d04c5b581e649ebeb34b53cf2fe890b3a33df98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 16 Jun 2026 10:03:15 +0100 Subject: [PATCH 06/16] fix: support fee profile policy fields (#96) Admin merged after Python 3.12/3.13 and branch-policy checks passed; optional E2E app check is queued/non-blocking for dev-branch PRs. --- README.md | 29 +-- gltest/artifacts/contract.py | 20 ++- gltest/contracts/contract.py | 24 ++- gltest/contracts/contract_factory.py | 19 +- gltest/contracts/wait.py | 54 ++++++ gltest/fees/profile.py | 165 ++++++++++++++++-- .../contracts/current_style_contract.py | 18 ++ .../artifact/test_contract_definition.py | 24 +++ tests/gltest/contracts/test_fee_params.py | 47 +++++ tests/gltest/fees/test_fee_profile.py | 127 +++++++++++++- 10 files changed, 466 insertions(+), 61 deletions(-) create mode 100644 gltest/contracts/wait.py create mode 100644 tests/gltest/artifact/contracts/current_style_contract.py diff --git a/README.md b/README.md index 1e01eb3..7414796 100644 --- a/README.md +++ b/README.md @@ -228,11 +228,14 @@ gltest --fee-profile artifacts/fee-profile.json gltest --fee-profile artifacts/fee-profile.json --fee-profile-headroom 1.5 ``` -`--fee-profile` writes JSON that can be passed verbatim as `suggestions` to -`createTransactionKit` from `@genlayer/transaction-kit`. The optional -`--fee-profile-headroom` multiplier defaults to `1.25`. Each value is the -maximum observed consumed fee across the session multiplied by headroom, rounded -up, and emitted as a decimal string. +`--fee-profile` writes JSON that can be used as developer fee suggestions by +transaction-kit, genlayer-js, genlayer-py, or CLI-based submission flows. The optional +`--fee-profile-headroom` multiplier defaults to `1.25`. Fee and time-unit +values are multiplied by headroom, rounded up, and emitted as decimal strings. +When the same method is observed in multiple tests, the profile records the +maximum observed value for each field across all of those branches. +`rotationsPerRound` is recorded exactly because it is a posture choice rather +than a consumed fee amount. ```json { @@ -240,13 +243,19 @@ up, and emitted as a decimal string. "network": "localnet", "measuredAt": "2026-06-10T12:00:00Z", "deploy": { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", "executionBudgetPerRound": "625000", - "totalMessageFees": "0" + "totalMessageFees": "0", + "rotationsPerRound": "0" }, "methods": { "create_bet": { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", "executionBudgetPerRound": "312500", - "totalMessageFees": "12500" + "totalMessageFees": "12500", + "rotationsPerRound": "0" } } } @@ -255,9 +264,9 @@ up, and emitted as a decimal string. Fee profiling is currently measurable on Studio-based networks whose receipts include consumed fee data. Testnet receipts do not expose consumed fees yet, and direct/sim mode does not go through these receipt paths. Time-unit allocations -are not measurable from backend receipts, so `leaderTimeunitsAllocation` and -`validatorTimeunitsAllocation` are intentionally omitted; downstream presets or -network defaults should supply them. +are recorded from the submitted fee distribution when the backend receipt +includes it. Live price caps and `feeValue` are intentionally omitted so the SDK +can quote them from the current network policy at transaction time. ### Assertions diff --git a/gltest/artifacts/contract.py b/gltest/artifacts/contract.py index e332a1c..30b435e 100644 --- a/gltest/artifacts/contract.py +++ b/gltest/artifacts/contract.py @@ -104,16 +104,18 @@ def _extract_contract_name_from_file(file_path: Path) -> str: raise ValueError(f"No valid contract class found in {file_path}") +def _attribute_path(node: ast.expr) -> list[str]: + if isinstance(node, ast.Name): + return [node.id] + if isinstance(node, ast.Attribute): + return [*_attribute_path(node.value), node.attr] + return [] + + def _is_genlayer_contract_base(base: ast.expr) -> bool: - """Return True for ``gl.contract.Contract`` inheritance.""" - return ( - isinstance(base, ast.Attribute) - and base.attr == "Contract" - and isinstance(base.value, ast.Attribute) - and base.value.attr == "contract" - and isinstance(base.value.value, ast.Name) - and base.value.value.id == "gl" - ) + """Return True for supported GenLayer contract base classes.""" + path = _attribute_path(base) + return path in (["gl", "contract", "Contract"], ["gl", "Contract"]) def _create_contract_definition( diff --git a/gltest/contracts/contract.py b/gltest/contracts/contract.py index 90d6041..de0996c 100644 --- a/gltest/contracts/contract.py +++ b/gltest/contracts/contract.py @@ -16,6 +16,7 @@ from gltest.fees import maybe_record_fee_observation from .contract_functions import ContractFunction from .stats_collector import StatsCollector, SimulationConfig +from .wait import wait_for_transaction_receipt, wait_until_from_status def _fees_with_value(fees: Optional[Dict[str, Any]], fee_value: Optional[int]): @@ -34,14 +35,6 @@ def _fee_kwargs( return {"fees": _fees_with_value(fees, fee_value)} -def _wait_until_from_status( - status: TransactionStatus, -) -> Literal["decided", "finalized"]: - if status == TransactionStatus.FINALIZED: - return "finalized" - return "decided" - - def read_contract_wrapper( self, method_name: str, @@ -141,9 +134,10 @@ def transact_method( **_fee_kwargs(client.write_contract, fees, fee_value), sim_config=sim_config, ) - receipt = client.wait_for_transaction_receipt( + receipt = wait_for_transaction_receipt( + client, transaction_hash=tx_hash, - wait_until=wait_until or _wait_until_from_status(wait_transaction_status), + wait_until=wait_until or wait_until_from_status(wait_transaction_status), interval=actual_wait_interval, retries=actual_wait_retries, ) @@ -153,9 +147,10 @@ def transact_method( if wait_triggered_transactions: triggered_transactions = receipt.get("triggered_transactions", []) for triggered_transaction in triggered_transactions: - client.wait_for_transaction_receipt( + wait_for_transaction_receipt( + client, transaction_hash=triggered_transaction, - wait_until=_wait_until_from_status( + wait_until=wait_until_from_status( wait_triggered_transactions_status ), interval=actual_wait_interval, @@ -292,9 +287,10 @@ def appeal( account=self.account, value=value, ) - return client.wait_for_transaction_receipt( + return wait_for_transaction_receipt( + client, transaction_hash=tx_hash, - wait_until=wait_until or _wait_until_from_status(wait_transaction_status), + wait_until=wait_until or wait_until_from_status(wait_transaction_status), interval=actual_wait_interval, retries=actual_wait_retries, ) diff --git a/gltest/contracts/contract_factory.py b/gltest/contracts/contract_factory.py index fa818f0..c200dd3 100644 --- a/gltest/contracts/contract_factory.py +++ b/gltest/contracts/contract_factory.py @@ -26,6 +26,7 @@ from gltest.fees import maybe_record_fee_observation from gltest.utils import extract_contract_address from gltest.types import TransactionContext +from .wait import wait_for_transaction_receipt, wait_until_from_status def _fees_with_value(fees: Optional[Dict[str, Any]], fee_value: Optional[int]): @@ -40,14 +41,6 @@ def _fee_kwargs(call, fees: Optional[Dict[str, Any]], fee_value: Optional[int]): return {"fees": _fees_with_value(fees, fee_value)} -def _wait_until_from_status( - status: TransactionStatus, -) -> Literal["decided", "finalized"]: - if status == TransactionStatus.FINALIZED: - return "finalized" - return "decided" - - @dataclass class ContractFactory: """ @@ -223,10 +216,11 @@ def deploy_contract_tx( **_fee_kwargs(client.deploy_contract, fees, fee_value), sim_config=sim_config, ) - tx_receipt = client.wait_for_transaction_receipt( + tx_receipt = wait_for_transaction_receipt( + client, transaction_hash=tx_hash, wait_until=wait_until - or _wait_until_from_status(wait_transaction_status), + or wait_until_from_status(wait_transaction_status), interval=actual_wait_interval, retries=actual_wait_retries, ) @@ -234,9 +228,10 @@ def deploy_contract_tx( if wait_triggered_transactions: triggered_transactions = tx_receipt.get("triggered_transactions", []) for triggered_transaction in triggered_transactions: - client.wait_for_transaction_receipt( + wait_for_transaction_receipt( + client, transaction_hash=triggered_transaction, - wait_until=_wait_until_from_status( + wait_until=wait_until_from_status( wait_triggered_transactions_status ), interval=actual_wait_interval, diff --git a/gltest/contracts/wait.py b/gltest/contracts/wait.py new file mode 100644 index 0000000..f80a76e --- /dev/null +++ b/gltest/contracts/wait.py @@ -0,0 +1,54 @@ +import inspect +from typing import Callable, Literal + +from gltest.types import TransactionStatus + + +def wait_until_from_status( + status: TransactionStatus, +) -> Literal["decided", "finalized"]: + if status == TransactionStatus.FINALIZED: + return "finalized" + return "decided" + + +def _status_from_wait_until(wait_until: Literal["decided", "finalized"]): + if wait_until == "finalized": + return TransactionStatus.FINALIZED + return TransactionStatus.ACCEPTED + + +def _accepts_var_kwargs(call: Callable) -> bool: + return any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in inspect.signature(call).parameters.values() + ) + + +def wait_for_transaction_receipt( + client, + *, + transaction_hash: str, + wait_until: Literal["decided", "finalized"], + interval: int, + retries: int, +): + call = client.wait_for_transaction_receipt + parameters = inspect.signature(call).parameters + kwargs = { + "transaction_hash": transaction_hash, + "interval": interval, + "retries": retries, + } + + if "wait_until" in parameters or ( + "status" not in parameters and _accepts_var_kwargs(call) + ): + kwargs["wait_until"] = wait_until + else: + kwargs["status"] = _status_from_wait_until(wait_until) + + if "full_transaction" in parameters: + kwargs["full_transaction"] = True + + return call(**kwargs) diff --git a/gltest/fees/profile.py b/gltest/fees/profile.py index d7c2b1d..766b7b7 100644 --- a/gltest/fees/profile.py +++ b/gltest/fees/profile.py @@ -8,11 +8,21 @@ from gltest.logging import logger from gltest_cli.config.general import get_general_config -FEE_KEYS = ("executionBudgetPerRound", "totalMessageFees") +FEE_KEYS = ( + "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation", + "executionBudgetPerRound", + "totalMessageFees", + "rotationsPerRound", +) CONSUMED_KEY_MAPPING = { "executionBudgetPerRound": "executionConsumed", "totalMessageFees": "messageFeesConsumed", } +ALLOCATION_KEY_MAPPING = { + "leaderTimeunitsAllocation": "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation": "validatorTimeunitsAllocation", +} class FeeProfileCollector: @@ -62,34 +72,163 @@ def _extract_observation( try: if not receipt: return None - fees = receipt.get("fees") - if not fees: - return None - consumed = fees.get("consumed") - if not consumed: + legacy_observation = self._extract_legacy_fee_observation(receipt) + if legacy_observation is not None: + return legacy_observation + + accounting = self._extract_fee_accounting(receipt) + if accounting is None: return None - return { - output_key: int(consumed[consumed_key]) - for output_key, consumed_key in CONSUMED_KEY_MAPPING.items() - } + + observation = self._extract_distribution_observation(accounting) + report = self._dict_or_none(accounting.get("execution_fee_report")) or {} + execution_fee_report_total = self._int_value( + report.get("totalEstimatedFee") + ) + execution_consumed = self._int_value( + accounting.get("execution_fee_consumed") + ) + message_consumed = self._int_value(accounting.get("message_fee_consumed")) + genvm_message_consumed = self._int_value( + accounting.get("genvm_message_fee_consumed") + ) + + observation.update( + { + "executionBudgetPerRound": execution_consumed + + execution_fee_report_total, + "totalMessageFees": max( + message_consumed, genvm_message_consumed + ), + } + ) + return observation except Exception as e: if not self._warned_malformed: logger.warning("Failed to record fee profile observation: %s", e) self._warned_malformed = True return None + def _extract_legacy_fee_observation( + self, receipt: Dict[str, Any] + ) -> Optional[Dict[str, int]]: + fees = self._dict_or_none(receipt.get("fees")) + if not fees: + return None + consumed = self._dict_or_none(fees.get("consumed")) + if not consumed: + return None + observation = { + output_key: int(consumed[consumed_key]) + for output_key, consumed_key in CONSUMED_KEY_MAPPING.items() + } + distribution = self._dict_or_none(fees.get("distribution")) + if distribution: + observation.update(self._allocation_observation(distribution)) + return observation + + def _extract_distribution_observation( + self, accounting: Dict[str, Any] + ) -> Dict[str, int]: + for candidate in self._distribution_candidates(accounting): + distribution = self._dict_or_none(candidate) + if distribution: + return self._allocation_observation(distribution) + return {} + + def _distribution_candidates(self, accounting: Dict[str, Any]): + yield accounting.get("fees_distribution") + yield accounting.get("feesDistribution") + + preset = self._dict_or_none(accounting.get("recommended_fee_preset")) + if preset: + yield preset.get("distribution") + + camel_preset = self._dict_or_none(accounting.get("recommendedFeePreset")) + if camel_preset: + yield camel_preset.get("distribution") + + def _allocation_observation(self, distribution: Dict[str, Any]) -> Dict[str, int]: + observation = { + output_key: self._int_value(distribution.get(source_key)) + for output_key, source_key in ALLOCATION_KEY_MAPPING.items() + if distribution.get(source_key) is not None + } + rotations = distribution.get("rotations") + if isinstance(rotations, list) and rotations: + observation["rotationsPerRound"] = max( + self._int_value(rotation) for rotation in rotations + ) + return observation + + def _extract_fee_accounting( + self, receipt: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: + for candidate in self._fee_accounting_candidates(receipt): + accounting = self._dict_or_none(candidate) + if accounting: + return accounting + return None + + def _fee_accounting_candidates(self, receipt: Dict[str, Any]): + yield receipt.get("fee_accounting") + yield receipt.get("feeAccounting") + + data = self._dict_or_none(receipt.get("data")) + if data: + yield data.get("fee_accounting") + yield data.get("feeAccounting") + + genvm_result = self._dict_or_none(receipt.get("genvm_result")) + if genvm_result: + yield genvm_result.get("fee_accounting") + yield genvm_result.get("feeAccounting") + + consensus_data = self._dict_or_none(receipt.get("consensus_data")) + if not consensus_data: + return + + leader_receipt = consensus_data.get("leader_receipt") + leader_receipts = ( + leader_receipt + if isinstance(leader_receipt, list) + else [leader_receipt] + ) + for item in leader_receipts: + receipt_item = self._dict_or_none(item) + if not receipt_item: + continue + yield receipt_item.get("fee_accounting") + yield receipt_item.get("feeAccounting") + leader_genvm_result = self._dict_or_none(receipt_item.get("genvm_result")) + if leader_genvm_result: + yield leader_genvm_result.get("fee_accounting") + yield leader_genvm_result.get("feeAccounting") + + @staticmethod + def _dict_or_none(value: Any) -> Optional[Dict[str, Any]]: + return value if isinstance(value, dict) else None + + @staticmethod + def _int_value(value: Any) -> int: + return int(value or 0) + @staticmethod def _record_max(current: Dict[str, int], observation: Dict[str, int]) -> None: for key in FEE_KEYS: + if key not in observation: + continue current[key] = max(current.get(key, 0), observation[key]) @staticmethod def _apply_headroom(values: Dict[str, int], headroom: float) -> Dict[str, str]: multiplier = Decimal(str(headroom)) - # The backend only reports consumed fee amounts, not consumed time-unit - # allocations, so leader/validator allocation suggestions are omitted. return { - key: str(math.ceil(Decimal(value) * multiplier)) + key: str( + value + if key == "rotationsPerRound" + else math.ceil(Decimal(value) * multiplier) + ) for key, value in values.items() } diff --git a/tests/gltest/artifact/contracts/current_style_contract.py b/tests/gltest/artifact/contracts/current_style_contract.py new file mode 100644 index 0000000..caa7b20 --- /dev/null +++ b/tests/gltest/artifact/contracts/current_style_contract.py @@ -0,0 +1,18 @@ +# { "Depends": "py-genlayer:latest" } + +import genlayer as gl + + +class CurrentStyleContract(gl.Contract): + storage: str + + def __init__(self, initial_storage: str): + self.storage = initial_storage + + @gl.public.view + def get_storage(self) -> str: + return self.storage + + @gl.public.write + def update_storage(self, new_storage: str) -> None: + self.storage = new_storage diff --git a/tests/gltest/artifact/test_contract_definition.py b/tests/gltest/artifact/test_contract_definition.py index 4e96e5a..25cda1d 100644 --- a/tests/gltest/artifact/test_contract_definition.py +++ b/tests/gltest/artifact/test_contract_definition.py @@ -1,6 +1,7 @@ import pytest from gltest.artifacts.contract import ( find_contract_definition_from_name, + find_contract_definition_from_path, compute_contract_code, ) from gltest_cli.config.general import get_general_config @@ -53,3 +54,26 @@ def test_class_is_not_intelligent_contract(): with pytest.raises(FileNotFoundError): _ = find_contract_definition_from_name("NotICContract") + + +def test_current_style_gl_contract_base(): + general_config = get_general_config() + general_config.set_contracts_dir(Path("tests/gltest/artifact/contracts")) + + contract_definition = find_contract_definition_from_name("CurrentStyleContract") + + assert contract_definition.contract_name == "CurrentStyleContract" + assert contract_definition.main_file_path == ( + Path("tests/gltest/artifact/contracts") / "current_style_contract.py" + ) + + +def test_current_style_gl_contract_base_from_path(): + general_config = get_general_config() + general_config.set_contracts_dir(Path("tests/gltest/artifact/contracts")) + + contract_definition = find_contract_definition_from_path( + "current_style_contract.py" + ) + + assert contract_definition.contract_name == "CurrentStyleContract" diff --git a/tests/gltest/contracts/test_fee_params.py b/tests/gltest/contracts/test_fee_params.py index e159487..9186d03 100644 --- a/tests/gltest/contracts/test_fee_params.py +++ b/tests/gltest/contracts/test_fee_params.py @@ -1,5 +1,7 @@ from gltest.contracts.contract import Contract from gltest.contracts.contract_factory import ContractFactory +from gltest.contracts.wait import wait_for_transaction_receipt +from gltest.types import TransactionStatus class FakeGeneralConfig: @@ -38,6 +40,51 @@ def wait_for_transaction_receipt(self, **kwargs): } +class OldWaitClient: + def __init__(self): + self.wait_for_transaction_receipt_calls = [] + + def wait_for_transaction_receipt( + self, + transaction_hash, + status=TransactionStatus.ACCEPTED, + interval=3000, + retries=50, + full_transaction=False, + ): + self.wait_for_transaction_receipt_calls.append( + { + "transaction_hash": transaction_hash, + "status": status, + "interval": interval, + "retries": retries, + "full_transaction": full_transaction, + } + ) + return {"status": "ACCEPTED"} + + +def test_wait_helper_supports_old_sdk_status_signature(): + client = OldWaitClient() + + receipt = wait_for_transaction_receipt( + client, + transaction_hash="0xwrite", + wait_until="decided", + interval=1, + retries=2, + ) + + assert receipt["status"] == "ACCEPTED" + assert client.wait_for_transaction_receipt_calls[0] == { + "transaction_hash": "0xwrite", + "status": TransactionStatus.ACCEPTED, + "interval": 1, + "retries": 2, + "full_transaction": True, + } + + def test_transact_threads_fee_params_to_sdk(monkeypatch): client = FakeClient() monkeypatch.setattr("gltest.contracts.contract.get_gl_client", lambda: client) diff --git a/tests/gltest/fees/test_fee_profile.py b/tests/gltest/fees/test_fee_profile.py index 325794d..93eaad6 100644 --- a/tests/gltest/fees/test_fee_profile.py +++ b/tests/gltest/fees/test_fee_profile.py @@ -30,6 +30,38 @@ def fee_receipt(execution_consumed, message_fees_consumed): } +def studio_fee_accounting_receipt( + *, + execution_consumed=100, + execution_report_total=200, + message_consumed=10, + genvm_message_consumed=7, + leader_timeunits=100, + validator_timeunits=200, + rotations=None, +): + if rotations is None: + rotations = [0] + return { + "status": "ACCEPTED", + "data": { + "fee_accounting": { + "fees_distribution": { + "leaderTimeunitsAllocation": str(leader_timeunits), + "validatorTimeunitsAllocation": str(validator_timeunits), + "rotations": [str(rotation) for rotation in rotations], + }, + "execution_fee_consumed": str(execution_consumed), + "message_fee_consumed": str(message_consumed), + "genvm_message_fee_consumed": str(genvm_message_consumed), + "execution_fee_report": { + "totalEstimatedFee": str(execution_report_total), + }, + }, + }, + } + + class FakeGeneralConfig: def __init__(self, fee_profile_path=None): self.fee_profile_path = fee_profile_path @@ -89,10 +121,14 @@ def test_collector_records_max_and_applies_headroom_with_big_int(): assert profile["version"] == 1 assert profile["network"] == "localnet" assert profile["deploy"] == { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", "executionBudgetPerRound": "127", "totalMessageFees": "13", } assert profile["methods"]["create_bet"] == { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", "executionBudgetPerRound": "125000000000000000000", "totalMessageFees": "0", } @@ -118,7 +154,88 @@ def test_message_fee_zero_is_recorded(): assert profile["methods"]["create_bet"]["totalMessageFees"] == "0" -def test_profile_shape_omits_deploy_when_unobserved_and_time_unit_keys(): +def test_current_studio_fee_accounting_shape_is_recorded(): + collector = FeeProfileCollector() + collector.record_method("resolve_bet", studio_fee_accounting_receipt()) + + profile = collector.build_profile(network="localnet", headroom=1.25) + + assert profile["methods"]["resolve_bet"] == { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", + "executionBudgetPerRound": "375", + "totalMessageFees": "13", + "rotationsPerRound": "0", + } + + +def test_method_profile_uses_per_field_maxima_across_branches(): + collector = FeeProfileCollector() + collector.record_method( + "complex_action", + studio_fee_accounting_receipt( + execution_consumed=500, + execution_report_total=100, + message_consumed=0, + genvm_message_consumed=0, + leader_timeunits=100, + validator_timeunits=200, + rotations=[0], + ), + ) + collector.record_method( + "complex_action", + studio_fee_accounting_receipt( + execution_consumed=100, + execution_report_total=100, + message_consumed=800, + genvm_message_consumed=750, + leader_timeunits=80, + validator_timeunits=150, + rotations=[1], + ), + ) + + profile = collector.build_profile(network="localnet", headroom=1.0) + + assert profile["methods"]["complex_action"] == { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", + "executionBudgetPerRound": "600", + "totalMessageFees": "800", + "rotationsPerRound": "1", + } + + +def test_nested_leader_fee_accounting_shape_is_recorded(): + collector = FeeProfileCollector() + collector.record_method( + "resolve_bet", + { + "consensus_data": { + "leader_receipt": [ + { + "genvm_result": { + "fee_accounting": { + "execution_fee_consumed": "50", + "genvm_message_fee_consumed": "9", + } + } + } + ] + } + }, + ) + + profile = collector.build_profile(network="localnet", headroom=1.0) + + assert profile["methods"]["resolve_bet"] == { + "executionBudgetPerRound": "50", + "totalMessageFees": "9", + } + + +def test_profile_shape_includes_time_unit_keys_when_available(): collector = FeeProfileCollector() collector.record_method("create_bet", fee_receipt(10, 5)) @@ -127,12 +244,12 @@ def test_profile_shape_omits_deploy_when_unobserved_and_time_unit_keys(): assert set(profile) == {"version", "network", "measuredAt", "methods"} assert profile["methods"] == { "create_bet": { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", "executionBudgetPerRound": "10", "totalMessageFees": "5", } } - assert "leaderTimeunitsAllocation" not in profile["methods"]["create_bet"] - assert "validatorTimeunitsAllocation" not in profile["methods"]["create_bet"] def test_write_creates_parent_dirs_and_round_trips_json(tmp_path): @@ -168,6 +285,8 @@ def test_transact_records_fee_profile_observation(monkeypatch, tmp_path): network="localnet", headroom=1.0 ) assert profile["methods"]["create_bet"] == { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", "executionBudgetPerRound": "312500", "totalMessageFees": "12500", } @@ -196,6 +315,8 @@ def test_deploy_records_fee_profile_observation(monkeypatch, tmp_path): network="localnet", headroom=1.0 ) assert profile["deploy"] == { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", "executionBudgetPerRound": "625000", "totalMessageFees": "0", } From 12a7576d3fa75cc4b3b52e026748a9705f7bdafe Mon Sep 17 00:00:00 2001 From: Kira Date: Thu, 23 Jul 2026 19:05:59 +0900 Subject: [PATCH 07/16] fix(gltest)!: migrate to genvm manager repo (#98) * fix(gltest): resolve runners from local tree when GENVM_PREBUILT_DIR set GenVM v0.6 dropped the monolithic runner bundle the direct/glsim loader downloads from the (retired) genlayerlabs/genvm releases, so under v0.6 every load failed with `No GenVM runner bundle for `. When GENVM_PREBUILT_DIR points at a prebuilt GenVM tree, extract the runner straight from its content-addressed tar (globbed under any *runners* dir, so runners/ and executor//legacy-runners/ both resolve) and skip the download. Download path is untouched and stays the default when the env var is unset. * test(fixtures): migrate to genvm v0.3 SDK --- gltest/direct/sdk_loader.py | 144 ++++++++++-------- .../contracts/football_prediction_market.py | 2 +- .../examples/contracts/intelligent_oracle.py | 8 +- .../contracts/intelligent_oracle_factory.py | 2 +- tests/examples/contracts/llm_erc20.py | 4 +- tests/examples/contracts/log_indexer.py | 4 +- .../contracts/multi_file_contract/__init__.py | 4 +- tests/examples/contracts/multi_read_erc20.py | 2 +- .../contracts/multi_tenant_storage.py | 6 +- tests/examples/contracts/user_storage.py | 2 +- tests/glsim/deterministic_factory_contract.py | 2 +- .../contracts/current_style_contract.py | 2 +- tests/gltest_direct/test_sdk_loader.py | 5 +- 13 files changed, 105 insertions(+), 82 deletions(-) diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index d1fa7ab..869f72a 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -9,7 +9,9 @@ import re import sys import json +import shutil import tarfile +import platform import tempfile import urllib.error import urllib.request @@ -17,13 +19,28 @@ from typing import Optional, Dict, List CACHE_DIR = Path.home() / ".cache" / "gltest-direct" -GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm/releases" -GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm/releases" - -# GenVM 0.3.0 renamed this bundle from genvm-universal.tar.xz; newest name first. -RUNNER_BUNDLE_ASSETS = ("genvm-runners-all.tar.xz", "genvm-universal.tar.xz") +GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm-manager/releases" +GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm-manager/releases" + + +def _host_release_asset() -> str: + """genvm-manager (v0.6+) ships a whole-tree tarball per platform, named + genvm--.tar.xz — pick the one matching this host.""" + machine = platform.machine().lower() + arch = "arm64" if machine in ("aarch64", "arm64") else "amd64" + os_name = "macos" if platform.system().lower() == "darwin" else "linux" + return f"genvm-{arch}-{os_name}.tar.xz" + + +# Download candidates, newest-scheme first: the genvm-manager per-platform whole +# tree, then the pre-v0.6 runner-only bundles (genvm-runners-all → genvm-universal). +RUNNER_BUNDLE_ASSETS = ( + _host_release_asset(), + "genvm-runners-all.tar.xz", + "genvm-universal.tar.xz", +) GENVM_VERSION_ENV = "GENVM_VERSION" -FALLBACK_VERSION = "v0.2.16" +FALLBACK_VERSION = "v0.6.0-rc0" RUNNER_TYPE = "py-genlayer" STD_LIB_TYPE = "py-lib-genlayer-std" @@ -158,68 +175,72 @@ def download_artifacts(version: str) -> Path: ) from last_error +def _extract_local_runner( + root: Path, runner_type: str, runner_hash: Optional[str] +) -> Path: + """Extract a runner from a local prebuilt GenVM tree (GENVM_PREBUILT_DIR); globs + any *runners* dir so runners/ and executor//legacy-runners/ both match.""" + sub = ( + f"{runner_hash[:2]}/{runner_hash[2:]}.tar" + if runner_hash and runner_hash.lower() != "latest" + else "*/*.tar" + ) + hits = sorted(root.glob(f"**/*runners*/{runner_type}/{sub}")) + if not hits: + raise FileNotFoundError(f"runner {runner_type}:{runner_hash} not under {root}") + tar = hits[-1] + dest = CACHE_DIR / "extracted" / "local" / runner_type / (tar.parent.name + tar.stem) + if not dest.exists(): + dest.mkdir(parents=True, exist_ok=True) + with tarfile.open(tar, "r:") as inner: + inner.extractall(dest, filter="data") + return dest + + +def _extract_release_tree(tarball_path: Path, version: str) -> Path: + """Unpack a downloaded GenVM release tarball once (cached) into a local tree. + + genvm-manager ships the whole tree (bin/ lib/ runners/ executor/…), not a + runner-only bundle, so we unpack it to a directory that looks exactly like a + GENVM_PREBUILT_DIR and then resolve runners through the same globbing path. + """ + tree = CACHE_DIR / "trees" / version + if (tree / ".extracted").exists(): + return tree + trees = CACHE_DIR / "trees" + trees.mkdir(parents=True, exist_ok=True) + # Extract into a process-unique dir, mark it complete, then publish with an + # atomic rename. Concurrent cold-cache extractions each use their own tmp and + # race only on the final rename; the loser sees the winner's finished tree. + tmp = Path(tempfile.mkdtemp(dir=trees, prefix=f".{version}.")) + try: + with tarfile.open(tarball_path, "r:xz") as outer: + outer.extractall(tmp, filter="data") + (tmp / ".extracted").touch() + os.replace(tmp, tree) + except OSError: + shutil.rmtree(tmp, ignore_errors=True) + if (tree / ".extracted").exists(): + return tree # another extraction published first + raise + return tree + + def extract_runner( tarball_path: Path, runner_type: str, runner_hash: Optional[str] = None, version: Optional[str] = None, ) -> Path: - """Extract a runner from the tarball.""" + """Resolve a runner dir from a local prebuilt tree or a downloaded release.""" + prebuilt = os.environ.get("GENVM_PREBUILT_DIR") + if prebuilt: + return _extract_local_runner(Path(prebuilt), runner_type, runner_hash) if version is None: match = re.search(r"genvm-universal-(.+)\.tar\.xz", tarball_path.name) version = match.group(1) if match else "unknown" - - extract_base = CACHE_DIR / "extracted" / version / runner_type - - # Fast path: if hash specified and already extracted, skip tarball entirely - if runner_hash and runner_hash.lower() != "latest": - extract_dir = extract_base / runner_hash - if extract_dir.exists(): - return extract_dir - - # Check if any version already extracted (for "latest" case) - if extract_base.exists(): - existing = sorted(extract_base.iterdir(), reverse=True) - if existing and (not runner_hash or runner_hash.lower() == "latest"): - return existing[0] - - # Need to open tarball - this is slow (~13s for xz) - with tarfile.open(tarball_path, "r:xz") as outer_tar: - prefix = f"runners/{runner_type}/" - runner_tars = [ - m.name for m in outer_tar.getmembers() - if m.name.startswith(prefix) and m.name.endswith(".tar") - ] - - if not runner_tars: - raise ValueError(f"No {runner_type} runners found in tarball") - - # Treat "latest" as no specific hash - if runner_hash and runner_hash.lower() != "latest": - target = f"runners/{runner_type}/{runner_hash[:2]}/{runner_hash[2:]}.tar" - if target not in runner_tars: - raise ValueError(f"Runner hash {runner_hash} not found") - runner_tar_name = target - extract_dir = extract_base / runner_hash - else: - runner_tar_name = sorted(runner_tars)[-1] - parts = runner_tar_name.split("/") - runner_hash = parts[-2] + parts[-1].replace(".tar", "") - extract_dir = extract_base / runner_hash - - if extract_dir.exists(): - return extract_dir - - inner_tar_file = outer_tar.extractfile(runner_tar_name) - if inner_tar_file is None: - raise ValueError(f"Failed to read {runner_tar_name}") - - extract_dir.mkdir(parents=True, exist_ok=True) - - with tarfile.open(fileobj=inner_tar_file, mode="r:") as inner_tar: - inner_tar.extractall(extract_dir, filter='data') - - return extract_dir + tree = _extract_release_tree(tarball_path, version) + return _extract_local_runner(tree, runner_type, runner_hash) def parse_runner_manifest(runner_dir: Path) -> Dict[str, str]: @@ -256,10 +277,11 @@ def setup_sdk_paths( if contract_path and contract_path.exists(): contract_deps = parse_contract_header(contract_path) - if version is None: + prebuilt = os.environ.get("GENVM_PREBUILT_DIR") + if version is None and not prebuilt: version = resolve_version() - tarball = download_artifacts(version) + tarball = None if prebuilt else download_artifacts(version) runner_hash = contract_deps.get(RUNNER_TYPE) runner_dir = extract_runner(tarball, RUNNER_TYPE, runner_hash, version) diff --git a/tests/examples/contracts/football_prediction_market.py b/tests/examples/contracts/football_prediction_market.py index 2e33279..dfcb5f5 100644 --- a/tests/examples/contracts/football_prediction_market.py +++ b/tests/examples/contracts/football_prediction_market.py @@ -37,7 +37,7 @@ def __init__(self, game_date: str, team1: str, team2: str): ) self.team1 = team1 self.team2 = team2 - self.winner = gl.u256(0) + self.winner = 0 self.score = "" @gl.public.write diff --git a/tests/examples/contracts/intelligent_oracle.py b/tests/examples/contracts/intelligent_oracle.py index 8e9cd06..5bc0a13 100644 --- a/tests/examples/contracts/intelligent_oracle.py +++ b/tests/examples/contracts/intelligent_oracle.py @@ -19,10 +19,10 @@ class IntelligentOracle(gl.contract.Contract): prediction_market_id: str title: str description: str - potential_outcomes: gl.DynArray[str] - rules: gl.DynArray[str] - data_source_domains: gl.DynArray[str] - resolution_urls: gl.DynArray[str] + potential_outcomes: gl.storage.DynArray[str] + rules: gl.storage.DynArray[str] + data_source_domains: gl.storage.DynArray[str] + resolution_urls: gl.storage.DynArray[str] earliest_resolution_date: str # Store as ISO format string status: str # Store as string since Enum isn't supported analysis: str # Store analysis results diff --git a/tests/examples/contracts/intelligent_oracle_factory.py b/tests/examples/contracts/intelligent_oracle_factory.py index 0d147b8..c49e335 100644 --- a/tests/examples/contracts/intelligent_oracle_factory.py +++ b/tests/examples/contracts/intelligent_oracle_factory.py @@ -6,7 +6,7 @@ class Registry(gl.contract.Contract): # Declare persistent storage fields - contract_addresses: gl.DynArray[str] + contract_addresses: gl.storage.DynArray[str] intelligent_oracle_code: str def __init__(self, intelligent_oracle_code: str): diff --git a/tests/examples/contracts/llm_erc20.py b/tests/examples/contracts/llm_erc20.py index 85561e4..9b150ac 100644 --- a/tests/examples/contracts/llm_erc20.py +++ b/tests/examples/contracts/llm_erc20.py @@ -7,10 +7,10 @@ class LlmErc20(gl.contract.Contract): - balances: gl.TreeMap[gl.Address, gl.u256] + balances: gl.storage.TreeMap[gl.Address, gl.u256] def __init__(self, total_supply: int) -> None: - self.balances[gl.message.sender_address] = gl.u256(total_supply) + self.balances[gl.message.sender_address] = total_supply @gl.public.write def transfer(self, amount: int, to_address: str) -> None: diff --git a/tests/examples/contracts/log_indexer.py b/tests/examples/contracts/log_indexer.py index a3eed4f..5637eb7 100644 --- a/tests/examples/contracts/log_indexer.py +++ b/tests/examples/contracts/log_indexer.py @@ -53,14 +53,14 @@ def get_closest_vector(self, text: str) -> dict | None: @gl.public.write def add_log(self, log: str, log_id: int) -> None: emb = self.get_embedding(log) - self.vector_store.insert(emb, StoreValue(text=log, log_id=gl.u256(log_id))) + self.vector_store.insert(emb, StoreValue(text=log, log_id=log_id)) @gl.public.write def update_log(self, log_id: int, log: str) -> None: emb = self.get_embedding(log) for elem in self.vector_store.knn(emb, 2): if elem.value.text == log: - elem.value.log_id = gl.u256(log_id) + elem.value.log_id = log_id @gl.public.write def remove_log(self, id: int) -> None: diff --git a/tests/examples/contracts/multi_file_contract/__init__.py b/tests/examples/contracts/multi_file_contract/__init__.py index a2c75cd..8927ab5 100644 --- a/tests/examples/contracts/multi_file_contract/__init__.py +++ b/tests/examples/contracts/multi_file_contract/__init__.py @@ -10,8 +10,8 @@ def __init__(self): self.other_addr = gl.contract.deploy( code=text.encode("utf-8"), args=["123"], - salt_nonce=gl.u256(1), - value=gl.u256(0), + salt_nonce=1, + value=0, on="accepted", ) diff --git a/tests/examples/contracts/multi_read_erc20.py b/tests/examples/contracts/multi_read_erc20.py index 3bdf346..8ad807f 100644 --- a/tests/examples/contracts/multi_read_erc20.py +++ b/tests/examples/contracts/multi_read_erc20.py @@ -5,7 +5,7 @@ class multi_read_erc20(gl.contract.Contract): - balances: gl.TreeMap[gl.Address, gl.TreeMap[gl.Address, gl.u256]] + balances: gl.storage.TreeMap[gl.Address, gl.storage.TreeMap[gl.Address, gl.u256]] def __init__(self): pass diff --git a/tests/examples/contracts/multi_tenant_storage.py b/tests/examples/contracts/multi_tenant_storage.py index 6aa37e9..2bcd15d 100644 --- a/tests/examples/contracts/multi_tenant_storage.py +++ b/tests/examples/contracts/multi_tenant_storage.py @@ -12,9 +12,9 @@ class MultiTentantStorage(gl.contract.Contract): This is done to test contract calls between different contracts. """ - all_storage_contracts: gl.DynArray[gl.Address] - available_storage_contracts: gl.DynArray[gl.Address] - mappings: gl.TreeMap[ + all_storage_contracts: gl.storage.DynArray[gl.Address] + available_storage_contracts: gl.storage.DynArray[gl.Address] + mappings: gl.storage.TreeMap[ gl.Address, gl.Address ] # mapping of user address to storage contract address diff --git a/tests/examples/contracts/user_storage.py b/tests/examples/contracts/user_storage.py index a37a902..8f02bf9 100644 --- a/tests/examples/contracts/user_storage.py +++ b/tests/examples/contracts/user_storage.py @@ -5,7 +5,7 @@ class UserStorage(gl.contract.Contract): - storage: gl.TreeMap[gl.Address, str] + storage: gl.storage.TreeMap[gl.Address, str] # constructor def __init__(self): diff --git a/tests/glsim/deterministic_factory_contract.py b/tests/glsim/deterministic_factory_contract.py index 3b65b96..ef51fcf 100644 --- a/tests/glsim/deterministic_factory_contract.py +++ b/tests/glsim/deterministic_factory_contract.py @@ -24,7 +24,7 @@ def deploy_child(self, salt: int) -> str: code=CHILD_CODE.encode("utf-8"), args=[], kwargs={}, - salt_nonce=gl.u256(salt), + salt_nonce=salt, on="accepted", ) self.child_address = child_address.as_hex diff --git a/tests/gltest/artifact/contracts/current_style_contract.py b/tests/gltest/artifact/contracts/current_style_contract.py index caa7b20..ce6020a 100644 --- a/tests/gltest/artifact/contracts/current_style_contract.py +++ b/tests/gltest/artifact/contracts/current_style_contract.py @@ -3,7 +3,7 @@ import genlayer as gl -class CurrentStyleContract(gl.Contract): +class CurrentStyleContract(gl.contract.Contract): storage: str def __init__(self, initial_storage: str): diff --git a/tests/gltest_direct/test_sdk_loader.py b/tests/gltest_direct/test_sdk_loader.py index 7a7cbd6..dd28db5 100644 --- a/tests/gltest_direct/test_sdk_loader.py +++ b/tests/gltest_direct/test_sdk_loader.py @@ -162,7 +162,7 @@ def _download(url, dest): assert result == tmp_path / "genvm-universal-v0.3.0.tar.xz" assert result.read_bytes() == b"bundle" assert tried == [ - f"{sdk_loader.GITHUB_RELEASES_URL}/download/v0.3.0/genvm-runners-all.tar.xz" + f"{sdk_loader.GITHUB_RELEASES_URL}/download/v0.3.0/{sdk_loader.RUNNER_BUNDLE_ASSETS[0]}" ] def test_falls_back_to_old_asset_on_404(self, monkeypatch, tmp_path): @@ -171,7 +171,8 @@ def test_falls_back_to_old_asset_on_404(self, monkeypatch, tmp_path): def _download(url, dest): tried.append(url) - if url.endswith("genvm-runners-all.tar.xz"): + # 404 every asset except the last so the loop walks the full list. + if not url.endswith(sdk_loader.RUNNER_BUNDLE_ASSETS[-1]): raise self._http_404(url) dest.write_bytes(b"bundle") From 447d658435c89433b1105f2fcce041db5e41bf68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Sun, 26 Jul 2026 14:07:46 +0100 Subject: [PATCH 08/16] fix: resolve v0.30-dev bug-hunt findings (#102) * test: add v0.30 bug hunt regressions * fix: resolve v0.30 bug hunt regressions --- glsim/engine.py | 8 +- glsim/server.py | 3 + gltest/direct/sdk_loader.py | 11 ++- tests/test_bug_hunt_v030.py | 174 ++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 tests/test_bug_hunt_v030.py diff --git a/glsim/engine.py b/glsim/engine.py index 828f6e0..fe00bdc 100644 --- a/glsim/engine.py +++ b/glsim/engine.py @@ -115,7 +115,7 @@ def create_snapshot(self) -> int: "state_gl_to_hash": dict(self.state._gl_to_hash), "state_eth_hash_to_hash": dict(self.state._eth_hash_to_hash), "instances_keys": set(self._instances.keys()), - "storages_keys": set(self._storages.keys()), + "storages": {key: manager.snapshot() for key, manager in self._storages.items()}, "classes_keys": set(self._classes.keys()), "state_time_offset": self.state._time_offset_seconds, } @@ -140,8 +140,12 @@ def restore_snapshot(self, snapshot_id: int) -> bool: if key not in snap["instances_keys"]: del self._instances[key] for key in list(self._storages.keys()): - if key not in snap["storages_keys"]: + if key not in snap["storages"]: del self._storages[key] + for key, storage_data in snap["storages"].items(): + manager = self._storages.get(key) + if manager is not None: + manager.restore(storage_data) for key in list(self._classes.keys()): if key not in snap["classes_keys"]: del self._classes[key] diff --git a/glsim/server.py b/glsim/server.py index 5964ccf..88ef6c3 100644 --- a/glsim/server.py +++ b/glsim/server.py @@ -863,10 +863,13 @@ def _rpc_sim_call_sdk(state: StateStore, engine: SimEngine, params: dict) -> Any _install_sim_config_mocks(engine, sim_config) _apply_time_context(engine, state, sim_config) + snapshot_id = engine.create_snapshot() try: result = engine.call_method(to, method, args, kwargs, sender) finally: _clear_sim_config_mocks(engine) + engine.restore_snapshot(snapshot_id) + engine._snapshots.pop(snapshot_id, None) result_bytes = encode_calldata_result(result) # Return a simplified transaction receipt diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index 869f72a..b6a5914 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -207,6 +207,8 @@ def _extract_release_tree(tarball_path: Path, version: str) -> Path: tree = CACHE_DIR / "trees" / version if (tree / ".extracted").exists(): return tree + if tree.exists(): + shutil.rmtree(tree) trees = CACHE_DIR / "trees" trees.mkdir(parents=True, exist_ok=True) # Extract into a process-unique dir, mark it complete, then publish with an @@ -297,8 +299,13 @@ def setup_sdk_paths( embeddings_dir: Optional[Path] = None proto_dir: Optional[Path] = None if embeddings_hash: - embeddings_dir = extract_runner(tarball, EMBEDDINGS_TYPE, embeddings_hash, version) - proto_hash = runner_deps.get(PROTOBUF_TYPE) + embeddings_dir = extract_runner( + tarball, EMBEDDINGS_TYPE, embeddings_hash, version + ) + embeddings_deps = parse_runner_manifest(embeddings_dir) + proto_hash = embeddings_deps.get(PROTOBUF_TYPE) or runner_deps.get( + PROTOBUF_TYPE + ) if proto_hash: proto_dir = extract_runner(tarball, PROTOBUF_TYPE, proto_hash, version) diff --git a/tests/test_bug_hunt_v030.py b/tests/test_bug_hunt_v030.py new file mode 100644 index 0000000..be360c6 --- /dev/null +++ b/tests/test_bug_hunt_v030.py @@ -0,0 +1,174 @@ +"""Regression tests for high-confidence defects found on v0.30-dev. + +These tests are intentionally red until the corresponding production defects +are fixed. +""" + +from __future__ import annotations + +import io +import json +import sys +import tarfile +from pathlib import Path + +import pytest +import rlp + +from genlayer_py.abi import calldata +from gltest.direct import sdk_loader + + +STORAGE_CONTRACT = str( + Path(__file__).parent / "examples" / "contracts" / "storage.py" +) + + +@pytest.fixture +def glsim_client(): + from glsim.server import create_app + from starlette.testclient import TestClient + + app = create_app( + num_validators=1, + llm_provider=None, + use_browser=False, + verbose=True, + ) + with TestClient(app) as client: + yield client + + +def _rpc(client, method, params=None): + payload = {"jsonrpc": "2.0", "method": method, "id": 1} + if params is not None: + payload["params"] = params + response = client.post("/api", json=payload) + assert response.status_code == 200 + body = response.json() + assert "error" not in body, body.get("error") + return body["result"] + + +def _deploy_storage(client, initial_value): + return _rpc( + client, + "sim_deploy", + {"code_path": STORAGE_CONTRACT, "args": [initial_value]}, + )["contract_address"] + + +def _sdk_write_request(address, value): + encoded_call = calldata.encode( + {"method": "update_storage", "args": [value], "kwargs": {}} + ) + return { + "to": address, + "from": "0x1111111111111111111111111111111111111111", + "data": "0x" + rlp.encode([encoded_call, b"\x00"]).hex(), + } + + +def test_fee_estimation_does_not_persist_the_simulated_write(glsim_client): + address = _deploy_storage(glsim_client, "before") + + _rpc( + glsim_client, + "sim_estimateTransactionFees", + [_sdk_write_request(address, "simulated")], + ) + + read = _rpc( + glsim_client, + "sim_read", + {"to": address, "method": "get_storage"}, + ) + assert read["result"] == "before" + + +def test_restore_snapshot_reverts_existing_contract_storage(glsim_client): + address = _deploy_storage(glsim_client, "before") + snapshot_id = _rpc(glsim_client, "sim_createSnapshot") + + _rpc( + glsim_client, + "sim_call", + { + "to": address, + "method": "update_storage", + "args": ["after"], + }, + ) + _rpc(glsim_client, "sim_restoreSnapshot", [snapshot_id]) + + read = _rpc( + glsim_client, + "sim_read", + {"to": address, "method": "get_storage"}, + ) + assert read["result"] == "before" + + +def test_setup_sdk_paths_loads_protobuf_from_embeddings_manifest( + monkeypatch, tmp_path +): + contract = tmp_path / "contract.py" + contract.write_text( + '# {"Depends": "py-lib-genlayer-embeddings:embedhash"}\n', + encoding="utf-8", + ) + runner_dir = tmp_path / "runner" + std_dir = tmp_path / "std" + embeddings_dir = tmp_path / "embeddings" + protobuf_dir = tmp_path / "protobuf" + for path in (runner_dir, std_dir, embeddings_dir, protobuf_dir): + path.mkdir() + + directories = { + sdk_loader.RUNNER_TYPE: runner_dir, + sdk_loader.STD_LIB_TYPE: std_dir, + sdk_loader.EMBEDDINGS_TYPE: embeddings_dir, + sdk_loader.PROTOBUF_TYPE: protobuf_dir, + } + + def fake_extract(_tarball, runner_type, runner_hash=None, version=None): + return directories[runner_type] + + def fake_manifest(path): + if path == runner_dir: + return {sdk_loader.STD_LIB_TYPE: "stdhash"} + if path == embeddings_dir: + return {sdk_loader.PROTOBUF_TYPE: "protohash"} + return {} + + monkeypatch.setenv("GENVM_PREBUILT_DIR", str(tmp_path / "prebuilt")) + monkeypatch.setattr(sdk_loader, "extract_runner", fake_extract) + monkeypatch.setattr(sdk_loader, "parse_runner_manifest", fake_manifest) + monkeypatch.setattr(sys, "path", list(sys.path)) + + added = sdk_loader.setup_sdk_paths(contract) + + assert protobuf_dir in added + + +def test_release_tree_recovers_from_an_incomplete_cached_extraction( + monkeypatch, tmp_path +): + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path / "cache") + version = "v0.6.0" + stale_tree = sdk_loader.CACHE_DIR / "trees" / version + stale_tree.mkdir(parents=True) + (stale_tree / "partial-download").write_text("incomplete", encoding="utf-8") + + tarball = tmp_path / "bundle.tar.xz" + payload = b"complete" + with tarfile.open(tarball, "w:xz") as archive: + info = tarfile.TarInfo("payload") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + + tree = sdk_loader._extract_release_tree(tarball, version) + + assert (tree / ".extracted").is_file() + assert (tree / "payload").read_bytes() == payload + assert not (tree / "partial-download").exists() From 179b0b94a979648aa6c27063d9755d3264f9a87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Sun, 26 Jul 2026 18:55:58 +0100 Subject: [PATCH 09/16] fix: drain every queued glsim PostMessage (#103) --- glsim/engine.py | 45 +++++++++++++------------- tests/glsim/test_post_message_queue.py | 44 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 tests/glsim/test_post_message_queue.py diff --git a/glsim/engine.py b/glsim/engine.py index fe00bdc..4c62021 100644 --- a/glsim/engine.py +++ b/glsim/engine.py @@ -291,32 +291,33 @@ def call_method( finally: self._call_depth -= 1 - # Drain exactly one queued PostMessage at the top level only. - # The _draining flag prevents the drained call from draining further, - # matching real GenLayer where PostMessage is async (next block). + # Once the parent call completes, execute every message it emitted. + # Messages emitted by a drained child are appended and handled by the + # same loop, while _draining prevents recursive drain loops. if self._call_depth == 0 and not self._draining and self._post_queue: - msg = self._post_queue.pop(0) - self._post_queue.clear() - self._draining = True - print(f"[PostMessage DRAIN] executing {msg['method']} on {msg['address']} (sender={msg.get('sender')})") - try: - self.call_method( - msg['address'], msg['method'], - msg.get('args', []), msg.get('kwargs', {}), - sender=msg.get('sender'), - ) - print(f"[PostMessage DRAIN] {msg['method']} completed OK") - except Exception as e: - print(f"[PostMessage DRAIN] {msg['method']} ERROR: {e}") - self.vm._trace(f"PostMessage error: {e}") - finally: - self._draining = False - elif self._call_depth == 0 and not self._draining: - if not self._post_queue: - pass # No PostMessages queued (normal for reads) + self._drain_post_queue() return result + def _drain_post_queue(self) -> None: + self._draining = True + try: + while self._post_queue: + msg = self._post_queue.pop(0) + print(f"[PostMessage DRAIN] executing {msg['method']} on {msg['address']} (sender={msg.get('sender')})") + try: + self.call_method( + msg['address'], msg['method'], + msg.get('args', []), msg.get('kwargs', {}), + sender=msg.get('sender'), + ) + print(f"[PostMessage DRAIN] {msg['method']} completed OK") + except Exception as e: + print(f"[PostMessage DRAIN] {msg['method']} ERROR: {e}") + self.vm._trace(f"PostMessage error: {e}") + finally: + self._draining = False + def get_schema(self, contract_address: str) -> Optional[Dict]: """Get the ABI/schema for a deployed contract.""" contract = self.state.get_contract(contract_address) diff --git a/tests/glsim/test_post_message_queue.py b/tests/glsim/test_post_message_queue.py new file mode 100644 index 0000000..241a701 --- /dev/null +++ b/tests/glsim/test_post_message_queue.py @@ -0,0 +1,44 @@ +from glsim.engine import SimEngine +from glsim.state import StateStore + + +def test_drain_post_queue_executes_all_messages_in_order(monkeypatch): + engine = SimEngine(StateStore()) + engine._post_queue = [ + { + "address": "0x01", + "method": "first", + "args": [1], + "kwargs": {}, + "sender": "0xaa", + }, + { + "address": "0x02", + "method": "second", + "args": [2], + "kwargs": {}, + "sender": "0xbb", + }, + ] + calls = [] + + def fake_call(address, method, args, kwargs, sender): + calls.append((address, method, args, sender)) + if method == "first": + engine._post_queue.append( + { + "address": "0x03", + "method": "nested", + "args": [3], + "kwargs": {}, + "sender": "0xcc", + } + ) + + monkeypatch.setattr(engine, "call_method", fake_call) + + engine._drain_post_queue() + + assert [call[1] for call in calls] == ["first", "second", "nested"] + assert engine._post_queue == [] + assert engine._draining is False From ac525f553c8f1d5c372481780eaff5fb1f7d01a6 Mon Sep 17 00:00:00 2001 From: Kira Date: Tue, 25 Aug 2026 04:15:56 +0900 Subject: [PATCH 10/16] =?UTF-8?q?chore(genvm):=20migrate=20to=20GenVM=20v0?= =?UTF-8?q?.3.0-rc7=20SDK=20APIs=20=E2=AC=86=EF=B8=8F=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(genvm): migrate to the v0.3 gl_call ABI ⬆️ Squashed: the v0.3.0-rc7 SDK API migration and the follow-up rename of the raw gl_call request tags to their v1 names. * fix(glsim): accept both cross-call ABIs * fix(genvm): give VecDB its explicit distance metric 🐛 The pinned embeddings runner's `VecDB` takes the distance metric as a fourth type parameter. Three arguments now fail at contract import, which leaves the deploy without state rather than reporting a type error. --------- Co-authored-by: Edgars --- glsim/README.md | 4 +-- glsim/engine.py | 10 +++++- gltest/direct/sdk_loader.py | 32 ++++++++++++++----- gltest/direct/vm.py | 2 +- gltest/direct/wasi_mock.py | 10 +++++- .../contracts/intelligent_oracle_factory.py | 2 +- tests/examples/contracts/log_indexer.py | 4 ++- .../contracts/multi_file_contract/__init__.py | 2 +- .../contracts/multi_tenant_storage.py | 2 +- tests/glsim/deterministic_factory_contract.py | 2 +- tests/gltest_direct/test_direct_runner.py | 11 ++++--- 11 files changed, 58 insertions(+), 23 deletions(-) diff --git a/glsim/README.md b/glsim/README.md index 4053253..47ba9ac 100644 --- a/glsim/README.md +++ b/glsim/README.md @@ -145,9 +145,9 @@ Contracts that read sibling files via `open("/contract/OtherModule.py")` work ### Cross-Contract Calls Contracts using `gl.contract.deploy()`, `gl.contract.get_at().view()`, and `gl.contract.get_at().emit()` work. glsim handles: -- **DeployContract** — deploys child contract with isolated storage +- **EmitInternalDeployMessage** — deploys child contract with isolated storage - **CallContract** — calls method on deployed contract, returns result -- **PostMessage** — fire-and-forget call (no return value) +- **EmitInternalMessage** — fire-and-forget call (no return value) ### GenVM Library Stubs diff --git a/glsim/engine.py b/glsim/engine.py index 4c62021..9693134 100644 --- a/glsim/engine.py +++ b/glsim/engine.py @@ -614,14 +614,22 @@ def _install_live_handlers(self) -> None: self.vm._live_llm_handler = self._llm_handler def install_cross_contract_hook(self) -> None: - """Install gl_call hook for cross-contract calls (DeployContract, CallContract, PostMessage).""" + """Install gl_call hooks for current and rc7 cross-contract calls.""" engine = self def hook(vm, request): + if "EmitInternalDeployMessage" in request: + return engine._handle_deploy_in_contract( + vm, request["EmitInternalDeployMessage"] + ) if "DeployContract" in request: return engine._handle_deploy_in_contract(vm, request["DeployContract"]) if "CallContract" in request: return engine._handle_call_in_contract(vm, request["CallContract"]) + if "EmitInternalMessage" in request: + return engine._handle_post_in_contract( + vm, request["EmitInternalMessage"] + ) if "PostMessage" in request: return engine._handle_post_in_contract(vm, request["PostMessage"]) return None diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index b6a5914..d463270 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -11,6 +11,7 @@ import json import shutil import tarfile +import zipfile import platform import tempfile import urllib.error @@ -42,6 +43,9 @@ def _host_release_asset() -> str: GENVM_VERSION_ENV = "GENVM_VERSION" FALLBACK_VERSION = "v0.6.0-rc0" +# v0.3 runner trees use .zip; the v0.2 legacy-runners tree uses .tar. +RUNNER_ARCHIVE_EXTS = (".tar", ".zip") + RUNNER_TYPE = "py-genlayer" STD_LIB_TYPE = "py-lib-genlayer-std" EMBEDDINGS_TYPE = "py-lib-genlayer-embeddings" @@ -179,21 +183,33 @@ def _extract_local_runner( root: Path, runner_type: str, runner_hash: Optional[str] ) -> Path: """Extract a runner from a local prebuilt GenVM tree (GENVM_PREBUILT_DIR); globs - any *runners* dir so runners/ and executor//legacy-runners/ both match.""" + any *runners* dir so runners/ and executor//legacy-runners/ both match. + v0.3 runners ship as .zip, the v0.2 legacy tree still ships .tar.""" sub = ( - f"{runner_hash[:2]}/{runner_hash[2:]}.tar" + f"{runner_hash[:2]}/{runner_hash[2:]}" if runner_hash and runner_hash.lower() != "latest" - else "*/*.tar" + else "*/*" + ) + hits = sorted( + hit + for ext in RUNNER_ARCHIVE_EXTS + for hit in root.glob(f"**/*runners*/{runner_type}/{sub}{ext}") ) - hits = sorted(root.glob(f"**/*runners*/{runner_type}/{sub}")) if not hits: raise FileNotFoundError(f"runner {runner_type}:{runner_hash} not under {root}") - tar = hits[-1] - dest = CACHE_DIR / "extracted" / "local" / runner_type / (tar.parent.name + tar.stem) + archive = hits[-1] + dest = ( + CACHE_DIR / "extracted" / "local" / runner_type + / (archive.parent.name + archive.stem) + ) if not dest.exists(): dest.mkdir(parents=True, exist_ok=True) - with tarfile.open(tar, "r:") as inner: - inner.extractall(dest, filter="data") + if archive.suffix == ".zip": + with zipfile.ZipFile(archive) as inner: + inner.extractall(dest) + else: + with tarfile.open(archive, "r:") as inner: + inner.extractall(dest, filter="data") return dest diff --git a/gltest/direct/vm.py b/gltest/direct/vm.py index a88f5af..c54e113 100644 --- a/gltest/direct/vm.py +++ b/gltest/direct/vm.py @@ -202,7 +202,7 @@ class VMContext: _live_web_handler: Optional[Any] = None _live_llm_handler: Optional[Any] = None - # Cross-contract call hook (for glsim — handles DeployContract/CallContract/PostMessage) + # Cross-contract call hook (glsim accepts current and rc7 request names). _gl_call_hook: Optional[Any] = None # Debug tracing diff --git a/gltest/direct/wasi_mock.py b/gltest/direct/wasi_mock.py index 5c490e2..e463210 100644 --- a/gltest/direct/wasi_mock.py +++ b/gltest/direct/wasi_mock.py @@ -84,7 +84,15 @@ def get_self_balance() -> int: return vm._balances.get(addr_bytes, 0) -_CROSS_CONTRACT_OPS = frozenset({"DeployContract", "CallContract", "PostMessage"}) +_CROSS_CONTRACT_OPS = frozenset( + { + "DeployContract", + "EmitInternalDeployMessage", + "CallContract", + "PostMessage", + "EmitInternalMessage", + } +) def gl_call(data: bytes, /) -> int: diff --git a/tests/examples/contracts/intelligent_oracle_factory.py b/tests/examples/contracts/intelligent_oracle_factory.py index c49e335..f92adf4 100644 --- a/tests/examples/contracts/intelligent_oracle_factory.py +++ b/tests/examples/contracts/intelligent_oracle_factory.py @@ -38,7 +38,7 @@ def create_new_prediction_market( earliest_resolution_date, ], salt_nonce=registered_contracts + 1, - on="accepted", + on="decided", ) print("contract_address", contract_address) print("contract_address type", type(contract_address)) diff --git a/tests/examples/contracts/log_indexer.py b/tests/examples/contracts/log_indexer.py index 5637eb7..e2a78ba 100644 --- a/tests/examples/contracts/log_indexer.py +++ b/tests/examples/contracts/log_indexer.py @@ -23,7 +23,9 @@ class StoreValue: # contract class class LogIndexer(gl.contract.Contract): - vector_store: gle.VecDB[np.float32, typing.Literal[384], StoreValue] + vector_store: gle.VecDB[ + np.float32, typing.Literal[384], StoreValue, gle.EuclideanDistance + ] def __init__(self): pass diff --git a/tests/examples/contracts/multi_file_contract/__init__.py b/tests/examples/contracts/multi_file_contract/__init__.py index 8927ab5..9d23dcb 100644 --- a/tests/examples/contracts/multi_file_contract/__init__.py +++ b/tests/examples/contracts/multi_file_contract/__init__.py @@ -12,7 +12,7 @@ def __init__(self): args=["123"], salt_nonce=1, value=0, - on="accepted", + on="decided", ) @gl.public.write diff --git a/tests/examples/contracts/multi_tenant_storage.py b/tests/examples/contracts/multi_tenant_storage.py index 2bcd15d..a1bcd75 100644 --- a/tests/examples/contracts/multi_tenant_storage.py +++ b/tests/examples/contracts/multi_tenant_storage.py @@ -46,6 +46,6 @@ def update_storage(self, new_storage: str) -> None: self.available_storage_contracts.pop() contract_to_use = self.mappings[gl.message.sender_address] - gl.contract.get_at(contract_to_use).emit(on="accepted").update_storage( + gl.contract.get_at(contract_to_use).emit(on="decided").update_storage( new_storage ) diff --git a/tests/glsim/deterministic_factory_contract.py b/tests/glsim/deterministic_factory_contract.py index ef51fcf..87db3f5 100644 --- a/tests/glsim/deterministic_factory_contract.py +++ b/tests/glsim/deterministic_factory_contract.py @@ -25,7 +25,7 @@ def deploy_child(self, salt: int) -> str: args=[], kwargs={}, salt_nonce=salt, - on="accepted", + on="decided", ) self.child_address = child_address.as_hex return self.child_address diff --git a/tests/gltest_direct/test_direct_runner.py b/tests/gltest_direct/test_direct_runner.py index 42f0036..95baab3 100644 --- a/tests/gltest_direct/test_direct_runner.py +++ b/tests/gltest_direct/test_direct_runner.py @@ -250,7 +250,8 @@ class TestNondetRestrictions: """Tests that cross-contract calls are forbidden inside nondet context. GenVM raises SystemError: 6 (forbidden) when contract code attempts - cross-contract calls (DeployContract, CallContract, PostMessage) inside + cross-contract calls (EmitInternalDeployMessage, CallContract, + EmitInternalMessage) inside eq_principle/run_nondet. These tests verify gltest enforces the same restriction in direct mode. """ @@ -278,7 +279,7 @@ def bad_leader(): gl_vm.run_nondet(bad_leader, lambda r: True) def test_deploy_contract_forbidden_in_nondet(self, direct_vm, direct_deploy): - """DeployContract inside run_nondet raises RuntimeError.""" + """EmitInternalDeployMessage inside run_nondet raises RuntimeError.""" direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") import genlayer.vm as gl_vm @@ -286,7 +287,7 @@ def test_deploy_contract_forbidden_in_nondet(self, direct_vm, direct_deploy): from gltest.direct import wasi_mock def bad_leader(): - request = {"DeployContract": {"code": b"pass", "calldata": {}}} + request = {"EmitInternalDeployMessage": {"code": b"pass", "calldata": {}}} wasi_mock.gl_call(calldata.encode(request)) return "should not reach" @@ -294,7 +295,7 @@ def bad_leader(): gl_vm.run_nondet(bad_leader, lambda r: True) def test_post_message_forbidden_in_nondet(self, direct_vm, direct_deploy): - """PostMessage inside run_nondet raises RuntimeError.""" + """EmitInternalMessage inside run_nondet raises RuntimeError.""" direct_deploy(str(CONTRACTS_DIR / "storage.py"), "v") import genlayer.vm as gl_vm @@ -303,7 +304,7 @@ def test_post_message_forbidden_in_nondet(self, direct_vm, direct_deploy): def bad_leader(): request = { - "PostMessage": { + "EmitInternalMessage": { "address": b"\x00" * 20, "calldata": {"method": "bar", "args": []}, } From 7d6de26cf0218d599545afb3b01f0c88ee4cafe3 Mon Sep 17 00:00:00 2001 From: KirilA Date: Tue, 1 Sep 2026 01:33:34 +0300 Subject: [PATCH 11/16] fix(gltest): align with layered genlayer-py lifecycle types (#108) genlayer-py dropped the public `TransactionStatus` enum when it layered the consumer lifecycle over the raw protocol status. gltest imports the protocol enum from the new surface and keeps `TransactionStatus` as an alias of it so downstream suites that already pass it to `wait_transaction_status` keep working; internal code uses the protocol name and `wait_until`. Success assertions read the state-discriminated `lifecycle` field and fall back to the flat status that pre-lifecycle receipts still carry, so gltest works against both SDK generations. --- gltest/assertions.py | 20 ++++++++++- gltest/contracts/contract.py | 8 ++--- gltest/contracts/contract_factory.py | 14 +++++--- gltest/contracts/contract_functions.py | 10 ++++-- gltest/contracts/wait.py | 17 +++++---- gltest/types.py | 17 ++++++++- tests/gltest/assertions/test_assertions.py | 41 ++++++++++++++++++++++ 7 files changed, 107 insertions(+), 20 deletions(-) diff --git a/gltest/assertions.py b/gltest/assertions.py index 14a66bd..5899d18 100644 --- a/gltest/assertions.py +++ b/gltest/assertions.py @@ -9,6 +9,7 @@ ACCEPTED_STATUSES = {"ACCEPTED", "FINALIZED", "5", "7"} +ACCEPTED_LIFECYCLE_OUTCOMES = {None, "accepted"} SUCCESS_RESULTS = {"FINISHED_WITH_RETURN", "1"} @@ -19,9 +20,26 @@ def _string_value(value: Any) -> Optional[str]: return str(enum_value) +def _accepted_lifecycle(result: GenLayerTransaction) -> Optional[bool]: + """Read the layered lifecycle, or None when the receipt has none.""" + lifecycle = result.get("lifecycle") + if not isinstance(lifecycle, dict): + return None + state = lifecycle.get("state") + if state == "decided": + return lifecycle.get("outcome") == "accepted" + if state == "finalized": + return lifecycle.get("outcome") in ACCEPTED_LIFECYCLE_OUTCOMES + return False + + def _has_accepted_status(result: GenLayerTransaction) -> bool: + accepted_lifecycle = _accepted_lifecycle(result) + if accepted_lifecycle is not None: + return accepted_lifecycle + # Receipts from a pre-lifecycle SDK still carry a flat status. status = _string_value(result.get("status_name", result.get("status"))) - return status in ACCEPTED_STATUSES + return status is not None and status.upper() in ACCEPTED_STATUSES def _leader_receipt(result: GenLayerTransaction) -> Optional[dict]: diff --git a/gltest/contracts/contract.py b/gltest/contracts/contract.py index de0996c..7378695 100644 --- a/gltest/contracts/contract.py +++ b/gltest/contracts/contract.py @@ -6,7 +6,7 @@ from gltest.types import ( CalldataEncodable, GenLayerTransaction, - TransactionStatus, + ProtocolTransactionStatus, TransactionHashVariant, TransactionContext, ) @@ -88,11 +88,11 @@ def transact_method( fees: Optional[Dict[str, Any]] = None, fee_value: Optional[int] = None, wait_until: Optional[Literal["decided", "finalized"]] = None, - wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_transaction_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, wait_triggered_transactions: bool = False, - wait_triggered_transactions_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_triggered_transactions_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, transaction_context: Optional[TransactionContext] = None, ): """ @@ -252,7 +252,7 @@ def appeal( self, tx_hash: str, value: int = 0, - wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_transaction_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, wait_until: Optional[Literal["decided", "finalized"]] = None, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, diff --git a/gltest/contracts/contract_factory.py b/gltest/contracts/contract_factory.py index c200dd3..77a4d84 100644 --- a/gltest/contracts/contract_factory.py +++ b/gltest/contracts/contract_factory.py @@ -19,7 +19,11 @@ from genlayer_py.types import SimConfig from .contract import Contract from gltest.logging import logger -from gltest.types import TransactionStatus, GenLayerTransaction, CalldataEncodable +from gltest.types import ( + ProtocolTransactionStatus, + GenLayerTransaction, + CalldataEncodable, +) from gltest.assertions import tx_execution_failed from gltest.exceptions import DeploymentError from gltest_cli.config.general import get_general_config @@ -130,9 +134,9 @@ def deploy( wait_until: Optional[Literal["decided", "finalized"]] = None, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, - wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_transaction_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, wait_triggered_transactions: bool = False, - wait_triggered_transactions_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_triggered_transactions_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, transaction_context: Optional[TransactionContext] = None, ) -> Contract: """ @@ -172,9 +176,9 @@ def deploy_contract_tx( wait_until: Optional[Literal["decided", "finalized"]] = None, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, - wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_transaction_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, wait_triggered_transactions: bool = False, - wait_triggered_transactions_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_triggered_transactions_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, transaction_context: Optional[TransactionContext] = None, ) -> GenLayerTransaction: """ diff --git a/gltest/contracts/contract_functions.py b/gltest/contracts/contract_functions.py index 5965087..e1ffe7d 100644 --- a/gltest/contracts/contract_functions.py +++ b/gltest/contracts/contract_functions.py @@ -1,6 +1,10 @@ from dataclasses import dataclass from typing import Callable, Optional, Dict, Any, Literal -from gltest.types import TransactionStatus, TransactionHashVariant, TransactionContext +from gltest.types import ( + ProtocolTransactionStatus, + TransactionHashVariant, + TransactionContext, +) @dataclass @@ -31,11 +35,11 @@ def transact( fees: Optional[Dict[str, Any]] = None, fee_value: Optional[int] = None, wait_until: Optional[Literal["decided", "finalized"]] = None, - wait_transaction_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_transaction_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, wait_interval: Optional[int] = None, wait_retries: Optional[int] = None, wait_triggered_transactions: bool = False, - wait_triggered_transactions_status: TransactionStatus = TransactionStatus.ACCEPTED, + wait_triggered_transactions_status: ProtocolTransactionStatus = ProtocolTransactionStatus.ACCEPTED, transaction_context: Optional[TransactionContext] = None, ): """Executes a state-changing contract method through consensus. Returns the transaction receipt.""" diff --git a/gltest/contracts/wait.py b/gltest/contracts/wait.py index f80a76e..5674235 100644 --- a/gltest/contracts/wait.py +++ b/gltest/contracts/wait.py @@ -1,21 +1,26 @@ import inspect -from typing import Callable, Literal +from typing import Any, Callable, Literal -from gltest.types import TransactionStatus +from gltest.types import ProtocolTransactionStatus + + +def _status_text(status: Any) -> str: + """Normalize an enum member, its value, or a bare string to one form.""" + return str(getattr(status, "value", status)).upper() def wait_until_from_status( - status: TransactionStatus, + status: ProtocolTransactionStatus, ) -> Literal["decided", "finalized"]: - if status == TransactionStatus.FINALIZED: + if _status_text(status) == _status_text(ProtocolTransactionStatus.FINALIZED): return "finalized" return "decided" def _status_from_wait_until(wait_until: Literal["decided", "finalized"]): if wait_until == "finalized": - return TransactionStatus.FINALIZED - return TransactionStatus.ACCEPTED + return ProtocolTransactionStatus.FINALIZED + return ProtocolTransactionStatus.ACCEPTED def _accepts_var_kwargs(call: Callable) -> bool: diff --git a/gltest/types.py b/gltest/types.py index 694ebc0..ada6715 100644 --- a/gltest/types.py +++ b/gltest/types.py @@ -2,12 +2,27 @@ from genlayer_py.types import ( CalldataAddress, GenLayerTransaction, - TransactionStatus, CalldataEncodable, TransactionHashVariant, ) from typing import List, TypedDict, Dict, Any +try: + # genlayer-py layers the consumer lifecycle over the raw protocol status. + from genlayer_py.types.transactions import ProtocolTransactionStatus +except ImportError: # genlayer-py before the lifecycle layering + from genlayer_py.types import TransactionStatus as ProtocolTransactionStatus + +try: + from genlayer_py.types import TransactionLifecycle +except ImportError: # genlayer-py before the lifecycle layering + TransactionLifecycle = Dict[str, Any] + +# gltest keeps `TransactionStatus` as part of its own public API for the test +# suites that already pass it to `wait_transaction_status`. Internal code uses +# the protocol name; new code should express waits with `wait_until` instead. +TransactionStatus = ProtocolTransactionStatus + class MockedLLMResponse(TypedDict): """Maps prompts to responses""" diff --git a/tests/gltest/assertions/test_assertions.py b/tests/gltest/assertions/test_assertions.py index 9317a3b..9ed7a4d 100644 --- a/tests/gltest/assertions/test_assertions.py +++ b/tests/gltest/assertions/test_assertions.py @@ -115,6 +115,47 @@ def test_undetermined_transaction_is_not_successful(): assert tx_execution_failed(transaction) is True +def test_decided_lifecycle_transaction_is_successful(): + transaction = { + "lifecycle": {"state": "decided", "outcome": "accepted"}, + "tx_execution_result_name": "FINISHED_WITH_RETURN", + } + + assert tx_execution_succeeded(transaction) is True + assert tx_execution_failed(transaction) is False + + +def test_finalized_lifecycle_transaction_is_successful(): + transaction = { + "lifecycle": {"state": "finalized", "outcome": "accepted"}, + "consensus_data": {"leader_receipt": [{"execution_result": "SUCCESS"}]}, + } + + assert tx_execution_succeeded(transaction) is True + assert tx_execution_failed(transaction) is False + + +def test_undetermined_lifecycle_transaction_is_not_successful(): + transaction = { + "lifecycle": {"state": "decided", "outcome": "undetermined"}, + "tx_execution_result_name": "FINISHED_WITH_RETURN", + "consensus_data": {"leader_receipt": [{"execution_result": "SUCCESS"}]}, + } + + assert tx_execution_succeeded(transaction) is False + assert tx_execution_failed(transaction) is True + + +def test_processing_lifecycle_transaction_is_not_successful(): + transaction = { + "lifecycle": {"state": "processing", "phase": "revealing"}, + "tx_execution_result_name": "FINISHED_WITH_RETURN", + } + + assert tx_execution_succeeded(transaction) is False + assert tx_execution_failed(transaction) is True + + def test_error_transaction_is_not_successful(): transaction = { "status": "ACCEPTED", From 742c357e239c09db02aec9e27de965c5241513dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 3 Sep 2026 14:46:22 +0100 Subject: [PATCH 12/16] build: prepare v0.30 release candidates (#110) Require the published genlayer-py 0.19 RC used by the lifecycle APIs on this train. Add guarded RC/final branch routing, exact native-CI and tag provenance checks, prerelease GitHub releases, and fast regression coverage for the release policy. --- .claude/skills/release/SKILL.md | 25 +++-- .github/workflows/publish.yml | 31 ++++-- .github/workflows/tests.yml | 3 + CLAUDE.md | 2 +- CONTRIBUTING.md | 4 +- docs/BRANCHING.md | 7 +- pyproject.toml | 2 +- releaserc.toml | 11 ++- scripts/release.sh | 165 ++++++++++++++++++-------------- scripts/release_version.py | 124 ++++++++++++++++++++++++ tests/test_release_version.py | 112 ++++++++++++++++++++++ uv.lock | 8 +- 12 files changed, 389 insertions(+), 105 deletions(-) create mode 100755 scripts/release_version.py create mode 100644 tests/test_release_version.py diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index d89b967..69ccdf1 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,11 +1,11 @@ --- name: release -description: Cut a release of genlayer-testing-suite. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. Use when a human asks "release v0.29.x" or "ship a new version". +description: Cut a release or release candidate of genlayer-testing-suite. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. --- # Release skill — genlayer-testing-suite -This repo follows a branch-per-major release model. There is no auto-bump on push. A release happens when a human (or you on their behalf) runs `scripts/release.sh` on the target stable branch. +This repo follows a branch-per-release-line model. There is no auto-bump on push. A final release is cut from its stable branch; an RC is cut from the matching `*-dev` integration branch. ## When to use this skill @@ -18,12 +18,13 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re ## What this repo's release model expects -- Branches are named after the major they ship: `v0.29` (current stable). When `v0.19` opens, the previous `v0.29` stays read-only for back-ports. +- Branches are named after the release line they ship: `v0.29` (stable) and `v0.30-dev` (integration). When `v0.30` becomes stable, the previous `v0.29` stays available for back-ports. - Tags live within those branches: `v0.29.1`, `v0.29.2`, ... - **Semver-zero rule**: this package is still on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` IS a major bump. `scripts/release.sh` refuses both `minor` and `major` keywords without `--allow-major` while we're on 0.x. -- A major (= minor on 0.x) bump means cutting a new branch (`v0.19`) — not tagging on top of the current one. -- `CHANGELOG.md` is updated in the release commit (python-semantic-release with explicit version). -- `publish.yml` fires on the tag push and does the PyPI publish + GitHub Release. +- A major (= minor on 0.x) bump means cutting a new branch (`v0.30`) — not tagging on top of the current one. +- `CHANGELOG.md` is updated in the release commit by python-semantic-release; an explicit requested version must match the version computed from release history and conventional commits. +- Final tags are cut from `vX.Y`; RC tags such as `v0.30.0-rc.1` are cut from `vX.Y-dev`. +- `publish.yml` verifies that the tag is the current owning branch head, publishes to PyPI, and marks RC GitHub Releases as prereleases. ## Steps @@ -31,13 +32,15 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re - Which version? If unspecified, ask whether it's patch or explicit. - If they say "minor" or "major" while we're on 0.x, surface that this means cutting a new branch — confirm before proceeding. -2. **Switch to the target branch + sync.** +2. **Switch to the owning branch + sync.** ```bash git checkout v0.29 git pull --ff-only origin v0.29 ``` If the working tree isn't clean, stop and surface what's there. + For `v0.30.0-rc.1`, use `v0.30-dev` instead. The script rejects final versions on a dev branch and prereleases on a stable branch. + 3. **Verify the head is shippable.** - Latest CI green: ```bash @@ -50,9 +53,10 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re 4. **Run the release script.** ```bash - scripts/release.sh # or patch + scripts/release.sh # final on vX.Y + scripts/release.sh --allow-major # first RC of a new 0.x line ``` - It bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): vX.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. + First run the same command with `--dry-run`; it exercises all read-only preflight and version-policy checks. The real command bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): X.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. 5. **Watch the publish workflow.** ```bash @@ -69,7 +73,8 @@ If they ask "publish to PyPI directly" — refuse and point at this flow. The re ## Things to refuse - **Minor or major bump on 0.x without `--allow-major`**. Those are major bumps in semver-zero and belong on a new branch. -- **Releasing from `main`** — `main` is retired. +- **Releasing from `main`** — it is only a static/default alias, not a release branch. +- **A final tag from `*-dev`, or an RC tag from the stable branch** — the tag must belong to the exact owning branch. - **Hand-editing `pyproject.toml` to bump the version** — the script keeps pyproject, the CHANGELOG entry, the commit message, and the tag in lockstep. - **Publishing a tag where `publish.yml` failed** — fix the underlying issue, re-cut the release (delete the bad tag locally and on origin, re-run the script). diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3724609..222e68e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,17 +1,19 @@ name: Publish Package to PyPI # Tag-driven publish. The release is cut by a human (or Claude via the -# release skill) running scripts/release.sh on the target stable branch +# release skill) running scripts/release.sh on the owning version branch # — that script bumps pyproject.toml (and glsim/__init__.py), updates # CHANGELOG.md, commits, tags vX.Y.Z, and pushes both the branch commit # and the tag. This workflow fires on the tag push, runs tests, # sanity-checks the tag matches pyproject.toml, builds, and publishes # to PyPI. It never bumps or tags by itself. on: - workflow_dispatch: push: tags: - - "v*" + - "v*.*.*" + +permissions: + contents: write jobs: run-tests: @@ -34,16 +36,22 @@ jobs: - name: Install Python run: uv python install 3.12 - - name: Verify tag matches pyproject.toml version + - name: Verify tag, package version, and owning branch run: | TAG_VERSION="${GITHUB_REF_NAME#v}" PKG_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Tag ($TAG_VERSION) and pyproject.toml ($PKG_VERSION) disagree — refusing to publish." >&2 - echo "Re-cut the release via scripts/release.sh so the tag and the committed version match." >&2 + NORMALIZED_VERSION="$(python scripts/release_version.py verify-tag "$TAG_VERSION" "$PKG_VERSION")" + EXPECTED_BRANCH="$(python scripts/release_version.py branch "$TAG_VERSION")" + git fetch --no-tags origin \ + "refs/heads/$EXPECTED_BRANCH:refs/remotes/origin/$EXPECTED_BRANCH" + TAG_COMMIT="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")" + BRANCH_HEAD="$(git rev-parse "origin/$EXPECTED_BRANCH")" + if [ "$TAG_COMMIT" != "$BRANCH_HEAD" ]; then + echo "Tag $GITHUB_REF_NAME points to $TAG_COMMIT, but $EXPECTED_BRANCH is at $BRANCH_HEAD." >&2 + echo "Re-cut the release from the current owning branch head via scripts/release.sh." >&2 exit 1 fi - echo "Tag $GITHUB_REF_NAME matches pyproject.toml $PKG_VERSION." + echo "Tag $GITHUB_REF_NAME matches package $NORMALIZED_VERSION and $EXPECTED_BRANCH@$BRANCH_HEAD." - name: Clean previous builds run: rm -rf -- dist build *.egg-info @@ -64,6 +72,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + RELEASE_FLAGS=() + if [ "$(python scripts/release_version.py is-prerelease "$GITHUB_REF_NAME")" = "true" ]; then + RELEASE_FLAGS+=(--prerelease) + fi NOTES="$(awk -v ver="$GITHUB_REF_NAME" ' $0 ~ "^## \\[?" substr(ver, 2) {capture=1; next} capture && /^## / {exit} @@ -74,4 +86,5 @@ jobs: fi gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ - --notes "$NOTES" + --notes "$NOTES" \ + "${RELEASE_FLAGS[@]}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9af11b..ee6a644 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,3 +40,6 @@ jobs: # Unit tests only — integration tests here download the GenVM SDK. - name: Action | Run direct runner unit tests run: uv run gltest tests/gltest_direct/test_sdk_loader.py + + - name: Action | Validate release version policy + run: uv run pytest tests/test_release_version.py diff --git a/CLAUDE.md b/CLAUDE.md index f13d265..acb791f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,4 +168,4 @@ Multi-file contracts use a `runner.json` manifest: 2. Contracts are discovered automatically from the contracts directory 3. Transaction receipts include consensus information and triggered transactions 4. LLM-based contract methods use `gl.eq_principle_prompt_non_comparative()` -5. Version is managed automatically via semantic release (no manual updates) +5. Versions are updated only by `scripts/release.sh` via semantic release; do not edit them manually diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d81e6cc..755d454 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ the default/static GitHub branch. ## Releases -Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. See `.claude/skills/release/SKILL.md` for the full flow. +Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. Release candidates are cut from the active `*-dev` branch (for example, `v0.30.0-rc.1` from `v0.30-dev`), while final versions are cut from the matching stable branch. See `.claude/skills/release/SKILL.md` for the full flow. **Semver-zero rule**: this package is on 0.x, so minor IS the breaking-change boundary. `0.29 → 0.30` is a major bump and needs a new branch — the script refuses `minor`/`major` keywords without `--allow-major`. @@ -153,7 +153,7 @@ The project uses automated semantic versioning based on commit messages: | `feat!:`, `fix!:`, or `BREAKING CHANGE:` | **Major** version bump | 1.0.0 → 2.0.0 | | `docs:`, `style:`, `refactor:`, `test:`, `chore:`, `build:`, `ci:` | **No** version bump | Version stays the same | -**Important**: Never manually edit version numbers in `pyproject.toml` or other files. Releases are cut from the stable branch using the release automation described above. +**Important**: Never manually edit version numbers in `pyproject.toml` or other files. Final releases are cut from the stable branch and release candidates from its matching `*-dev` branch using the release automation described above. ### Improving Documentation diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 3d5ba44..d56d15a 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -41,8 +41,11 @@ When an integration train is ready, open a promotion PR from the integration branch to the matching stable branch, for example `v0.30-dev` to `v0.30`. That promotion PR is the release-readiness gate and must pass required -cross-repo `E2E Tests`. The actual package release is cut from the stable branch -using a version tag after the stable branch is ready. +cross-repo `E2E Tests`. The final package release is cut from the stable branch +using a version tag after the stable branch is ready. A release candidate may +be cut earlier from the matching integration branch; RC tags use +`vX.Y.Z-rc.N`, publish as PyPI prereleases, and never substitute for the +promotion PR's final release gate. ## `main` diff --git a/pyproject.toml b/pyproject.toml index bea205d..1b58363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "pytest", - "genlayer-py>=0.18.0,<0.20.0", + "genlayer-py>=0.19.0rc1,<0.20.0", "colorama>=0.4.6", "pyyaml", "python-dotenv" diff --git a/releaserc.toml b/releaserc.toml index ac4b94a..e0a3e19 100644 --- a/releaserc.toml +++ b/releaserc.toml @@ -11,11 +11,16 @@ allow_zero_version = true no_git_verify = false tag_format = "v{version}" -[semantic_release.branches.main] -match = "main" +[semantic_release.branches.stable] +match = "^v[0-9]+\\.[0-9]+$" prerelease_token = "rc" prerelease = false +[semantic_release.branches.dev] +match = "^v[0-9]+\\.[0-9]+-dev$" +prerelease_token = "rc" +prerelease = true + [semantic_release.changelog] exclude_commit_patterns = [] mode = "update" @@ -62,4 +67,4 @@ insecure = false [semantic_release.publish] dist_glob_patterns = ["dist/*"] -upload_to_vcs_release = true \ No newline at end of file +upload_to_vcs_release = true diff --git a/scripts/release.sh b/scripts/release.sh index 8abd0a2..04d87a7 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Cut a release on the current stable branch. +# Cut a release on the current owning version branch. # # Bumps pyproject.toml, updates CHANGELOG.md via python-semantic-release, # commits, tags vX.Y.Z, and pushes both the branch commit and the tag. @@ -7,38 +7,44 @@ # GitHub Release). # # Releases are deliberate. There is no auto-bump on push; only this -# script is supposed to create release tags. Run from the major branch -# you want to ship a release on (e.g. v0.29 for v0.29.x). +# script is supposed to create release tags. Run from the release line +# that owns the version: vX.Y for finals and vX.Y-dev for RCs. # # Usage: -# scripts/release.sh # explicit semver — recommended -# scripts/release.sh patch # 0.18.0 → 0.18.1 -# scripts/release.sh minor # 0.18.0 → 0.19.0 — refused unless --allow-major (see below) -# scripts/release.sh major # 0.18.0 → 1.0.0 — refused unless --allow-major +# scripts/release.sh # final release from vX.Y +# scripts/release.sh # release candidate from vX.Y-dev +# scripts/release.sh patch # 0.29.0 → 0.29.1 +# scripts/release.sh minor # 0.29.0 → 0.30.0 — needs --allow-major +# scripts/release.sh major # 0.29.0 → 1.0.0 — needs --allow-major # scripts/release.sh --allow-major +# scripts/release.sh --dry-run [--allow-major] # # Semver-zero rule: while the major is 0, the MINOR is the breaking- -# change boundary (per semver). 0.18 → 0.19 IS a major bump. The script -# refuses both `minor` and `major` keywords without --allow-major while -# the current major is 0. Patches stay automatic-friendly. +# change boundary. The script refuses minor/major changes without +# --allow-major. # # Pre-flight (each check refuses to proceed on failure): -# - On a v[.] branch (refuses on main / feature branches) +# - On vX.Y for a final or vX.Y-dev for a release candidate # - Working tree clean # - Local HEAD matches origin/ -# - Latest CI run on HEAD is green +# - Tests workflow on that exact HEAD is green set -euo pipefail ALLOW_MAJOR=0 -if [ "${1:-}" = "--allow-major" ]; then - ALLOW_MAJOR=1 +DRY_RUN=0 +while [[ "${1:-}" == --* ]]; do + case "$1" in + --allow-major) ALLOW_MAJOR=1 ;; + --dry-run) DRY_RUN=1 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac shift -fi +done VERSION_ARG="${1:-}" if [ -z "$VERSION_ARG" ]; then - echo "Usage: $0 [--allow-major] |patch|minor|major" >&2 + echo "Usage: $0 [--dry-run] [--allow-major] ||patch|minor|major" >&2 exit 2 fi @@ -46,16 +52,13 @@ repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" branch="$(git rev-parse --abbrev-ref HEAD)" -if ! [[ "$branch" =~ ^v[0-9]+(\.[0-9]+)?(-dev)?$ ]]; then +if ! [[ "$branch" =~ ^v[0-9]+\.[0-9]+(-dev)?$ ]]; then cat >&2 </dev/null 2>&1; then - status="$(gh run list --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" - case "$status" in - success) ;; - "" ) - echo "Warning: no CI run found for $local_sha on $branch. Continuing anyway." >&2 - ;; - *) - echo "Latest CI on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 - exit 1 - ;; - esac +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required to verify the release head's native tests." >&2 + exit 1 fi +status="$(gh run list --workflow tests.yml --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" +case "$status" in + success) ;; + "" ) + echo "No Tests workflow run found for $local_sha on $branch. Refusing to release an unverified head." >&2 + exit 1 + ;; + *) + echo "Latest Tests workflow on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 + exit 1 + ;; +esac -current_version="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" - -# Resolve to a concrete X.Y.Z so the major-bump guard can compare. +release_flags=() case "$VERSION_ARG" in major|minor|patch) - next_version="$(python3 - "$current_version" "$VERSION_ARG" <<'PY' -import sys -cur = sys.argv[1].split(".") -kind = sys.argv[2] -major, minor, patch = int(cur[0]), int(cur[1]), int(cur[2]) -if kind == "major": - print(f"{major+1}.0.0") -elif kind == "minor": - print(f"{major}.{minor+1}.0") -elif kind == "patch": - print(f"{major}.{minor}.{patch+1}") -PY -)" + release_flags+=("--$VERSION_ARG") ;; *) - next_version="$VERSION_ARG" + requested_version="$(python3 scripts/release_version.py normalize "$VERSION_ARG")" || exit 2 ;; esac -if ! [[ "$next_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Not a valid semver: $next_version" >&2 - exit 2 +if [[ "$branch" == *-dev ]]; then + release_flags+=(--as-prerelease --prerelease-token rc) +fi + +computed_raw="$( + uvx --from 'python-semantic-release==10.0.2' \ + semantic-release -c releaserc.toml version --print "${release_flags[@]}" +)" +next_version="$(python3 scripts/release_version.py normalize "$computed_raw")" || exit 2 + +if [[ -n "${requested_version:-}" && "$requested_version" != "$next_version" ]]; then + cat >&2 </dev/null + +last_tag="$(git describe --tags --abbrev=0 --match 'v*.*.*' 2>/dev/null || true)" +if [[ -z "$last_tag" ]]; then + echo "No previous release tag is reachable from $branch; refusing to infer release boundaries." >&2 + exit 1 +fi +last_version="$(python3 scripts/release_version.py normalize "$last_tag")" || exit 2 +cur_major="${last_version%%.*}" next_major="${next_version%%.*}" -cur_minor="$(echo "$current_version" | cut -d. -f2)" +cur_minor="$(echo "$last_version" | cut -d. -f2)" next_minor="$(echo "$next_version" | cut -d. -f2)" # Semver-zero: while major == 0, MINOR bumps are major bumps. @@ -130,12 +143,11 @@ if [ "$cur_major" = "0" ]; then if [ "$next_major" != "0" ] || [ "$next_minor" != "$cur_minor" ]; then if [ "$ALLOW_MAJOR" -ne 1 ]; then cat >&2 <&2 </dev/null +if ! git rev-parse --verify --quiet "refs/tags/v$next_version" >/dev/null; then + echo "Release tool did not create the expected tag v$next_version; refusing to push." >&2 + exit 1 +fi + git push origin "$branch" git push origin "v$next_version" diff --git a/scripts/release_version.py b/scripts/release_version.py new file mode 100755 index 0000000..3deaaf5 --- /dev/null +++ b/scripts/release_version.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Validate GenLayer Testing Suite release versions and owning branches.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +import sys + + +_VERSION_PATTERN = re.compile( + r"^v?(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:(?:-?rc\.?(?P[1-9][0-9]*)))?$", + re.IGNORECASE, +) +_BRANCH_PATTERN = re.compile( + r"^v(?P0|[1-9][0-9]*)\." r"(?P0|[1-9][0-9]*)(?P-dev)?$" +) + + +@dataclass(frozen=True) +class ReleaseVersion: + major: int + minor: int + patch: int + rc: int | None = None + + @property + def normalized(self) -> str: + suffix = "" if self.rc is None else f"-rc.{self.rc}" + return f"{self.major}.{self.minor}.{self.patch}{suffix}" + + @property + def is_prerelease(self) -> bool: + return self.rc is not None + + @property + def release_branch(self) -> str: + suffix = "-dev" if self.is_prerelease else "" + return f"v{self.major}.{self.minor}{suffix}" + + +def parse_release_version(value: str) -> ReleaseVersion: + match = _VERSION_PATTERN.fullmatch(value.strip()) + if match is None: + raise ValueError( + f"{value!r} is not a supported release version; use X.Y.Z for a " + "final release or X.Y.Z-rc.N for a release candidate" + ) + return ReleaseVersion( + major=int(match.group("major")), + minor=int(match.group("minor")), + patch=int(match.group("patch")), + rc=int(match.group("rc")) if match.group("rc") is not None else None, + ) + + +def validate_branch_version(branch: str, value: str) -> ReleaseVersion: + branch_match = _BRANCH_PATTERN.fullmatch(branch) + if branch_match is None: + raise ValueError(f"{branch!r} is not a release branch; use vX.Y or vX.Y-dev") + + version = parse_release_version(value) + branch_line = (int(branch_match.group("major")), int(branch_match.group("minor"))) + if branch_line != (version.major, version.minor): + raise ValueError( + f"{version.normalized} belongs to v{version.major}.{version.minor}, " + f"not {branch}" + ) + + branch_is_dev = branch_match.group("dev") is not None + if branch_is_dev and not version.is_prerelease: + raise ValueError( + f"final release {version.normalized} must be cut from " + f"v{version.major}.{version.minor}" + ) + if not branch_is_dev and version.is_prerelease: + raise ValueError( + f"release candidate {version.normalized} must be cut from " + f"v{version.major}.{version.minor}-dev" + ) + return version + + +def _usage() -> str: + return ( + "usage: release_version.py normalize | branch | " + "is-prerelease | validate | " + "verify-tag " + ) + + +def main(argv: list[str]) -> int: + try: + command = argv[1] + if command == "normalize" and len(argv) == 3: + print(parse_release_version(argv[2]).normalized) + elif command == "branch" and len(argv) == 3: + print(parse_release_version(argv[2]).release_branch) + elif command == "is-prerelease" and len(argv) == 3: + print("true" if parse_release_version(argv[2]).is_prerelease else "false") + elif command == "validate" and len(argv) == 4: + print(validate_branch_version(argv[2], argv[3]).normalized) + elif command == "verify-tag" and len(argv) == 4: + tag_version = parse_release_version(argv[2]) + package_version = parse_release_version(argv[3]) + if tag_version != package_version: + raise ValueError( + f"tag {tag_version.normalized} does not match package " + f"version {package_version.normalized}" + ) + print(tag_version.normalized) + else: + raise ValueError(_usage()) + except (IndexError, ValueError) as exc: + print(exc, file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/test_release_version.py b/tests/test_release_version.py new file mode 100644 index 0000000..4a555a3 --- /dev/null +++ b/tests/test_release_version.py @@ -0,0 +1,112 @@ +import importlib.util +from pathlib import Path +import sys +import tomllib + +import pytest +import yaml + + +MODULE_PATH = Path(__file__).parents[1] / "scripts" / "release_version.py" +SPEC = importlib.util.spec_from_file_location("release_version", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +release_version = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release_version +SPEC.loader.exec_module(release_version) + + +@pytest.mark.parametrize( + ("raw", "normalized", "branch", "is_prerelease"), + [ + ("0.30.0", "0.30.0", "v0.30", False), + ("v0.30.0-rc.1", "0.30.0-rc.1", "v0.30-dev", True), + ("0.30.0rc2", "0.30.0-rc.2", "v0.30-dev", True), + ], +) +def test_release_version_normalizes_pep440_rc_spellings( + raw, normalized, branch, is_prerelease +): + version = release_version.parse_release_version(raw) + + assert version.normalized == normalized + assert version.release_branch == branch + assert version.is_prerelease is is_prerelease + + +@pytest.mark.parametrize( + ("branch", "version", "message"), + [ + ("main", "0.30.0", "not a release branch"), + ("v0.29-dev", "0.30.0-rc.1", "belongs to v0.30"), + ("v0.30", "0.30.0-rc.1", "must be cut from v0.30-dev"), + ("v0.30-dev", "0.30.0", "must be cut from v0.30"), + ], +) +def test_release_version_rejects_wrong_release_route(branch, version, message): + with pytest.raises(ValueError, match=message): + release_version.validate_branch_version(branch, version) + + +def test_release_version_accepts_rc_only_on_owning_dev_line(): + version = release_version.validate_branch_version("v0.30-dev", "0.30.0rc1") + + assert version.normalized == "0.30.0-rc.1" + + +@pytest.mark.parametrize("version", ["0.30.0-alpha.1", "0.30.0-rc.0", "00.30.0"]) +def test_release_version_rejects_non_rc_or_noncanonical_versions(version): + with pytest.raises(ValueError, match="not a supported release version"): + release_version.parse_release_version(version) + + +def test_release_tag_and_package_version_compare_after_normalization(): + assert ( + release_version.main( + ["release_version.py", "verify-tag", "v0.30.0-rc.1", "0.30.0rc1"] + ) + == 0 + ) + + +def test_semantic_release_routes_stable_and_rc_branches(): + config_path = Path(__file__).parents[1] / "releaserc.toml" + branch_config = tomllib.loads(config_path.read_text())["semantic_release"][ + "branches" + ] + + assert branch_config == { + "stable": { + "match": r"^v[0-9]+\.[0-9]+$", + "prerelease_token": "rc", + "prerelease": False, + }, + "dev": { + "match": r"^v[0-9]+\.[0-9]+-dev$", + "prerelease_token": "rc", + "prerelease": True, + }, + } + + +def test_publish_workflow_is_tag_only_and_enforces_release_provenance(): + workflow_path = Path(__file__).parents[1] / ".github" / "workflows" / "publish.yml" + workflow = yaml.load(workflow_path.read_text(), Loader=yaml.BaseLoader) + + assert workflow["on"] == {"push": {"tags": ["v*.*.*"]}} + assert workflow["permissions"] == {"contents": "write"} + + publish_steps = workflow["jobs"]["publish-to-pypi"]["steps"] + verification = next( + step["run"] + for step in publish_steps + if step["name"] == "Verify tag, package version, and owning branch" + ) + assert "release_version.py verify-tag" in verification + assert "release_version.py branch" in verification + assert 'TAG_COMMIT" != "$BRANCH_HEAD' in verification + + github_release = next( + step["run"] for step in publish_steps if step["name"] == "Create GitHub Release" + ) + assert "release_version.py is-prerelease" in github_release + assert "--prerelease" in github_release diff --git a/uv.lock b/uv.lock index f85bc87..2afb723 100644 --- a/uv.lock +++ b/uv.lock @@ -688,14 +688,14 @@ wheels = [ [[package]] name = "genlayer-py" -version = "0.18.0" +version = "0.19.0rc1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "web3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/99/8f2d98de18bb1c6533a7b04487d6333564a94fb0b85e972042292cceafda/genlayer_py-0.18.0.tar.gz", hash = "sha256:184954732e16b61f1a8465ff8a8ab8bdcd99952b5285271ad47d523f68136940", size = 45155, upload-time = "2026-04-22T14:27:11.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9c/5b5938ac9065e881be2746a6dc1aefb6a858c4109397ab08de33ddb4d050/genlayer_py-0.19.0rc1.tar.gz", hash = "sha256:7c295bbccf48afc14c84e25f874b94b7c2d1ea9df438f2efa16d1a49dd99f952", size = 90613, upload-time = "2026-09-03T00:05:08.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/f8/a1fdd3e7c5a7c79fce774aacd535fec2bddfc742d0feabbb8770118d8b67/genlayer_py-0.18.0-py3-none-any.whl", hash = "sha256:d18cb57a6ee2c09d8db32659b9f152d4cef50d3c2308da2975bab7ae41b2e5a0", size = 58336, upload-time = "2026-04-22T14:27:12.061Z" }, + { url = "https://files.pythonhosted.org/packages/65/f3/67eecbb71cf373f7128e822ece2d0de3ea4c976d31533163f18ab139dc34/genlayer_py-0.19.0rc1-py3-none-any.whl", hash = "sha256:3707c6741c98b71b7cec48f6133fa7264e373ea9a584bc2d69ad63a11f026efc", size = 106598, upload-time = "2026-09-03T00:05:06.849Z" }, ] [[package]] @@ -724,7 +724,7 @@ requires-dist = [ { name = "colorama", specifier = ">=0.4.6" }, { name = "eth-account", marker = "extra == 'sim'", specifier = ">=0.10" }, { name = "fastapi", marker = "extra == 'sim'", specifier = ">=0.100" }, - { name = "genlayer-py", specifier = ">=0.18.0,<0.20.0" }, + { name = "genlayer-py", specifier = ">=0.19.0rc1,<0.20.0" }, { name = "httpx", marker = "extra == 'sim'", specifier = ">=0.24" }, { name = "numpy", marker = "extra == 'sim'", specifier = ">=1.26" }, { name = "pytest" }, From ca579bd82717bfdfb7f9292b38d00515ce79bc11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 3 Sep 2026 15:40:06 +0100 Subject: [PATCH 13/16] fix: preserve runtime network identity (#111) --- CLAUDE.md | 3 +- README.md | 11 ++++- docs/api-references/index.md | 6 ++- docs/studio-runner.md | 13 ++++-- gltest/fees/profile.py | 18 ++++++-- gltest_cli/config/constants.py | 10 ++++- gltest_cli/config/plugin.py | 1 + gltest_cli/config/types.py | 40 +++++++++++++++-- gltest_cli/config/user.py | 20 ++++++++- pyproject.toml | 2 +- tests/gltest/fees/test_fee_profile.py | 10 ++++- .../config/test_config_integration.py | 5 +++ .../config/test_fee_profile_plugin.py | 30 +++++++++++++ .../gltest_cli/config/test_general_config.py | 43 +++++++++++++++++++ tests/gltest_cli/config/test_plugin.py | 4 +- tests/gltest_cli/config/test_user.py | 5 +++ uv.lock | 8 ++-- 17 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 tests/gltest_cli/config/test_fee_profile_plugin.py diff --git a/CLAUDE.md b/CLAUDE.md index acb791f..cbb06fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,7 +145,8 @@ print(stats) # Shows success rate, reliability score, execution time, etc. ### Network Configuration Networks are configured in `gltest.config.yaml`: -- `localnet`: Local development (chain ID 61999) +- `localnet`: Local development (chain ID 61127) +- `studio_devnet`: Hosted Studio preview (chain ID 61997) - `testnet_asimov`: Public testnet (chain ID 4221) Paths can be configured in `gltest.config.yaml`: diff --git a/README.md b/README.md index 7414796..e2f214b 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Run with the `gltest` CLI: ```bash gltest # Run all tests gltest tests/test_mycontract.py # Specific file +gltest --network studio_devnet # Hosted release preview gltest --network studionet # Specific network gltest --leader-only # Skip consensus (faster) gltest -v # Verbose output @@ -152,6 +153,9 @@ networks: url: "http://127.0.0.1:4000/api" leader_only: false + studio_devnet: + # Pre-configured release preview — accounts auto-generated + studionet: # Pre-configured — accounts auto-generated @@ -169,7 +173,7 @@ environment: .env ``` Key options: -- **Networks**: `localnet` and `studionet` work out of the box. `testnet_asimov` requires account keys. +- **Networks**: `localnet`, `studio_devnet`, and `studionet` work out of the box. `testnet_asimov` requires account keys. - **Paths**: Where your contracts and artifacts live. - **Environment**: `.env` file for private keys. @@ -241,6 +245,7 @@ than a consumed fee amount. { "version": 1, "network": "localnet", + "chainId": 61127, "measuredAt": "2026-06-10T12:00:00Z", "deploy": { "leaderTimeunitsAllocation": "125", @@ -266,7 +271,9 @@ include consumed fee data. Testnet receipts do not expose consumed fees yet, and direct/sim mode does not go through these receipt paths. Time-unit allocations are recorded from the submitted fee distribution when the backend receipt includes it. Live price caps and `feeValue` are intentionally omitted so the SDK -can quote them from the current network policy at transaction time. +can quote them from the current network policy at transaction time. The numeric +`chainId` scopes the measurements to the exact selected runtime chain; consumers +must fall back to network defaults when it is absent or does not match. ### Assertions diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 445584b..54cb0de 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -130,6 +130,7 @@ Run with the `gltest` CLI: ```bash gltest # Run all tests gltest tests/test_mycontract.py # Specific file +gltest --network studio_devnet # Hosted release preview gltest --network studionet # Specific network gltest --leader-only # Skip consensus (faster) gltest -v # Verbose output @@ -147,6 +148,9 @@ networks: url: "http://127.0.0.1:4000/api" leader_only: false + studio_devnet: + # Pre-configured release preview — accounts auto-generated + studionet: # Pre-configured — accounts auto-generated @@ -164,7 +168,7 @@ environment: .env ``` Key options: -- **Networks**: `localnet` and `studionet` work out of the box. `testnet_asimov` requires account keys. +- **Networks**: `localnet`, `studio_devnet`, and `studionet` work out of the box. `testnet_asimov` requires account keys. - **Paths**: Where your contracts and artifacts live. - **Environment**: `.env` file for private keys. diff --git a/docs/studio-runner.md b/docs/studio-runner.md index 0e0b267..f057348 100644 --- a/docs/studio-runner.md +++ b/docs/studio-runner.md @@ -7,7 +7,7 @@ Deploy and test intelligent contracts against a running GenLayer Studio instance Studio mode deploys contracts to GenLayer Studio (or a remote network) and interacts through JSON-RPC. Every write operation goes through the full consensus pipeline. - **Fidelity**: Real consensus, real validators, real transaction lifecycle -- **Networks**: localnet (Docker), studionet, testnet_asimov, or custom +- **Networks**: localnet (Docker), Studio Devnet, studionet, public testnets, or custom - **Mocking**: Mock validators with predefined LLM/web responses for deterministic tests ### When to Use Studio Mode @@ -74,6 +74,9 @@ networks: url: "http://127.0.0.1:4000/api" leader_only: false + studio_devnet: + # Pre-configured release preview — accounts auto-generated + studionet: # Pre-configured — accounts auto-generated # Override any settings if needed @@ -107,8 +110,9 @@ environment: .env # File containing private keys and secrets | Network | Accounts | Notes | |---------|----------|-------| -| `localnet` | Auto-generated | Local Docker deployment | -| `studionet` | Auto-generated | GenLayer Studio cloud | +| `localnet` | Auto-generated | Local Docker deployment (chain 61127) | +| `studio_devnet` | Auto-generated | Hosted release preview (chain 61997) | +| `studionet` | Auto-generated | Stable GenLayer Studio cloud (chain 61999) | | `testnet_asimov` | Must be configured | Public testnet | **Network configuration fields:** @@ -117,7 +121,7 @@ environment: .env # File containing private keys and secrets |-------|----------|-------------| | `url` | For custom networks | RPC endpoint | | `id` | For custom networks | Chain ID | -| `chain_type` | For custom networks | One of: `localnet`, `studionet`, `testnet_asimov` | +| `chain_type` | For custom networks | One of the pre-configured network types above | | `accounts` | For testnet/custom | List of private keys (use env vars) | | `from` | No | Which account to use as default (defaults to first) | | `leader_only` | No | Skip consensus for all operations | @@ -163,6 +167,7 @@ gltest -v # Verbose output ```bash gltest --network localnet # Specific network (from config) +gltest --network studio_devnet gltest --network studionet gltest --network testnet_asimov gltest --rpc-url http://custom:4000/api # Custom RPC URL diff --git a/gltest/fees/profile.py b/gltest/fees/profile.py index 766b7b7..ebb8968 100644 --- a/gltest/fees/profile.py +++ b/gltest/fees/profile.py @@ -42,12 +42,16 @@ def record_method(self, method_name: str, receipt: Dict[str, Any]) -> None: method_values = self._methods.setdefault(method_name, {}) self._record_max(method_values, observation) - def build_profile(self, network: str, headroom: float) -> Dict[str, Any]: + def build_profile( + self, network: str, headroom: float, chain_id: Optional[int] = None + ) -> Dict[str, Any]: profile: Dict[str, Any] = { "version": 1, "network": network, "measuredAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } + if chain_id is not None: + profile["chainId"] = chain_id if self._deploy: profile["deploy"] = self._apply_headroom(self._deploy, headroom) profile["methods"] = { @@ -56,8 +60,16 @@ def build_profile(self, network: str, headroom: float) -> Dict[str, Any]: } return profile - def write(self, path: Path, network: str, headroom: float) -> Dict[str, Any]: - profile = self.build_profile(network=network, headroom=headroom) + def write( + self, + path: Path, + network: str, + headroom: float, + chain_id: Optional[int] = None, + ) -> Dict[str, Any]: + profile = self.build_profile( + network=network, headroom=headroom, chain_id=chain_id + ) path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(profile, indent=2) + "\n", encoding="utf-8") diff --git a/gltest_cli/config/constants.py b/gltest_cli/config/constants.py index 8316567..c10fb26 100644 --- a/gltest_cli/config/constants.py +++ b/gltest_cli/config/constants.py @@ -3,8 +3,14 @@ GLTEST_CONFIG_FILE = "gltest.config.yaml" DEFAULT_NETWORK = "localnet" -PRECONFIGURED_NETWORKS = ["localnet", "studionet", "testnet_asimov", "testnet_bradbury"] -CHAINS = ["localnet", "studionet", "testnet_asimov", "testnet_bradbury"] +PRECONFIGURED_NETWORKS = [ + "localnet", + "studio_devnet", + "studionet", + "testnet_asimov", + "testnet_bradbury", +] +CHAINS = PRECONFIGURED_NETWORKS.copy() DEFAULT_RPC_URL = SIMULATOR_JSON_RPC_URL DEFAULT_ENVIRONMENT = ".env" DEFAULT_CONTRACTS_DIR = Path("contracts") diff --git a/gltest_cli/config/plugin.py b/gltest_cli/config/plugin.py index 90d2f6a..49286d2 100644 --- a/gltest_cli/config/plugin.py +++ b/gltest_cli/config/plugin.py @@ -222,6 +222,7 @@ def pytest_sessionfinish(session, exitstatus): path=fee_profile_path, network=general_config.get_network_name(), headroom=general_config.get_fee_profile_headroom(), + chain_id=general_config.get_chain().id, ) logger.info(f"Wrote fee profile to {fee_profile_path}") if not collector.has_observations(): diff --git a/gltest_cli/config/types.py b/gltest_cli/config/types.py index 9438b0d..cd64d90 100644 --- a/gltest_cli/config/types.py +++ b/gltest_cli/config/types.py @@ -1,7 +1,14 @@ +from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional -from genlayer_py.chains import localnet, studionet, testnet_asimov, testnet_bradbury +from genlayer_py.chains import ( + localnet, + studio_devnet, + studionet, + testnet_asimov, + testnet_bradbury, +) from genlayer_py.types import GenLayerChain from gltest_cli.config.constants import PRECONFIGURED_NETWORKS from gltest_cli.config.constants import ( @@ -190,12 +197,39 @@ def get_chain_type(self) -> str: def get_chain(self) -> GenLayerChain: chain_map = { "localnet": localnet, + "studio_devnet": studio_devnet, "studionet": studionet, "testnet_asimov": testnet_asimov, "testnet_bradbury": testnet_bradbury, } chain_type = self.get_chain_type() - return chain_map[chain_type] + chain = deepcopy(chain_map[chain_type]) + + # The selected network is the runtime authority. This is especially + # important for custom Studio deployments: the endpoint and chain ID + # must travel together or signed transactions use the preset's ID. + network_config = self.user_config.networks.get(self.get_network_name()) + uses_network_chain_type = ( + self.plugin_config.chain_type is None + or ( + network_config is not None + and self.plugin_config.chain_type == network_config.chain_type + ) + ) + if ( + uses_network_chain_type + and network_config is not None + and network_config.id is not None + ): + chain.id = network_config.id + rpc_url = ( + self.get_rpc_url() + if uses_network_chain_type or self.plugin_config.rpc_url is not None + else None + ) + if rpc_url: + chain.rpc_urls["default"]["http"] = [rpc_url] + return chain def get_default_wait_interval(self) -> int: if self.plugin_config.default_wait_interval is not None: @@ -243,4 +277,4 @@ def check_local_rpc(self) -> bool: return self.get_chain_type() == "localnet" def check_studio_based_rpc(self) -> bool: - return self.get_chain_type() in ("studionet", "localnet") + return self.get_chain_type() in ("studio_devnet", "studionet", "localnet") diff --git a/gltest_cli/config/user.py b/gltest_cli/config/user.py index 2c8beb8..74fc090 100644 --- a/gltest_cli/config/user.py +++ b/gltest_cli/config/user.py @@ -18,7 +18,13 @@ DEFAULT_LEADER_ONLY, CHAINS, ) -from genlayer_py.chains import localnet, studionet, testnet_asimov, testnet_bradbury +from genlayer_py.chains import ( + localnet, + studio_devnet, + studionet, + testnet_asimov, + testnet_bradbury, +) from gltest_cli.config.types import UserConfig, NetworkConfigData, PathConfig VALID_ROOT_KEYS = ["networks", "paths", "environment"] @@ -51,6 +57,16 @@ def get_default_user_config() -> UserConfig: default_wait_retries=DEFAULT_WAIT_RETRIES, chain_type="localnet", ), + "studio_devnet": NetworkConfigData( + id=studio_devnet.id, + url=studio_devnet.rpc_urls["default"]["http"][0], + accounts=accounts_private_keys, + from_account=accounts_private_keys[0], + leader_only=DEFAULT_LEADER_ONLY, + default_wait_interval=DEFAULT_WAIT_INTERVAL, + default_wait_retries=DEFAULT_WAIT_RETRIES, + chain_type="studio_devnet", + ), "studionet": NetworkConfigData( id=studionet.id, url=studionet.rpc_urls["default"]["http"][0], @@ -275,6 +291,8 @@ def _get_overridden_networks(raw_config: dict) -> tuple[dict, str]: if "url" in network_config: networks_config[network_name].url = network_config["url"] + if "id" in network_config: + networks_config[network_name].id = network_config["id"] if "accounts" in network_config: networks_config[network_name].accounts = network_config["accounts"] networks_config[network_name].from_account = network_config["accounts"][ diff --git a/pyproject.toml b/pyproject.toml index 1b58363..2668d0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "pytest", - "genlayer-py>=0.19.0rc1,<0.20.0", + "genlayer-py>=0.19.0rc2,<0.20.0", "colorama>=0.4.6", "pyyaml", "python-dotenv" diff --git a/tests/gltest/fees/test_fee_profile.py b/tests/gltest/fees/test_fee_profile.py index 93eaad6..f787216 100644 --- a/tests/gltest/fees/test_fee_profile.py +++ b/tests/gltest/fees/test_fee_profile.py @@ -116,10 +116,13 @@ def test_collector_records_max_and_applies_headroom_with_big_int(): collector.record_deploy(fee_receipt(101, 10)) collector.record_method("create_bet", fee_receipt(big_value, 0)) - profile = collector.build_profile(network="localnet", headroom=1.25) + profile = collector.build_profile( + network="localnet", headroom=1.25, chain_id=61127 + ) assert profile["version"] == 1 assert profile["network"] == "localnet" + assert profile["chainId"] == 61127 assert profile["deploy"] == { "leaderTimeunitsAllocation": "125", "validatorTimeunitsAllocation": "250", @@ -257,10 +260,13 @@ def test_write_creates_parent_dirs_and_round_trips_json(tmp_path): collector.record_deploy(fee_receipt(10, 0)) output_path = tmp_path / "profiles" / "fees.json" - profile = collector.write(output_path, network="localnet", headroom=1.25) + profile = collector.write( + output_path, network="localnet", headroom=1.25, chain_id=61127 + ) assert output_path.exists() assert json.loads(output_path.read_text(encoding="utf-8")) == profile + assert profile["chainId"] == 61127 def test_transact_records_fee_profile_observation(monkeypatch, tmp_path): diff --git a/tests/gltest_cli/config/test_config_integration.py b/tests/gltest_cli/config/test_config_integration.py index 1043c02..b5c87f4 100644 --- a/tests/gltest_cli/config/test_config_integration.py +++ b/tests/gltest_cli/config/test_config_integration.py @@ -239,6 +239,11 @@ def test_multiple_networks(): assert general_config.get_network_name() == "testnet_asimov_custom" rpc_url = general_config.get_rpc_url() assert rpc_url == "https://testnet.example.com" + chain = general_config.get_chain() + assert chain.id == 55554 + assert chain.rpc_urls["default"]["http"] == [ + "https://testnet.example.com" + ] """ ) diff --git a/tests/gltest_cli/config/test_fee_profile_plugin.py b/tests/gltest_cli/config/test_fee_profile_plugin.py new file mode 100644 index 0000000..6d2358d --- /dev/null +++ b/tests/gltest_cli/config/test_fee_profile_plugin.py @@ -0,0 +1,30 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +from gltest_cli.config import plugin + + +def test_session_profile_uses_the_selected_runtime_chain_id(monkeypatch, tmp_path): + output_path = tmp_path / "fees.json" + general_config = SimpleNamespace( + get_fee_profile_path=Mock(return_value=output_path), + get_network_name=Mock(return_value="preview"), + get_fee_profile_headroom=Mock(return_value=1.25), + get_chain=Mock(return_value=SimpleNamespace(id=61997)), + ) + collector = SimpleNamespace( + write=Mock(return_value={"methods": {"create_bet": {}}}), + has_observations=Mock(return_value=True), + ) + monkeypatch.setattr(plugin, "get_general_config", lambda: general_config) + monkeypatch.setattr(plugin, "get_fee_profile_collector", lambda: collector) + + plugin.pytest_sessionfinish(session=None, exitstatus=0) + + collector.write.assert_called_once_with( + path=Path(output_path), + network="preview", + headroom=1.25, + chain_id=61997, + ) diff --git a/tests/gltest_cli/config/test_general_config.py b/tests/gltest_cli/config/test_general_config.py index 2723f42..c6dd72a 100644 --- a/tests/gltest_cli/config/test_general_config.py +++ b/tests/gltest_cli/config/test_general_config.py @@ -8,6 +8,7 @@ NetworkConfigData, ) from gltest_cli.config.constants import DEFAULT_ARTIFACTS_DIR, DEFAULT_CONTRACTS_DIR +from genlayer_py.chains import studio_devnet, studionet def test_general_config_artifacts_methods(): @@ -31,6 +32,48 @@ def test_general_config_artifacts_methods(): assert general_config.plugin_config.artifacts_dir == Path("plugin_artifacts") +def test_studio_devnet_is_available_as_a_preconfigured_chain(): + config = GeneralConfig( + user_config=UserConfig( + networks={ + "studio_devnet": NetworkConfigData( + id=studio_devnet.id, + url=studio_devnet.rpc_urls["default"]["http"][0], + chain_type="studio_devnet", + ) + }, + default_network="studio_devnet", + ) + ) + + chain = config.get_chain() + assert chain.id == 61997 + assert chain.rpc_urls["default"]["http"] == [ + "https://studio-dev.genlayer.com/api" + ] + assert config.check_studio_based_rpc() + + +def test_custom_studio_network_applies_its_id_and_url_to_the_chain(): + config = GeneralConfig( + user_config=UserConfig( + networks={ + "preview": NetworkConfigData( + id=62001, + url="https://preview.example/api", + chain_type="studionet", + ) + }, + default_network="preview", + ) + ) + + chain = config.get_chain() + assert chain.id == 62001 + assert chain.rpc_urls["default"]["http"] == ["https://preview.example/api"] + assert studionet.id == 61999 + + def test_general_config_artifacts_default(): """Test GeneralConfig artifacts directory with default values.""" user_config = UserConfig( diff --git a/tests/gltest_cli/config/test_plugin.py b/tests/gltest_cli/config/test_plugin.py index 92b10c2..4366a0c 100644 --- a/tests/gltest_cli/config/test_plugin.py +++ b/tests/gltest_cli/config/test_plugin.py @@ -19,8 +19,8 @@ def test_help_message(pytester): " file)", " --leader-only Run contracts in leader-only mode", " --chain-type=CHAIN_TYPE", - " Chain type (possible values: localnet, studionet,", - " testnet_asimov, testnet_bradbury)", + " Chain type (possible values: localnet, studio_devnet,", + " studionet, testnet_asimov, testnet_bradbury)", " --fee-profile=FEE_PROFILE", " Path to write a JSON fee profile for observed deploys", " and writes", diff --git a/tests/gltest_cli/config/test_user.py b/tests/gltest_cli/config/test_user.py index 0a78197..f07f4d8 100644 --- a/tests/gltest_cli/config/test_user.py +++ b/tests/gltest_cli/config/test_user.py @@ -71,6 +71,11 @@ def test_get_default_user_config(): assert isinstance(network.from_account, str) assert network.from_account in network.accounts + studio_preview = config.networks["studio_devnet"] + assert studio_preview.id == 61997 + assert studio_preview.url == "https://studio-dev.genlayer.com/api" + assert studio_preview.chain_type == "studio_devnet" + # Check paths assert isinstance(config.paths, PathConfig) assert config.paths.contracts == DEFAULT_CONTRACTS_DIR diff --git a/uv.lock b/uv.lock index 2afb723..1003183 100644 --- a/uv.lock +++ b/uv.lock @@ -688,14 +688,14 @@ wheels = [ [[package]] name = "genlayer-py" -version = "0.19.0rc1" +version = "0.19.0rc2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "web3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/9c/5b5938ac9065e881be2746a6dc1aefb6a858c4109397ab08de33ddb4d050/genlayer_py-0.19.0rc1.tar.gz", hash = "sha256:7c295bbccf48afc14c84e25f874b94b7c2d1ea9df438f2efa16d1a49dd99f952", size = 90613, upload-time = "2026-09-03T00:05:08.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/17/1a82ff1db9d6b33ca8a73c8edb9c1786860462ee1fa16eb5261e50b1b7ac/genlayer_py-0.19.0rc2.tar.gz", hash = "sha256:92056aa1b9eb336550a86c7cc3dbd9c7b10afd18d9d846d094e4bc7dd8d7224a", size = 90588, upload-time = "2026-09-03T14:22:46.187Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/67eecbb71cf373f7128e822ece2d0de3ea4c976d31533163f18ab139dc34/genlayer_py-0.19.0rc1-py3-none-any.whl", hash = "sha256:3707c6741c98b71b7cec48f6133fa7264e373ea9a584bc2d69ad63a11f026efc", size = 106598, upload-time = "2026-09-03T00:05:06.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cb/b9df445c0aa09d0307561579b306e5a1133e736f1619b8bb1687ed8e8f50/genlayer_py-0.19.0rc2-py3-none-any.whl", hash = "sha256:40f96bb2b0ded2174a98533d199cbdfc57235255a5b8ed84a0b9464851cb708c", size = 106602, upload-time = "2026-09-03T14:22:45.109Z" }, ] [[package]] @@ -724,7 +724,7 @@ requires-dist = [ { name = "colorama", specifier = ">=0.4.6" }, { name = "eth-account", marker = "extra == 'sim'", specifier = ">=0.10" }, { name = "fastapi", marker = "extra == 'sim'", specifier = ">=0.100" }, - { name = "genlayer-py", specifier = ">=0.19.0rc1,<0.20.0" }, + { name = "genlayer-py", specifier = ">=0.19.0rc2,<0.20.0" }, { name = "httpx", marker = "extra == 'sim'", specifier = ">=0.24" }, { name = "numpy", marker = "extra == 'sim'", specifier = ">=1.26" }, { name = "pytest" }, From e943a2abb15ab620f8da2148df7f9432e2c7bd8a Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 3 Sep 2026 16:21:17 +0100 Subject: [PATCH 14/16] chore(release): 0.30.0-rc.1 --- CHANGELOG.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++ glsim/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 164dbee..862c893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,147 @@ +## v0.30.0-rc.1 (2026-09-03) + +### Bug Fixes + +- Drain every queued glsim PostMessage + ([#103](https://github.com/genlayerlabs/genlayer-testing-suite/pull/103), + [`179b0b9`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/179b0b94a979648aa6c27063d9755d3264f9a87a)) + +- Migrate fixtures to genvm v0.3 sdk + ([#90](https://github.com/genlayerlabs/genlayer-testing-suite/pull/90), + [`f48c4d7`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/f48c4d7121d9109c5709a065ef15a91e1fe379b6)) + +- Preserve runtime network identity + ([#111](https://github.com/genlayerlabs/genlayer-testing-suite/pull/111), + [`ca579bd`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/ca579bd82717bfdfb7f9292b38d00515ce79bc11)) + +- Resolve v0.30 bug hunt regressions + ([#102](https://github.com/genlayerlabs/genlayer-testing-suite/pull/102), + [`447d658`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/447d658435c89433b1105f2fcce041db5e41bf68)) + +- Resolve v0.30-dev bug-hunt findings + ([#102](https://github.com/genlayerlabs/genlayer-testing-suite/pull/102), + [`447d658`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/447d658435c89433b1105f2fcce041db5e41bf68)) + +- Support current GenVM bundle artifacts + ([`74b72f0`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/74b72f0d65d13bd150bfcfbfb29c772c3321a22b)) + +- Support current GenVM bundle artifacts + ([#81](https://github.com/genlayerlabs/genlayer-testing-suite/pull/81), + [`8f8e802`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/8f8e802350140239be2b37590ed7a68253634ec5)) + +- Support fee profile policy fields + ([#96](https://github.com/genlayerlabs/genlayer-testing-suite/pull/96), + [`d04c5b5`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/d04c5b581e649ebeb34b53cf2fe890b3a33df98b)) + +- Support GenVM v0.3 direct SDK layout + ([#90](https://github.com/genlayerlabs/genlayer-testing-suite/pull/90), + [`f48c4d7`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/f48c4d7121d9109c5709a065ef15a91e1fe379b6)) + +- **genvm**: Give VecDB its explicit distance metric 🐛 + ([#106](https://github.com/genlayerlabs/genlayer-testing-suite/pull/106), + [`ac525f5`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/ac525f553c8f1d5c372481780eaff5fb1f7d01a6)) + +- **glsim**: Accept both cross-call ABIs + ([#106](https://github.com/genlayerlabs/genlayer-testing-suite/pull/106), + [`ac525f5`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/ac525f553c8f1d5c372481780eaff5fb1f7d01a6)) + +- **gltest**: Align with layered genlayer-py lifecycle types + ([#108](https://github.com/genlayerlabs/genlayer-testing-suite/pull/108), + [`7d6de26`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/7d6de26cf0218d599545afb3b01f0c88ee4cafe3)) + +- **gltest**: Migrate to genvm manager repo + ([#98](https://github.com/genlayerlabs/genlayer-testing-suite/pull/98), + [`12a7576`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/12a7576d3fa75cc4b3b52e026748a9705f7bdafe)) + +- **gltest**: Resolve runners from local tree when GENVM_PREBUILT_DIR set + ([#98](https://github.com/genlayerlabs/genlayer-testing-suite/pull/98), + [`12a7576`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/12a7576d3fa75cc4b3b52e026748a9705f7bdafe)) + +### Build System + +- Prepare v0.30 release candidates + ([#110](https://github.com/genlayerlabs/genlayer-testing-suite/pull/110), + [`742c357`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/742c357e239c09db02aec9e27de965c5241513dc)) + +### Chores + +- **genvm**: Migrate to GenVM v0.3.0-rc7 SDK APIs ⬆️ + ([#106](https://github.com/genlayerlabs/genlayer-testing-suite/pull/106), + [`ac525f5`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/ac525f553c8f1d5c372481780eaff5fb1f7d01a6)) + +- **genvm**: Migrate to the v0.3 gl_call ABI ⬆️ + ([#106](https://github.com/genlayerlabs/genlayer-testing-suite/pull/106), + [`ac525f5`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/ac525f553c8f1d5c372481780eaff5fb1f7d01a6)) + +### Continuous Integration + +- Install the sim extra — glsim's fastapi/starlette deps live there + ([#91](https://github.com/genlayerlabs/genlayer-testing-suite/pull/91), + [`60f850f`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/60f850f0dabd06ae019f19bda3634864c0ab7575)) + +- Keep main forwarded to active dev branch + ([#93](https://github.com/genlayerlabs/genlayer-testing-suite/pull/93), + [`343e3a3`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/343e3a358f9e235a93b49c60721ce7676585ff07)) + +- Run glsim suite; add dev-branch push trigger + ([#91](https://github.com/genlayerlabs/genlayer-testing-suite/pull/91), + [`60f850f`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/60f850f0dabd06ae019f19bda3634864c0ab7575)) + +- Scope down release/sync-docs to dedicated GitHub Apps + ([`27146a4`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/27146a46c7187a5985a335ecc05b7e9a6843a17f)) + +- **workflows**: Sync e2e-housekeeper.yml from genlayer-e2e + ([#85](https://github.com/genlayerlabs/genlayer-testing-suite/pull/85), + [`9bf2e85`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/9bf2e85f5bec7de7697bde7cf2c4f3bb289a8732)) + +- **workflows**: Sync e2e-housekeeper.yml from genlayer-e2e + ([#83](https://github.com/genlayerlabs/genlayer-testing-suite/pull/83), + [`f58afd7`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/f58afd724bd0b9f733c4a4ee9b81fd2faf6938b0)) + +- **workflows**: Sync e2e.yml from genlayer-e2e + ([#84](https://github.com/genlayerlabs/genlayer-testing-suite/pull/84), + [`da7429f`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/da7429f94cee11eb048d4cb4e640a3dc3531dcf1)) + +- **workflows**: Sync e2e.yml from genlayer-e2e + ([#82](https://github.com/genlayerlabs/genlayer-testing-suite/pull/82), + [`3de785f`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/3de785facdcbc3a5bf36164c8ee6685b7704eaac)) + +### Documentation + +- Add branching guide ([#95](https://github.com/genlayerlabs/genlayer-testing-suite/pull/95), + [`cef20a2`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/cef20a282638b185767dbb8c292731474ce11258)) + +### Features + +- --fee-profile reporter — measure per-method fees for developer suggestions + ([#94](https://github.com/genlayerlabs/genlayer-testing-suite/pull/94), + [`9da7c61`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/9da7c61d912ed7490e01906dfaaf188f14eadb8e)) + +- Branch-per-major release model + ([#80](https://github.com/genlayerlabs/genlayer-testing-suite/pull/80), + [`311e22a`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/311e22ae1005c917f2c8811c570b397a4b762b54)) + +- **glsim**: Accept fee-aware transaction RPCs + ([`a1144f5`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/a1144f59b023646e20115ac905dce8eca5866090)) + +- **gltest**: Fee-aware transactions and backend-agnostic success assertions + ([#91](https://github.com/genlayerlabs/genlayer-testing-suite/pull/91), + [`60f850f`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/60f850f0dabd06ae019f19bda3634864c0ab7575)) + +### Testing + +- Add v0.30 bug hunt regressions + ([#102](https://github.com/genlayerlabs/genlayer-testing-suite/pull/102), + [`447d658`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/447d658435c89433b1105f2fcce041db5e41bf68)) + +- **fixtures**: Migrate to genvm v0.3 SDK + ([#98](https://github.com/genlayerlabs/genlayer-testing-suite/pull/98), + [`12a7576`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/12a7576d3fa75cc4b3b52e026748a9705f7bdafe)) + + ## v0.29.2 (2026-04-20) ### Bug Fixes diff --git a/glsim/__init__.py b/glsim/__init__.py index 4dd4d92..d664b15 100644 --- a/glsim/__init__.py +++ b/glsim/__init__.py @@ -9,4 +9,4 @@ glsim --port 4000 """ -__version__ = "0.29.2" +__version__ = "0.30.0-rc.1" diff --git a/pyproject.toml b/pyproject.toml index 2668d0a..16e965a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "genlayer-test" -version = "0.29.2" +version = "0.30.0-rc.1" description = "GenLayer Testing Suite" authors = [ { name = "GenLayer" } From 36b799e1da3eb3e440cfacb30efd9ee43c1c95d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 3 Sep 2026 17:19:49 +0100 Subject: [PATCH 15/16] fix(direct): load current GenVM runner bundles (#112) Resolve current asset-bearing GenVM releases ahead of stale cache, use runner-bearing bundles in a fresh cache namespace, and validate ZIP extraction paths. --- gltest/direct/sdk_loader.py | 141 ++++++++++++++++--------- tests/gltest_direct/test_sdk_loader.py | 128 +++++++++++++++++++--- 2 files changed, 204 insertions(+), 65 deletions(-) diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index d463270..9b6743e 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -12,7 +12,6 @@ import shutil import tarfile import zipfile -import platform import tempfile import urllib.error import urllib.request @@ -24,27 +23,16 @@ GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm-manager/releases" -def _host_release_asset() -> str: - """genvm-manager (v0.6+) ships a whole-tree tarball per platform, named - genvm--.tar.xz — pick the one matching this host.""" - machine = platform.machine().lower() - arch = "arm64" if machine in ("aarch64", "arm64") else "amd64" - os_name = "macos" if platform.system().lower() == "darwin" else "linux" - return f"genvm-{arch}-{os_name}.tar.xz" - - -# Download candidates, newest-scheme first: the genvm-manager per-platform whole -# tree, then the pre-v0.6 runner-only bundles (genvm-runners-all → genvm-universal). -RUNNER_BUNDLE_ASSETS = ( - _host_release_asset(), - "genvm-runners-all.tar.xz", - "genvm-universal.tar.xz", -) +# Download candidates, newest-scheme first. These bundles contain the runner +# archives required by the direct loader. +RUNNER_BUNDLE_ASSETS = ("genvm-runners-all.tar.xz", "genvm-universal.tar.xz") GENVM_VERSION_ENV = "GENVM_VERSION" -FALLBACK_VERSION = "v0.6.0-rc0" +FALLBACK_VERSION = "v0.6.0-rc3" # v0.3 runner trees use .zip; the v0.2 legacy-runners tree uses .tar. RUNNER_ARCHIVE_EXTS = (".tar", ".zip") +BUNDLE_CACHE_DIR = CACHE_DIR / "bundles-v2" +TREE_CACHE_DIR = CACHE_DIR / "trees-v2" RUNNER_TYPE = "py-genlayer" STD_LIB_TYPE = "py-lib-genlayer-std" @@ -70,42 +58,70 @@ def parse_contract_header(contract_path: Path) -> Dict[str, str]: return deps +def _query_latest_version() -> Optional[str]: + """Newest suitable stable release, or newest RC when no stable exists.""" + req = urllib.request.Request( + f"{GITHUB_API_RELEASES}?per_page=100", + headers={ + "User-Agent": "gltest-direct", + "Accept": "application/vnd.github+json", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + releases = json.loads(resp.read().decode("utf-8")) + + prerelease_candidate = None + for release in releases: + if release.get("draft"): + continue + asset_names = {asset.get("name") for asset in release.get("assets", [])} + if not asset_names.intersection(RUNNER_BUNDLE_ASSETS): + continue + if release.get("prerelease"): + prerelease_candidate = prerelease_candidate or release["tag_name"] + continue + return release["tag_name"] + return prerelease_candidate + + def get_latest_version() -> str: - """Newest non-prerelease GenVM release that ships a known runner bundle.""" + """Return current published release, or the RC fallback offline.""" try: - req = urllib.request.Request( - f"{GITHUB_API_RELEASES}?per_page=100", - headers={ - "User-Agent": "gltest-direct", - "Accept": "application/vnd.github+json", - }, - ) - with urllib.request.urlopen(req, timeout=10) as resp: - releases = json.loads(resp.read().decode("utf-8")) - for release in releases: - if release.get("prerelease") or release.get("draft"): - continue - asset_names = {asset.get("name") for asset in release.get("assets", [])} - if asset_names.intersection(RUNNER_BUNDLE_ASSETS): - return release["tag_name"] + return _query_latest_version() or FALLBACK_VERSION except Exception as exc: print( f"Warning: could not resolve latest GenVM version ({exc}); " f"falling back to {FALLBACK_VERSION}", file=sys.stderr, ) - return FALLBACK_VERSION + return FALLBACK_VERSION def resolve_version() -> str: - """GenVM version to use: GENVM_VERSION env var > newest cached > latest release.""" + """Resolve GenVM version with explicit/current/offline-safe precedence. + + A cache is not authoritative: a stale cached RC must not shadow the + current manager release. The network-resolved release wins whenever it + differs from the offline fallback; cached artifacts are retained as an + offline fallback for development environments without network access. + """ pinned = os.environ.get(GENVM_VERSION_ENV) if pinned: return pinned + try: + latest = _query_latest_version() + if latest: + return latest + except Exception as exc: + print( + f"Warning: could not resolve latest GenVM version ({exc}); " + "checking the local cache", + file=sys.stderr, + ) cached = list_cached_versions() if cached: return cached[0] - return get_latest_version() + return FALLBACK_VERSION def _version_sort_key(version: str) -> tuple: @@ -115,11 +131,11 @@ def _version_sort_key(version: str) -> tuple: def list_cached_versions() -> List[str]: """List all cached genvm versions, newest first.""" - if not CACHE_DIR.exists(): + if not BUNDLE_CACHE_DIR.exists(): return [] versions = [] - for f in CACHE_DIR.glob("genvm-universal-*.tar.xz"): + for f in BUNDLE_CACHE_DIR.glob("genvm-universal-*.tar.xz"): match = re.search(r"genvm-universal-(.+)\.tar\.xz", f.name) if match: versions.append(match.group(1)) @@ -146,7 +162,11 @@ def _download_to(url: str, dest: Path) -> None: downloaded += len(chunk) if total: pct = downloaded * 100 // total - print(f"\r {pct}% ({downloaded // 1024 // 1024}MB)", end="", flush=True) + print( + f"\r {pct}% ({downloaded // 1024 // 1024}MB)", + end="", + flush=True, + ) tmp_path = tmp.name @@ -158,7 +178,8 @@ def download_artifacts(version: str) -> Path: """Download the GenVM runner bundle for version if not cached.""" CACHE_DIR.mkdir(parents=True, exist_ok=True) - tarball_path = CACHE_DIR / f"genvm-universal-{version}.tar.xz" + BUNDLE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + tarball_path = BUNDLE_CACHE_DIR / f"genvm-universal-{version}.tar.xz" if tarball_path.exists(): return tarball_path @@ -179,6 +200,19 @@ def download_artifacts(version: str) -> Path: ) from last_error +def _extract_zip(archive: zipfile.ZipFile, destination: Path) -> None: + """Extract a runner zip without allowing members to escape the cache dir.""" + destination_root = destination.resolve() + for member in archive.infolist(): + member_path = (destination / member.filename).resolve() + if ( + member_path != destination_root + and destination_root not in member_path.parents + ): + raise ValueError(f"unsafe runner zip member: {member.filename}") + archive.extractall(destination) + + def _extract_local_runner( root: Path, runner_type: str, runner_hash: Optional[str] ) -> Path: @@ -199,17 +233,24 @@ def _extract_local_runner( raise FileNotFoundError(f"runner {runner_type}:{runner_hash} not under {root}") archive = hits[-1] dest = ( - CACHE_DIR / "extracted" / "local" / runner_type + CACHE_DIR + / "extracted" + / "local" + / runner_type / (archive.parent.name + archive.stem) ) if not dest.exists(): dest.mkdir(parents=True, exist_ok=True) - if archive.suffix == ".zip": - with zipfile.ZipFile(archive) as inner: - inner.extractall(dest) - else: - with tarfile.open(archive, "r:") as inner: - inner.extractall(dest, filter="data") + try: + if archive.suffix == ".zip": + with zipfile.ZipFile(archive) as inner: + _extract_zip(inner, dest) + else: + with tarfile.open(archive, "r:") as inner: + inner.extractall(dest, filter="data") + except Exception: + shutil.rmtree(dest, ignore_errors=True) + raise return dest @@ -220,12 +261,12 @@ def _extract_release_tree(tarball_path: Path, version: str) -> Path: runner-only bundle, so we unpack it to a directory that looks exactly like a GENVM_PREBUILT_DIR and then resolve runners through the same globbing path. """ - tree = CACHE_DIR / "trees" / version + tree = TREE_CACHE_DIR / version if (tree / ".extracted").exists(): return tree if tree.exists(): shutil.rmtree(tree) - trees = CACHE_DIR / "trees" + trees = TREE_CACHE_DIR trees.mkdir(parents=True, exist_ok=True) # Extract into a process-unique dir, mark it complete, then publish with an # atomic rename. Concurrent cold-cache extractions each use their own tmp and diff --git a/tests/gltest_direct/test_sdk_loader.py b/tests/gltest_direct/test_sdk_loader.py index dd28db5..9aad158 100644 --- a/tests/gltest_direct/test_sdk_loader.py +++ b/tests/gltest_direct/test_sdk_loader.py @@ -1,7 +1,11 @@ """Unit tests for direct-runner GenVM version and artifact resolution.""" +import io import json import urllib.error +import zipfile + +import pytest from gltest.direct import sdk_loader @@ -23,39 +27,58 @@ def read(self): class TestResolveVersion: - """resolve_version() precedence: env var > cache > latest release.""" + """resolve_version() precedence: explicit > current release > cache fallback.""" def test_env_var_takes_precedence(self, monkeypatch): monkeypatch.setenv(sdk_loader.GENVM_VERSION_ENV, "v1.2.3") monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: ["v0.2.16"]) - monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9") + monkeypatch.setattr(sdk_loader, "_query_latest_version", lambda: "v0.9.9") assert sdk_loader.resolve_version() == "v1.2.3" - def test_falls_back_to_newest_cached_version(self, monkeypatch): + def test_current_release_wins_over_stale_cached_version(self, monkeypatch): monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False) monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: ["v0.2.16"]) - monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9") + monkeypatch.setattr(sdk_loader, "_query_latest_version", lambda: "v0.6.0-rc3") + + assert sdk_loader.resolve_version() == "v0.6.0-rc3" - assert sdk_loader.resolve_version() == "v0.2.16" + def test_offline_falls_back_to_newest_cached_version(self, monkeypatch, capsys): + monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False) + monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: ["v0.6.0-rc0"]) + + def _offline(): + raise OSError("network down") + + monkeypatch.setattr(sdk_loader, "_query_latest_version", _offline) + + assert sdk_loader.resolve_version() == "v0.6.0-rc0" + assert "checking the local cache" in capsys.readouterr().err + + def test_offline_without_cache_uses_current_fallback(self, monkeypatch): + monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False) + monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: []) + monkeypatch.setattr(sdk_loader, "_query_latest_version", lambda: None) + + assert sdk_loader.resolve_version() == "v0.6.0-rc3" def test_falls_back_to_latest_when_no_cache(self, monkeypatch): monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False) monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: []) - monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9") + monkeypatch.setattr(sdk_loader, "_query_latest_version", lambda: "v0.9.9") assert sdk_loader.resolve_version() == "v0.9.9" class TestGetLatestVersion: - """get_latest_version() skips pre-releases and assetless releases.""" + """get_latest_version() selects current RCs and skips unusable releases.""" def test_skips_releases_without_a_bundle_asset(self, monkeypatch): releases = [ { - "tag_name": "v0.9.9", - "prerelease": False, - "assets": [{"name": "genvm-linux-amd64.tar.xz"}], + "tag_name": "v0.6.0-rc3", + "prerelease": True, + "assets": [{"name": sdk_loader.RUNNER_BUNDLE_ASSETS[0]}], }, { "tag_name": "v0.2.16", @@ -88,12 +111,18 @@ def test_accepts_renamed_runners_all_asset(self, monkeypatch): assert sdk_loader.get_latest_version() == "v0.3.0" - def test_skips_prereleases(self, monkeypatch): + def test_skips_drafts_and_assetless_releases(self, monkeypatch): releases = [ { - "tag_name": "v0.3.0-rc0", + "tag_name": "v0.6.0-rc4", "prerelease": True, - "assets": [{"name": "genvm-runners-all.tar.xz"}], + "draft": True, + "assets": [{"name": sdk_loader.RUNNER_BUNDLE_ASSETS[0]}], + }, + { + "tag_name": "v0.6.0-rc3", + "prerelease": True, + "assets": [], }, { "tag_name": "v0.2.16", @@ -107,6 +136,19 @@ def test_skips_prereleases(self, monkeypatch): assert sdk_loader.get_latest_version() == "v0.2.16" + def test_uses_rc_when_no_stable_bundle_exists(self, monkeypatch): + releases = [ + { + "tag_name": "v0.6.0-rc3", + "prerelease": True, + "assets": [{"name": sdk_loader.RUNNER_BUNDLE_ASSETS[0]}], + } + ] + monkeypatch.setattr( + "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) + ) + assert sdk_loader.get_latest_version() == "v0.6.0-rc3" + def test_returns_fallback_when_api_unreachable(self, monkeypatch, capsys): def _boom(*a, **k): raise OSError("network down") @@ -121,12 +163,26 @@ class TestListCachedVersions: """list_cached_versions() orders versions numerically, newest first.""" def test_orders_versions_numerically(self, monkeypatch, tmp_path): - monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + monkeypatch.setattr(sdk_loader, "BUNDLE_CACHE_DIR", tmp_path / "bundles-v2") + sdk_loader.BUNDLE_CACHE_DIR.mkdir() for version in ("v0.2.9", "v0.2.16", "v0.10.0"): - (tmp_path / f"genvm-universal-{version}.tar.xz").write_bytes(b"") + ( + sdk_loader.BUNDLE_CACHE_DIR / f"genvm-universal-{version}.tar.xz" + ).write_bytes(b"") assert sdk_loader.list_cached_versions() == ["v0.10.0", "v0.2.16", "v0.2.9"] + def test_ignores_legacy_root_cache(self, monkeypatch, tmp_path): + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + monkeypatch.setattr(sdk_loader, "BUNDLE_CACHE_DIR", tmp_path / "bundles-v2") + sdk_loader.BUNDLE_CACHE_DIR.mkdir() + (tmp_path / "genvm-universal-v0.6.0-rc0.tar.xz").write_bytes(b"legacy") + (sdk_loader.BUNDLE_CACHE_DIR / "genvm-universal-v0.6.0-rc3.tar.xz").write_bytes( + b"current" + ) + + assert sdk_loader.list_cached_versions() == ["v0.6.0-rc3"] + class TestDownloadArtifacts: """download_artifacts() resolves whichever bundle asset a release ships.""" @@ -137,6 +193,7 @@ def _http_404(url): def test_returns_cached_tarball_without_downloading(self, monkeypatch, tmp_path): monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + monkeypatch.setattr(sdk_loader, "BUNDLE_CACHE_DIR", tmp_path) cached = tmp_path / "genvm-universal-v0.2.16.tar.xz" cached.write_bytes(b"cached") @@ -149,6 +206,7 @@ def _fail(*a, **k): def test_uses_first_available_asset(self, monkeypatch, tmp_path): monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + monkeypatch.setattr(sdk_loader, "BUNDLE_CACHE_DIR", tmp_path) tried = [] def _download(url, dest): @@ -167,6 +225,7 @@ def _download(url, dest): def test_falls_back_to_old_asset_on_404(self, monkeypatch, tmp_path): monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + monkeypatch.setattr(sdk_loader, "BUNDLE_CACHE_DIR", tmp_path) tried = [] def _download(url, dest): @@ -187,6 +246,7 @@ def _download(url, dest): def test_raises_when_no_asset_found(self, monkeypatch, tmp_path): monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + monkeypatch.setattr(sdk_loader, "BUNDLE_CACHE_DIR", tmp_path) def _download(url, dest): raise self._http_404(url) @@ -199,3 +259,41 @@ def _download(url, dest): assert "v9.9.9" in str(e) else: raise AssertionError("expected FileNotFoundError") + + +class TestExtractLocalRunner: + """Runner ZIP extraction cannot escape or poison the local cache.""" + + @staticmethod + def _runner_zip(root, members): + archive = root / "runners" / "py-genlayer" / "ab" / "cd.zip" + archive.parent.mkdir(parents=True) + with zipfile.ZipFile(archive, mode="w") as bundle: + for name, content in members.items(): + bundle.writestr(name, content) + return archive + + def test_extracts_safe_runner_zip(self, monkeypatch, tmp_path): + root = tmp_path / "prebuilt" + self._runner_zip(root, {"runner.json": '{"Seq": []}'}) + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path / "cache") + + extracted = sdk_loader._extract_local_runner(root, "py-genlayer", "abcd") + + assert (extracted / "runner.json").read_text() == '{"Seq": []}' + + def test_rejects_zip_traversal_and_removes_partial_cache( + self, monkeypatch, tmp_path + ): + root = tmp_path / "prebuilt" + self._runner_zip(root, {"../escape.py": "unsafe"}) + cache = tmp_path / "cache" + monkeypatch.setattr(sdk_loader, "CACHE_DIR", cache) + + with pytest.raises(ValueError, match="unsafe runner zip member"): + sdk_loader._extract_local_runner(root, "py-genlayer", "abcd") + + assert not (cache / "extracted" / "local" / "py-genlayer" / "abcd").exists() + assert not ( + cache / "extracted" / "local" / "py-genlayer" / "escape.py" + ).exists() From d84591b58746a6202b56b720199ce7b9f12fd666 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 3 Sep 2026 17:21:43 +0100 Subject: [PATCH 16/16] chore(release): 0.30.0-rc.2 --- CHANGELOG.md | 9 +++++++++ glsim/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 862c893..c029a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ +## v0.30.0-rc.2 (2026-09-03) + +### Bug Fixes + +- **direct**: Load current GenVM runner bundles + ([#112](https://github.com/genlayerlabs/genlayer-testing-suite/pull/112), + [`36b799e`](https://github.com/genlayerlabs/genlayer-testing-suite/commit/36b799e1da3eb3e440cfacb30efd9ee43c1c95d4)) + + ## v0.30.0-rc.1 (2026-09-03) ### Bug Fixes diff --git a/glsim/__init__.py b/glsim/__init__.py index d664b15..0916e80 100644 --- a/glsim/__init__.py +++ b/glsim/__init__.py @@ -9,4 +9,4 @@ glsim --port 4000 """ -__version__ = "0.30.0-rc.1" +__version__ = "0.30.0-rc.2" diff --git a/pyproject.toml b/pyproject.toml index 16e965a..82931b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "genlayer-test" -version = "0.30.0-rc.1" +version = "0.30.0-rc.2" description = "GenLayer Testing Suite" authors = [ { name = "GenLayer" }