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 <-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/README.md b/README.md index dac8d5f..7414796 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,56 @@ 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 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 +{ + "version": 1, + "network": "localnet", + "measuredAt": "2026-06-10T12:00:00Z", + "deploy": { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", + "executionBudgetPerRound": "625000", + "totalMessageFees": "0", + "rotationsPerRound": "0" + }, + "methods": { + "create_bet": { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", + "executionBudgetPerRound": "312500", + "totalMessageFees": "12500", + "rotationsPerRound": "0" + } + } +} +``` + +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 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 ```python @@ -367,9 +417,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 +450,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/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. 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..47ba9ac 100644 --- a/glsim/README.md +++ b/glsim/README.md @@ -144,10 +144,10 @@ 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: -- **DeployContract** — deploys child contract with isolated storage +Contracts using `gl.contract.deploy()`, `gl.contract.get_at().view()`, and `gl.contract.get_at().emit()` work. glsim handles: +- **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/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..9693134 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 @@ -114,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, } @@ -139,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] @@ -193,7 +198,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 +214,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 +274,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: @@ -286,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) @@ -592,16 +598,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.""" @@ -611,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 @@ -626,10 +637,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 +683,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 +742,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 +778,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 +801,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 +837,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 +930,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/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/artifacts/contract.py b/gltest/artifacts/contract.py index f1acb6f..30b435e 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,20 @@ 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 supported GenLayer contract base classes.""" + path = _attribute_path(base) + return path in (["gl", "contract", "Contract"], ["gl", "Contract"]) + + def _create_contract_definition( main_file_path: Path, contract_name: str ) -> ContractDefinition: 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..de0996c 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,10 +11,28 @@ 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 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]): + 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 read_contract_wrapper( @@ -66,6 +85,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,20 +131,28 @@ 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( + receipt = wait_for_transaction_receipt( + client, 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, ) + 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: - client.wait_for_transaction_receipt( + wait_for_transaction_receipt( + client, 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 +253,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, ): @@ -256,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, - 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..c200dd3 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, @@ -22,8 +23,22 @@ 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 +from .wait import wait_for_transaction_receipt, wait_until_from_status + + +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)} @dataclass @@ -110,6 +125,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 +145,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 +167,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,20 +213,27 @@ 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( + tx_receipt = wait_for_transaction_receipt( + client, 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, ) + 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: - client.wait_for_transaction_receipt( + wait_for_transaction_receipt( + client, 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/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/direct/loader.py b/gltest/direct/loader.py index cf0430d..09f8c7f 100644 --- a/gltest/direct/loader.py +++ b/gltest/direct/loader.py @@ -21,6 +21,25 @@ if TYPE_CHECKING: from .vm import VMContext +from .sdk_compat import ( + import_address, + import_calldata, + 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, @@ -73,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 @@ -94,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. @@ -124,7 +152,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 +160,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 +181,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 +195,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 +271,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 +373,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 +406,7 @@ def _calldata_roundtrip_args( at deploy time. """ try: - from genlayer.py import calldata + calldata = import_calldata() except ImportError: return args, kwargs @@ -428,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 @@ -461,16 +493,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 +523,7 @@ def _allocate_contract( pass try: - from genlayer.py.storage import Root + from genlayer.storage import Root Root.MANAGER = vm._storage @@ -520,7 +550,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/sdk_loader.py b/gltest/direct/sdk_loader.py index d1fa7ab..d463270 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -9,7 +9,10 @@ import re import sys import json +import shutil import tarfile +import zipfile +import platform import tempfile import urllib.error import urllib.request @@ -17,13 +20,31 @@ 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" + +# 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" @@ -158,68 +179,86 @@ 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. + v0.3 runners ship as .zip, the v0.2 legacy tree still ships .tar.""" + sub = ( + f"{runner_hash[:2]}/{runner_hash[2:]}" + if runner_hash and runner_hash.lower() != "latest" + else "*/*" + ) + hits = sorted( + hit + for ext in RUNNER_ARCHIVE_EXTS + for hit in root.glob(f"**/*runners*/{runner_type}/{sub}{ext}") + ) + if not hits: + raise FileNotFoundError(f"runner {runner_type}:{runner_hash} not under {root}") + 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) + 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 + + +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 + 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 + # 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 +295,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) @@ -275,8 +315,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/gltest/direct/vm.py b/gltest/direct/vm.py index 063e60e..c54e113 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') @@ -201,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 @@ -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..e463210 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() @@ -82,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: @@ -95,7 +105,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 +136,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 +350,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/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..766b7b7 --- /dev/null +++ b/gltest/fees/profile.py @@ -0,0 +1,265 @@ +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 = ( + "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation", + "executionBudgetPerRound", + "totalMessageFees", + "rotationsPerRound", +) +CONSUMED_KEY_MAPPING = { + "executionBudgetPerRound": "executionConsumed", + "totalMessageFees": "messageFeesConsumed", +} +ALLOCATION_KEY_MAPPING = { + "leaderTimeunitsAllocation": "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation": "validatorTimeunitsAllocation", +} + + +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 + 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 + + 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)) + return { + key: str( + value + if key == "rotationsPerRound" + else 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/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/support/ci/ACTIVE_DEV_BRANCH b/support/ci/ACTIVE_DEV_BRANCH new file mode 100644 index 0000000..31c2e1d --- /dev/null +++ b/support/ci/ACTIVE_DEV_BRANCH @@ -0,0 +1 @@ +v0.30-dev diff --git a/tests/examples/contracts/football_prediction_market.py b/tests/examples/contracts/football_prediction_market.py index a8c6ed8..dfcb5f5 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 = 0 self.score = "" @gl.public.write diff --git a/tests/examples/contracts/intelligent_oracle.py b/tests/examples/contracts/intelligent_oracle.py index e839243..5bc0a13 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.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 e17dca4..f92adf4 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.storage.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, @@ -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/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..9b150ac 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.storage.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] = 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..e2a78ba 100644 --- a/tests/examples/contracts/log_indexer.py +++ b/tests/examples/contracts/log_indexer.py @@ -1,29 +1,31 @@ # 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): - vector_store: gle.VecDB[np.float32, typing.Literal[384], StoreValue] +class LogIndexer(gl.contract.Contract): + vector_store: gle.VecDB[ + np.float32, typing.Literal[384], StoreValue, gle.EuclideanDistance + ] def __init__(self): pass @@ -53,14 +55,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=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 = 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..9d23dcb 100644 --- a/tests/examples/contracts/multi_file_contract/__init__.py +++ b/tests/examples/contracts/multi_file_contract/__init__.py @@ -1,18 +1,18 @@ -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), - on="accepted", + salt_nonce=1, + value=0, + on="decided", ) @gl.public.write @@ -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..8ad807f 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.storage.TreeMap[gl.Address, gl.storage.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..a1bcd75 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.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 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="decided").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..8f02bf9 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.storage.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..87db3f5 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,12 +20,12 @@ 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), - on="accepted", + salt_nonce=salt, + on="decided", ) self.child_address = child_address.as_hex return self.child_address 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/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 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/current_style_contract.py b/tests/gltest/artifact/contracts/current_style_contract.py new file mode 100644 index 0000000..ce6020a --- /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.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/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/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/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..9186d03 --- /dev/null +++ b/tests/gltest/contracts/test_fee_params.py @@ -0,0 +1,152 @@ +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: + 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"}]}, + } + + +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) + 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/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..93eaad6 --- /dev/null +++ b/tests/gltest/fees/test_fee_profile.py @@ -0,0 +1,322 @@ +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", + }, + }, + } + + +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 + + 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"] == { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", + "executionBudgetPerRound": "127", + "totalMessageFees": "13", + } + assert profile["methods"]["create_bet"] == { + "leaderTimeunitsAllocation": "125", + "validatorTimeunitsAllocation": "250", + "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_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)) + + profile = collector.build_profile(network="localnet", headroom=1.0) + + assert set(profile) == {"version", "network", "measuredAt", "methods"} + assert profile["methods"] == { + "create_bet": { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", + "executionBudgetPerRound": "10", + "totalMessageFees": "5", + } + } + + +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"] == { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", + "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"] == { + "leaderTimeunitsAllocation": "100", + "validatorTimeunitsAllocation": "200", + "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/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")) diff --git a/tests/gltest_direct/test_direct_runner.py b/tests/gltest_direct/test_direct_runner.py index 4991210..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. """ @@ -260,8 +261,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(): @@ -278,15 +279,15 @@ 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.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(): - request = {"DeployContract": {"code": b"pass", "calldata": {}}} + request = {"EmitInternalDeployMessage": {"code": b"pass", "calldata": {}}} wasi_mock.gl_call(calldata.encode(request)) return "should not reach" @@ -294,16 +295,16 @@ 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.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(): request = { - "PostMessage": { + "EmitInternalMessage": { "address": b"\x00" * 20, "calldata": {"method": "bar", "args": []}, } @@ -318,8 +319,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 +335,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 +352,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() 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") 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() 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" },