diff --git a/.claude/agents/reviewer-implementation.md b/.claude/agents/reviewer-implementation.md
new file mode 100644
index 000000000..e9756f396
--- /dev/null
+++ b/.claude/agents/reviewer-implementation.md
@@ -0,0 +1,76 @@
+---
+name: reviewer-implementation
+description: Reviews the implementation of a GenVM branch — code-vs-spec drift, code quality, AI slop, duplicated logic, useless comments, and doc completeness. Use as the "implementation" pass of a branch review.
+tools: Bash, Read, Grep, Glob
+model: opus
+---
+
+You are the **implementation** pass of a GenVM branch review. You read the code
+and judge how it is built. Read-only: never edit code.
+
+## Baseline
+
+Diff against the active dev branch, not `main`:
+
+- Default to `v0.3-dev` (or the current `v0.x-dev`) — confirm with
+ `git branch -a | grep dev`; fall back to `main` only if none exists.
+- State the base in one line; ignore commits already on it.
+- `git log --oneline ..HEAD` / `git diff --stat ..HEAD`, then read the
+ diffs of the changed code.
+
+## What to report (with file:line evidence)
+
+1. **Code-vs-spec drift.** Verify the implementation matches the ADR/spec
+ claim-for-claim: every form/grammar/permission/limit the spec names exists in
+ code with the same semantics, and the code does not add user-visible behavior
+ the spec omits. Call out each divergence.
+
+2. **Doc / SDK completeness.** New `gl_call`s, permissions, runner-id forms, etc.
+ must be reflected in `doc/website/src/spec/**`, `doc/schemas/*.json`, and any
+ SDK wrappers/docstrings. Flag anything implemented but undocumented.
+
+3. **AI slop & duplicated logic.** Deliberate engineering or generated filler?
+ Flag over-abstraction, padded boilerplate, dead/duplicated parsers, copy-paste,
+ and clever-for-no-reason indirection. Say plainly when it is NOT slop.
+ Specifically: a new `gl_call` handler (e.g. `register_runner` in
+ `wasi/genlayer_sdk.rs` and `Supervisor` helpers) must **share the underlying
+ loading/resolution logic with the runner loader in
+ `executor/src/rt/supervisor/actions.rs`** rather than reimplementing archive
+ parsing, id canonicalization, or cache insertion. A second parallel code path
+ doing what the loader already does is a finding — name the function it should
+ route through.
+
+ **Responsibilities must be encapsulated in the layer that owns them.** Each
+ layer does its own job and exposes ONE entry point; callers in other layers
+ delegate, they don't reach across and re-orchestrate. Concretely: the
+ wasi/vfs layer (`wasi/genlayer_sdk.rs`, `wasi/preview1.rs`) is a thin syscall
+ shim — it must NOT contain runner-resolution logic (computing a contract's
+ runner id, picking storage slots/state, stitching together
+ `get_runner_of_contract` + `load_runner` + archive mapping). That belongs in
+ the runners/supervisor layer; the gl_call handler should call a single
+ encapsulated function there. A handler that assembles a cross-layer flow
+ inline (even if each piece is "shared") is a finding — name the boundary it
+ violates and the single function it should call instead. Likewise, storage
+ layout, permission derivation, and limiter accounting each have one home;
+ flag logic that leaks into a layer that shouldn't know about it.
+
+4. **Correctness & quality.** Panics on malformed input (`slice`, `unwrap`),
+ error handling, idempotency.
+
+5. **Edge cases are tested.** Enumerate the edge cases of each new surface and
+ verify each has a test: the happy path is not enough. Expect negative tests for
+ missing permission, non-deterministic mode, malformed / non-existing ids, and
+ stress/loop cases (e.g. registering the same thing ~1000× to prove
+ consume-once). Name each untested edge case as a gap.
+
+6. **Useless comments.** Flag narrate-the-obvious comments. Good comments explain
+ *why* (invariants, cache dedup, lifecycle) — credit those.
+
+Do NOT flag the dev-mode / `hashes=test` build state — intentional, not a finding.
+Resource-accounting / consume-once limiter bugs are owned by the security
+reviewer; mention only if it also reads as duplicated logic.
+
+## Style
+
+Concise and direct. Lead with: is the implementation correct and clean enough to
+merge? Separate blocking issues from nits. Note if you did not build or run tests.
diff --git a/.claude/agents/reviewer-security.md b/.claude/agents/reviewer-security.md
new file mode 100644
index 000000000..11798cf71
--- /dev/null
+++ b/.claude/agents/reviewer-security.md
@@ -0,0 +1,57 @@
+---
+name: reviewer-security
+description: Security review of a GenVM branch — attacker mindset over permissions, resource limits, sandbox propagation, parsing, and state-exfiltration. Use as the "security" pass of a branch review.
+tools: Bash, Read, Grep, Glob
+model: opus
+---
+
+You are the **security** pass of a GenVM branch review. Think like an attacker
+writing a malicious contract. Read-only: never edit code.
+
+First read the repo root `SECURITY.md` — it is the source of truth for the threat
+model, scope, and severity ladder. Rank every finding by the priority it defines
+and cite that priority. Do not restate its contents in your report; reference it.
+
+## Baseline
+
+Diff against the active dev branch, not `main`:
+
+- Default to `v0.3-dev` (or the current `v0.x-dev`) — confirm with
+ `git branch -a | grep dev`; fall back to `main` only if none exists.
+- State the base in one line; ignore commits already on it.
+- `git log --oneline ..HEAD` / `git diff --stat ..HEAD`, then read the
+ diffs of the executor, wasi, supervisor, storage, and runner code.
+
+## What to check (with file:line evidence)
+
+- **Permission gates.** Every new capability is gated on its permission char AND
+ on `is_deterministic` where required. Check the gate is at entry, before any
+ side effect.
+- **Permission propagation.** New capabilities are correctly disabled / inherited
+ in sub-VMs, the sandbox (`& allow_write_ops`), and nondet spawns — not silently
+ leaked into a more-privileged child.
+- **Allocation bounded before allocating.** Reads/parses must charge the limiter
+ *before* allocating: decompression bombs (reject non-`Stored` zip entries),
+ length-prefixed reads, archive sizes. Charging after the alloc is a finding.
+- **Resource accounting is consume-once.** A charge that scales with repeated
+ calls is a REAL BUG, not a conservative nit. Content-addressed resources (e.g. a
+ `custom:` runner) must be charged **once** — registering/loading the same
+ thing N times must not consume the limit N times. The test: "do it ~1000× in a
+ loop — does the limit overflow?" If yes, flag it and prescribe dedup-by-hash /
+ consume-once. Never excuse N-times charging as "errs safe."
+- **Strict input parsing.** IDs/addresses/slots parsed with exact lengths and a
+ closed grammar; reserved prefixes truly reserved; malformed input rejected, not
+ coerced.
+- **Read-oracle / exfiltration.** Does a new primitive let a contract read another
+ contract's state, or observe data it shouldn't? A blob that is loaded+executed
+ (not returned) is usually safe; a path that returns bytes to the caller is not.
+- **Determinism.** New reads/branches in deterministic mode must be consensus-safe
+ (same result across validators).
+
+Do NOT flag the dev-mode / `hashes=test` build state — intentional, not a finding.
+
+## Style
+
+Concise. Lead with: any exploitable issue, yes/no, and what blocks merge.
+Distinguish a real vuln from a hardening nice-to-have. Note if you did not build
+or run anything.
diff --git a/.claude/agents/reviewer-spec.md b/.claude/agents/reviewer-spec.md
new file mode 100644
index 000000000..4fc4f4683
--- /dev/null
+++ b/.claude/agents/reviewer-spec.md
@@ -0,0 +1,63 @@
+---
+name: reviewer-spec
+description: Reviews the ADR/proposal and spec/schema of a GenVM branch as documents — soundness, clarity, and completeness (what was forgotten). Use as the "spec" pass of a branch review.
+tools: Bash, Read, Grep, Glob
+model: opus
+---
+
+You review the **specification** of a GenVM branch — the ADR, the spec/docs, the
+schemas — as documents: is the proposal sound, clear, and self-consistent? You do
+NOT cross-check the implementation (whether the code matches the spec is the
+implementation reviewer's job). Read-only: never edit code.
+
+## Baseline
+
+Diff against the active dev branch, not `main`:
+
+- Default to `v0.3-dev` (or the current `v0.x-dev`) — confirm with
+ `git branch -a | grep dev`; fall back to `main` only if none exists.
+- State the base in one line at the top; ignore commits already on it.
+- `git log --oneline ..HEAD` and `git diff --stat ..HEAD`, then read
+ the diffs of `doc/**` and `*.json` schemas.
+
+## What to report
+
+With file:line evidence:
+
+1. **ADR / proposal quality.** If the branch adds or changes a `doc/adr/*.md`
+ (or design doc), rate it. Good = concrete context with real linked issues, a
+ precise decision (grammar/types/IDs spelled out unambiguously), honest
+ consequences including breaking changes and footguns, and genuine
+ alternatives-considered. If there's no ADR for a change that warrants one,
+ say so.
+
+2. **Spec / schema soundness.** Read the spec and schema changes as a contract a
+ third party would implement against: is every form/grammar/permission/limit
+ defined precisely and unambiguously? Are there gaps, contradictions, or
+ under-specified edges? Is the JSON schema itself valid and matching the prose?
+ This is about the spec being *correct and complete on its own terms* — not
+ about the code.
+
+3. **Completeness — what did we forget to add?** A change usually touches a whole
+ family of surfaces; flag any the proposal/spec missed. E.g. a new `gl_call`
+ typically needs: a spec page, a JSON-schema entry, a permission (with its char
+ documented in the permissions spec), an SDK wrapper, error/edge-case
+ documentation, and a migration/breaking-change note if it changes existing
+ behavior. A new id/grammar form needs its schema pattern plus mention anywhere
+ the old forms are enumerated. List the surfaces that *should* have changed
+ together but didn't.
+
+4. **Edge cases are documented or inferrable.** Enumerate the edge cases of each
+ new surface (malformed input, missing permission, non-deterministic mode,
+ not-found / collision, limits hit) and check the spec either states the
+ behavior or makes it unambiguously inferrable from the stated rules. A behavior
+ a reader would have to guess is a spec gap — list each one. (Whether those
+ edge cases are *tested* is the implementation reviewer's job.)
+
+Do NOT flag the dev-mode / `hashes=test` build state — it is intentional (known
+flag to ignore test hashes), not a finding.
+
+## Style
+
+Concise and direct. Lead with: is the proposal sound and the spec implementable
+as written? Separate blocking gaps from nits. Don't pad.
diff --git a/.claude/skills/branch-review/SKILL.md b/.claude/skills/branch-review/SKILL.md
new file mode 100644
index 000000000..78fa08f8f
--- /dev/null
+++ b/.claude/skills/branch-review/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: branch-review
+description: Reviews the current GenVM branch by fanning out three specialized review agents (spec, security, implementation). Use when asked to "review this branch", "review the PR", or do a full code review of a diff.
+---
+
+# Reviewing a GenVM branch
+
+Run a three-pass review using the dedicated subagents. The spec pass runs
+**first** as a gate; the security and implementation passes run **concurrently**
+after it (both in a single message). All three are read-only.
+
+| Pass | Agent (`subagent_type`) | Owns |
+| --- | --- | --- |
+| Spec | `reviewer-spec` | ADR/proposal quality; spec & schema soundness; completeness; edge cases documented/inferrable |
+| Security | `reviewer-security` | permissions, sandbox propagation, limiter/consume-once, parsing, exfiltration (ranked by `SECURITY.md`) |
+| Implementation | `reviewer-implementation` | code-vs-spec drift, AI slop, duplicated logic, edge cases tested, doc completeness, useless comments |
+
+## Baseline
+
+All three diff against the active **dev** branch, not `main` (default
+`v0.3-dev`; confirm with `git branch -a | grep dev`). Pass the base in the prompt
+if the user named one. Do NOT treat the dev-mode / `hashes=test` build state as a
+finding — it is intentional.
+
+## Procedure
+
+1. Confirm the base branch (default `v0.3-dev`).
+2. Spawn `reviewer-spec`, told the base branch and any extra scope the user gave.
+ After it finishes, if it found issues discuss with the user before proceeding.
+3. In one message, spawn `reviewer-security` and `reviewer-implementation`, each
+ told the base branch and any extra scope the user gave.
+4. Synthesize their three reports into one review: lead with the merge verdict,
+ then a **Blocking** section and a **Nits** section, attributing findings to
+ their pass. De-duplicate overlaps (e.g. a duplicated-logic issue both security
+ and implementation raise) into a single entry.
+
+## Scope notes
+
+- If the user asks for only one dimension ("just the security review"), run that
+ one agent instead of all three.
+- Each agent is read-only; none will edit code. If the user wants fixes after the
+ review, that is a separate, explicit follow-up.
diff --git a/.claude/skills/rust-test-style/SKILL.md b/.claude/skills/rust-test-style/SKILL.md
index 379c2b0d5..364d02484 100644
--- a/.claude/skills/rust-test-style/SKILL.md
+++ b/.claude/skills/rust-test-style/SKILL.md
@@ -15,16 +15,29 @@ To run these tests, see the `/test` skill (`--filter-tag rust`).
## Where tests go
+**Decision rule — default to a separate file.** If the item under test is
+reachable from the crate's public API (a `pub fn`/`pub` type, even via a
+re-export), its test goes in its own `tests/.rs` file. Use an inline
+`#[cfg(test)] mod tests` **only** when the test must reach a *private* item that
+cannot be exercised through the public API. A public function tested inline is a
+review finding — move it to `tests/`.
+
Two locations, both in use:
1. **Integration tests** — one file per concern under the crate's `tests/` dir.
- Preferred for exercising a crate's public API. Each `tests/*.rs` file is its
- own compilation unit and gets its own runner case.
+ **This is the default** for anything testable through the public API. Each
+ `tests/*.rs` file is its own compilation unit (a separate crate) and gets its
+ own runner case.
- e.g. `executor/crates/calldata/tests/derive_decode.rs`,
+ `executor/crates/calldata/tests/address_checksum.rs`,
`executor/crates/common/tests/expr.rs`,
`modules/implementation/tests/test_rat.rs`
+ - Because each is a separate crate, it sees only the library crate plus
+ `[dev-dependencies]` — **not** the library's regular `[dependencies]`. If a
+ test needs a util the crate already depends on (e.g. `hex`), add it to
+ `[dev-dependencies]` too.
2. **Inline unit tests** — `#[cfg(test)] mod tests { ... }` at the bottom of a
- `src/*.rs` file, for testing private items. e.g.
+ `src/*.rs` file, reserved for testing **private** items. e.g.
`executor/crates/calldata/src/lib.rs`, `executor/crates/common/src/logger/mod.rs`.
```rust
diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md
index fcf9fff60..93fef3163 100644
--- a/.claude/skills/test/SKILL.md
+++ b/.claude/skills/test/SKILL.md
@@ -40,6 +40,13 @@ ya-test-runner run [OPTIONS]
| `--fail-fast` | Stop execution after first failure |
| `--coverage` | Enable coverage collection for Rust tests |
| `--log-level LEVEL` | Set log level (trace/debug/info/warning/error) |
+| `--ignore-hash` | Skip `.hash` (execution-hash) comparison entirely |
+
+**`--ignore-hash`:** integration cases compare both the printed semantics
+(`.N.stdout`) and a deterministic execution hash (`.N.hash`). When adding a new
+case, you usually don't have a correct `.hash` yet — run with `--ignore-hash` so
+only the `.stdout` semantics are checked, or set `stable_hash: false` on the
+entry (then validators compare to the leader's hash instead of a committed file).
### Show Information
```bash
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 535d78c6d..bca7c536d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -87,4 +87,4 @@ repos:
entry: ./support/ci/check-versions.py
language: system
pass_filenames: false
- files: ^(executor/Cargo\.toml|modules/implementation/Cargo\.toml|\.genvm-monorepo-root)$
+ files: ^(executor/Cargo\.toml|executor/crates/common/Cargo\.toml|modules/implementation/Cargo\.toml|\.genvm-monorepo-root)$
diff --git a/configure.rb b/configure.rb
index 5c2c7f558..d1ad1afe5 100755
--- a/configure.rb
+++ b/configure.rb
@@ -483,14 +483,18 @@ def generator.register_cargo(rel_path, extra_args: [], build_to: nil)
var :pool, :console
end
+runners_nix_inputs = $source_dir.join('runners').glob('**/*.nix')
+
generator.build(:nix_eval, 'out/executor/vTEST/data/latest.json') do
- var 'expr', 'let drv = import ./runners { host-system = builtins.currentSystem; } ; conv-hash = hash: if hash == "test" then "test" else builtins.convertHash { inherit hash; toHashFormat = "nix32"; } ; in builtins.listToAttrs (builtins.map (x: { name = x.id; value = conv-hash x.hash; }) drv)'
+ var 'expr', 'let drv = import ./runners { host-system = builtins.currentSystem; } ; hash-of = x: builtins.head (builtins.tail (builtins.match "([^:]+):(.*)" x.uid)) ; in builtins.listToAttrs (builtins.map (x: { name = x.id; value = hash-of x; }) drv)'
var 'wd', $source_dir
+ add_implicit_dependency runners_nix_inputs
end
generator.build(:nix_eval, 'out/executor/vTEST/data/all.json') do
- var 'expr', 'let drv = import ./runners { host-system = builtins.currentSystem; } ; conv-hash = hash: if hash == "test" then "test" else builtins.convertHash { inherit hash; toHashFormat = "nix32"; } ; in builtins.listToAttrs (builtins.map (x: { name = x.id; value = [ (conv-hash x.hash) ]; }) drv)'
+ var 'expr', 'let drv = import ./runners { host-system = builtins.currentSystem; } ; hash-of = x: builtins.head (builtins.tail (builtins.match "([^:]+):(.*)" x.uid)) ; in builtins.listToAttrs (builtins.map (x: { name = x.id; value = [ (hash-of x) ]; }) drv)'
var 'wd', $source_dir
+ add_implicit_dependency runners_nix_inputs
end
generator.build(:phony, 'all/data') do
diff --git a/doc/adr/011. runner ids and runtime registration.md b/doc/adr/011. runner ids and runtime registration.md
new file mode 100644
index 000000000..1d7bb3b23
--- /dev/null
+++ b/doc/adr/011. runner ids and runtime registration.md
@@ -0,0 +1,111 @@
+# ADR-011: Runner IDs and Runtime Runner Registration
+
+## Status
+
+- **Status**: Accepted
+- **Date**: 2026-06-19
+
+## Context
+
+[ADR-002](<002. code loading.md>) introduced runners and identified them by a
+`:` string, with a special `` literal for the
+contract currently being executed and an internal `on_chain:0x` id used
+when loading a contract's own runner from its code.
+
+This scheme is limiting:
+
+- The `` literal and the `on_chain:` id were implementation details
+ that were never specified, so contracts could not reference runner code stored
+ on chain in a documented, stable way.
+- There is no way to load runner code from an arbitrary contract's storage, which
+ blocks reuse of code parts between contracts (see research
+ [GVM-231](https://linear.app/genlayer-labs/issue/GVM-231)).
+- There is no way for a contract to provide runner code at runtime (e.g. to build
+ a more capable / user-provided execution environment) — the runner set was
+ fixed at load time.
+
+## Decision
+
+Rework runner ids into four explicit, specified forms (parsed in
+`executor/src/runners`):
+
+1. `name:hash` — a packaged runner by human readable name (`[a-zA-Z0-9_-]+`) and
+ archive hash. In debug mode the hash may be `test`/`latest`. Unchanged.
+2. `contract` — the runner of the contract currently being executed. Replaces the
+ old, undocumented `` literal.
+3. `chain:[:[:]]` — load a runner code blob directly from a
+ storage slot of an arbitrary contract: `` is a `0x` 20 byte hex
+ address, `a`/`f` selects accepted (latest non final) / finalized state, and
+ `` is a 32 byte slot id encoded with GVM32 (Crockford Base32, not `0x`
+ hex). Both `` and `` are optional: `` defaults to `a` and
+ `` defaults to the target contract's resolved root code slot, so
+ `chain:` and `chain::a` are valid shorthands. The current
+ contract's runner is the canonical `chain:` id pointing at itself and the code
+ slot, so `contract` and an explicit self reference deduplicate in the cache.
+4. `custom:` — a runner registered at runtime, looked up in an in-memory map
+ keyed by `` (the GVM32-encoded SHA3-256 of the registered code).
+
+`contract`, `chain` and `custom` are reserved prefixes and cannot be used as
+human readable runner names.
+
+Add a `gl_call` `RegisterRunner { code: Bytes }` that parses `code` as a runner
+archive, charges its size against the memory limit and stores it under
+`custom:`, where `` is the GVM32-encoded SHA3-256 of `code`. It
+returns the resulting runner id. It is allowed only in deterministic mode and only when the contract
+holds the new `register_runners` permission (permission char `u`). A friendly
+`genlayer.vm.register_runner(code) -> str` wrapper is exposed in the Python SDK.
+
+Add a companion `gl_call` `MapFile { runner, path_in_runner, path_in_vfs }` that maps
+a file (or, when `path_in_runner` ends with `/`, a directory subtree) from any
+runner into the VM filesystem at runtime, sharing the exact logic of the `MapFile`
+runner action. Because resolving a `chain:` runner reads another contract's
+storage, it requires the `read_storage` permission. Exposed as
+`genlayer.vm.map_file(...)` in the Python SDK.
+
+## Consequences
+
+### Positive
+
+- Runner ids are fully specified and uniform; `contract`/`chain`/`custom` are
+ first-class instead of hidden literals.
+- Contracts can load code parts from other contracts' storage on chain.
+- Contracts can extend their execution environment with runtime-provided code.
+
+### Negative
+
+- `` is no longer accepted; runners embedding it must be rebuilt to use
+ `contract` (done for the bundled `py-genlayer` wrappers).
+- `chain` and `custom` become reserved prefixes, slightly shrinking the space of
+ valid human readable runner names.
+
+### Neutral
+
+- The `custom:` map is per-execution and starts empty; registered runners do not
+ persist across executions.
+
+## Alternatives Considered
+
+- **Keep ``/`on_chain:` internal-only**: rejected because contracts
+ need a documented way to reference on-chain and current-contract runners.
+- **A dedicated `exec` gl_call that replaces the current VM with a new one
+ carrying user code** (GVM-231 idea A): rejected for now because propagating the
+ extended runner into sub-sub-VMs is not obvious; registering under `custom:` and
+ referencing it from ordinary `Depends`/`With` composes with the existing loader.
+
+## Implementation Notes
+
+- Parsing/canonicalization lives in `executor/src/runners/mod.rs`
+ (`parse_runner_id`, `chain_canonical`, `custom_runner_hash`); resolution and
+ loading in `executor/src/rt/supervisor/actions.rs`.
+- `chain:` reads code via a fresh `Storage` pointed at the target address/state
+ and `read_code_at(slot)`.
+- A dependency whose runner declares a public-ABI major different from the host's
+ `CURRENT_MAJOR` is rejected before its code is loaded, so an incompatible
+ on-chain runner cannot be pulled in via `Depends`/`With`.
+- Runner ids are interned via a `StrSymbol` newtype so the runner maps stay
+ interned while lookups accept `&str` without interning untrusted input (an
+ attacker-supplied id cannot grow the global symbol table).
+- The `custom:` map lives on `Supervisor` (`register_custom_runner` /
+ `get_custom_runner`).
+- Spec: `doc/schemas/runners.json` (`runner-id` type),
+ `doc/website/src/spec/.../02-gl_call.rst` and `.../03-vm/05-permissions.rst`.
diff --git a/doc/internal/comparisons/py-api.md b/doc/internal/comparisons/py-api.md
index 4e9b57692..6bb008cbe 100644
--- a/doc/internal/comparisons/py-api.md
+++ b/doc/internal/comparisons/py-api.md
@@ -80,7 +80,7 @@ So, unless leader gets some score for agreeing or disagreeing with validators it
Basis function:
```rust
-fn eq_principle(leader_fn: () -> bytes|rollback, validator_fn: (leaders_result: bytes|rollback) -> bool) -> bytes|rollback
+fn eq_principle(leader_fn: () -> bytes|user_error, validator_fn: (leaders_result: bytes|user_error) -> bool) -> bytes|user_error
```
On top of it all sorts of API can be built, including `eq_principle(prompt_template, result_producer)`
diff --git a/doc/internal/node.md b/doc/internal/node.md
deleted file mode 100644
index a749332ad..000000000
--- a/doc/internal/node.md
+++ /dev/null
@@ -1,186 +0,0 @@
-## Running the GenVM
-
-To run a genvm, one must start a genvm process with following arguments:
-- `--host` tcp-it address or `unix://` prefixed unix domain socket
-- `--message` message data as json, follows [schema](../schemas/message.json)
- See [example](../../executor/testdata/templates/message.json) that is used in tests
-
-## How to ask GenVM to quit?
-Send it `SIGTERM`. If it doesn't quit in some sensible amount of time just `SIGKILL` it
-
-## How node receives code, message, ... from user
-It is for node to decide. GenVM knows only about the calldata (and potentially message) and nothing else
-
-## Communication protocol
-All further communication is done via socket. If the GenVM process exited before sending the result, it means that GenVM crashed. Potential bugs should be reported
-
-Method ids list is available as [json](../../executor/codegen/data/host-fns.json). It is advised to use it in the build system to codegen constants
-
-Below is pseudo code, but [real code](../../executor/testdata/runner/base_host.py) is always up to date and is as readable as this code is (see `async def host_loop(handler: IHost):`)
-
-```
-const ACCOUNT_ADDR_SIZE = 20
-const SLOT_ID_SIZE = 32
-
-fn write_byte_slice(arr):
- write_u32_le len(arr)
- write_bytes arr
-fn read_slice():
- len := read_u32_le
- data := read_bytes(len)
- return data
-
-loop:
- method_id := read_byte
- match method_id
- json/methods/get_calldata:
- calldata, err := host_get_calldata()
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- write_byte_slice calldata
- json/methods/storage_read:
- read_type := read_byte as json/storage_type
- address := read_bytes(ACCOUNT_ADDR_SIZE)
- slot := read_bytes(SLOT_ID_SIZE)
- index := read_u32_le
- len := read_u32_le
- data, err := host_storage_read(read_type, address, slot, index, len)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- write_bytes data # must be exactly len in size
- json/methods/storage_write:
- slot := read_bytes(SLOT_ID_SIZE)
- index := read_u32_le
- len := read_u32_le
- data := read_bytes(len)
- err := host_storage_write(slot, index, data)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- json/methods/consume_result:
- host_result := read_slice()
- # this is needed to ensure that genvm doesn't close socket before all data is read
- write_byte 0x00
- break
- json/methods/get_leader_nondet_result:
- call_no := read_u32_le
- data, err := host_get_leader_nondet_result(call_no)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- write_byte_slice result_data
- json/methods/post_nondet_result:
- call_no := read_u32_le
- result := read_slice()
- err := host_post_nondet_result(call_no, result)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- json/methods/post_message:
- address := read_bytes(ACCOUNT_ADDR_SIZE)
- calldata := read_slice()
- message_data := read_slice() # JSON string
- err := host_post_message(address, calldata, message_data)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- json/methods/consume_fuel:
- gas := read_u64_le
- host_consume_fuel(gas)
- # note: this method doesn't send any response
- json/methods/deploy_contract:
- calldata := read_slice()
- code := read_slice()
- message_data := read_slice() # JSON string
- err := host_deploy_contract(calldata, code, message_data)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- json/methods/eth_call:
- address := read_bytes(ACCOUNT_ADDR_SIZE)
- calldata := read_slice()
- result, err := host_eth_call(address, calldata)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- write_byte_slice result
- json/methods/eth_send:
- address := read_bytes(ACCOUNT_ADDR_SIZE)
- calldata := read_slice()
- message_data := read_slice() # JSON string
- err := host_eth_send(address, calldata, message_data)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- json/methods/get_balance:
- address := read_bytes(ACCOUNT_ADDR_SIZE)
- balance, err := host_get_balance(address)
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- write_bytes balance.to_le_bytes(32) # 256-bit integer
- json/methods/remaining_fuel_as_gen:
- fuel, err := host_remaining_fuel_as_gen()
- if err != json/errors/ok:
- write_byte err
- else:
- write_byte json/errors/ok
- write_bytes fuel.to_le_bytes(8) # 64-bit integer, must be safe integer (fits in double)
-```
-
-See [mock implementation](../../executor/testdata/runner/mock_host.py)
-
-## Types
-
-### VM results
-There are following codes:
-- `Return`
-- `VMError` indicating vm-produced error that usually can't be handled
-- `UserError` user-produced error
-
-#### Nondet blocks and sandbox encoding
-- 1 byte of result code
-- result as-is (calldata for `Return`, string for `VMError|UserError`)
-
-#### Parent VM result
-- 1 byte of result code
-- calldata for `Return`, `{ "message": "string", "fingerprint": ... }` for `VMError|UserError`
-
-### Calldata
-`get_calldata` method must return [calldata encoded](../calldata.md) bytes that conform to ABI:
-```typescript
-{
- method?: string, // only for non-consturctors
- args: Array,
- kwargs?: { [key: string]: any }
-}
-```
-
-### Read result
-It has code followed by bytes, codes are:
-- return, it is followed by calldata
-- rollback and contract error, followed by a string; from host point of view there is no distinction between them
-- just error, which is internal error, like llm's modules being absent
-
-### Storage format
-Storage can be seen as a mapping from account address to slot address to linear memory. It supports two operations: `read` and `write`. Reading undefined memory **must** return zeroes
-
-Storage can be seen as a file system tree containing directories named as contracts which contain files named as slots, then following implementation is valid:
-```bash
-# read contract_a slot_b 10...100
-cat db/contract_a/slot_b.bytes /dev/null | tail -c +10 | head -c +100
-```
-
-**NOTE**: calculating storage updates, hashes and so on is host's (node's) responsibility. It is [the same in geth](https://github.com/ethereum/go-ethereum/blob/67a3b087951a3f3a8e341ae32b6ec18f3553e5cc/core/state/state_object.go#L232): they have dirty override for the store
diff --git a/doc/schemas/default-config.json b/doc/schemas/default-config.json
index 7c136a6be..3f56aa017 100644
--- a/doc/schemas/default-config.json
+++ b/doc/schemas/default-config.json
@@ -324,9 +324,22 @@
"type": "object",
"properties": {
"bucket_no": {
- "type": "integer",
- "minimum": 0,
- "maximum": 255
+ "oneOf": [
+ {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 255
+ },
+ "minItems": 1
+ }
+ ]
},
"subtract_on_start_expr": {
"type": "string",
diff --git a/doc/schemas/runners.json b/doc/schemas/runners.json
index 86751c0e5..779fe9eda 100644
--- a/doc/schemas/runners.json
+++ b/doc/schemas/runners.json
@@ -11,6 +11,29 @@
"WasmMode": {
"enum": ["det", "nondet"]
},
+ "runner-id": {
+ "type": "string",
+ "description": "Identifier of a runner to load",
+ "anyOf": [
+ {
+ "pattern": "^[a-zA-Z0-9_-]+:[a-zA-Z0-9_=-]+$",
+ "not": { "pattern": "^(contract|chain|custom):" },
+ "description": "`name:hash` — packaged runner by human readable name and archive hash (in debug mode hash may be `test`/`latest`). `contract`/`chain`/`custom` are reserved and cannot be used as a name"
+ },
+ {
+ "const": "contract",
+ "description": "the runner of the contract that is currently being executed"
+ },
+ {
+ "pattern": "^chain:0x[0-9a-fA-F]{40}(:[af](:[0-9abcdefghjkmnpqrstvwxyz]{52})?)?$",
+ "description": "`chain:[:[:]]` — code blob read from a `0x` 20 byte address at a GVM32-encoded 32 byte slot, `a`=accepted / `f`=finalized. `` and `` are optional: `` defaults to `a` and `` to the target contract's root code slot"
+ },
+ {
+ "pattern": "^custom:[a-zA-Z0-9_=-]+$",
+ "description": "`custom:` — runner registered at runtime"
+ }
+ ]
+ },
"InitAction": {
"description": "A single rule for loading.",
"type": "object",
@@ -116,8 +139,8 @@
"type": "string"
},
"Depends": {
- "description": "Depend on other runner by id:hash",
- "type": "string"
+ "description": "Depend on another runner",
+ "$ref": "#/definitions/runner-id"
},
"Seq": {
"description": "Execute sequence",
@@ -138,7 +161,10 @@
"description": "Makes given runner current without executing it's action. It means that MapFile will fork with files from this archive. Useful for creating lock files",
"type": "object",
"properties": {
- "runner": { "type": "string" },
+ "runner": {
+ "description": "runner id to make current",
+ "$ref": "#/definitions/runner-id"
+ },
"action": { "$ref": "#/definitions/InitAction" }
}
}
diff --git a/doc/website/src/impl-spec/01-core-architecture/02-host-interface.rst b/doc/website/src/impl-spec/01-core-architecture/02-host-interface.rst
index 97d1fcb81..a898e71ce 100644
--- a/doc/website/src/impl-spec/01-core-architecture/02-host-interface.rst
+++ b/doc/website/src/impl-spec/01-core-architecture/02-host-interface.rst
@@ -22,9 +22,8 @@ GenVM Execution
**Process Control**:
-- **Graceful Shutdown**: Send ``SIGTERM`` signal
-- **Force Termination**: Send ``SIGKILL`` if not responding
-- **Crash Detection**: Process exit before sending result indicates crash (should be reported as bug)
+- **Termination**: The executor has no internal timeout or graceful-shutdown path and may be killed at any moment with ``SIGKILL`` (see :doc:`04-executor`). Timeouts are enforced by the :term:`Manager`.
+- **Crash Detection**: Process exit before sending result indicates a kill/timeout or a crash (a crash should be reported as a bug)
**Node Responsibilities**: Node decides how to receive code and messages from users. GenVM only knows about calldata and message data.
@@ -56,7 +55,7 @@ Result Encoding
**Non-deterministic Blocks and Sandbox Encoding**:
- 1 byte of result code
-- Result data: calldata for ``Return``, string for ``VMError`` or ``UserError``
+- Result data: calldata for ``Return``, calldata for ``UserError``, string for ``VMError``
**Parent VM Result Encoding**:
diff --git a/doc/website/src/impl-spec/01-core-architecture/04-executor.rst b/doc/website/src/impl-spec/01-core-architecture/04-executor.rst
new file mode 100644
index 000000000..a5fb25194
--- /dev/null
+++ b/doc/website/src/impl-spec/01-core-architecture/04-executor.rst
@@ -0,0 +1,93 @@
+Executor
+========
+
+The executor (the ``genvm`` binary) runs a single contract execution to
+completion and writes its result back to the :term:`Host`. It is a short-lived,
+stateless process that the :term:`Manager` spawns once per execution.
+
+No Internal Timeout
+-------------------
+
+The executor does **not** implement any execution timeout, deadline, or signal
+handling of its own. It runs until the contract finishes — producing a
+``Return``, ``UserError``, or ``VMError`` result — or until it is terminated
+externally.
+
+Timeouts are enforced entirely by the :term:`Manager`, which owns the executor
+process lifecycle. When an execution exceeds its budget (or must otherwise be
+stopped), the manager kills the executor process directly with ``SIGKILL``. The
+executor installs no signal handlers and has no graceful-shutdown path, so it
+**can be killed at any moment**, between any two operations, without notice.
+
+Implications
+-----------
+
+- The executor keeps no durable state of its own. All persistent state lives in
+ the host and is written only as part of delivering a result. A killed executor
+ simply produces no result, which the manager treats as a failed / timed-out
+ execution.
+- Executor code must not rely on running cleanup, flushing buffers, or
+ cancellation logic during shutdown — there is no shutdown to hook into.
+
+Debug modes
+-----------
+
+The debug level is a top-level field of the manager ``run`` request
+(``debug_mode``), forwarded to the executor as ``--debug-mode ``. The
+default is ``disabled``.
+
+Output **capture** is a derived property with three states:
+
+- ``disabled`` — nothing is captured into the result: the executor's
+ stdout/stderr go to ``/dev/null`` and its logs are *forwarded to the manager's
+ own log* (so they are not lost, just not returned in the response).
+- ``bounded`` — captured into the result, but bounded: at most the 128 most
+ recent log entries are kept (oldest dropped), and stdout/stderr are truncated
+ to a 4 MiB tail each.
+- ``unbounded`` — captured into the result in full.
+
+When capture is ``disabled`` the result's log/stdout/stderr fields are empty
+(empty list / empty strings), not omitted; ``bounded`` and ``unbounded`` use the
+same field shapes, differing only in how much they contain.
+
+The levels are ordered; each *adds* to the previous one:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 20 22 58
+
+ * - Level
+ - Capture
+ - Adds
+ * - ``disabled``
+ - ``disabled``
+ - Production default. Logs are forwarded to the manager log; no result capture; no debug aids.
+ * - ``safe``
+ - ``bounded``
+ - Logs and stdout/stderr are captured into the result (bounded). Deterministic.
+ * - ``safe-unbounded``
+ - ``unbounded``
+ - Capture becomes unbounded and ``Trace`` gl_call output is emitted. Deterministic.
+ * - ``unsafe``
+ - ``unbounded``
+ - The ``:latest`` / ``:test`` runner ids may be resolved. **Unsafe across machines**: different nodes may resolve different code and diverge consensus, though a single node stays deterministic.
+ * - ``unsafe-tracing``
+ - ``unbounded``
+ - Real wall-clock time is exposed to the contract in deterministic mode (``RuntimeMicroSec`` returns actual elapsed time instead of ``0``; non-deterministic mode already returns real time regardless of debug level). **Can break determinism on a single machine.** Local debugging only.
+
+Only ``unsafe`` and ``unsafe-tracing`` can affect determinism (across machines
+and on a single machine respectively); ``safe`` and ``safe-unbounded`` are fully
+consensus-safe (``safe-unbounded`` deliberately couples unbounded capture with
+``Trace`` emission — both are deterministic, verbose debug aids). In code these
+distinctions are methods on ``genvm_common::DebugMode``: ``capture()`` (returns
+the ``Capture`` state), ``allows_tracing()``, ``allows_latest_resolution()``,
+and ``allows_nondeterminism()``.
+
+**Enforcement.** ``debug_mode`` is a per-request field, so on a consensus
+network the manager MUST reject (or clamp to a configured maximum) ``unsafe``
+and ``unsafe-tracing`` from untrusted callers — otherwise a single request could
+force ``:latest``/``:test`` resolution (cross-node divergence) or expose real
+time (single-machine non-determinism). Unbounded capture (``safe-unbounded`` and
+above) also trades the memory bound for completeness, so operators should treat
+the higher levels as privileged. An unrecognized ``debug_mode`` value is
+rejected rather than silently downgraded.
diff --git a/doc/website/src/impl-spec/01-core-architecture/index.rst b/doc/website/src/impl-spec/01-core-architecture/index.rst
index 96fe82009..af000fbfe 100644
--- a/doc/website/src/impl-spec/01-core-architecture/index.rst
+++ b/doc/website/src/impl-spec/01-core-architecture/index.rst
@@ -9,3 +9,4 @@ Overview of GenVM's core architectural components and design patterns.
01-components
02-host-interface
03-modules/index
+ 04-executor
diff --git a/doc/website/src/impl-spec/04-fees.rst b/doc/website/src/impl-spec/04-fees.rst
index 5547da08e..36a240116 100644
--- a/doc/website/src/impl-spec/04-fees.rst
+++ b/doc/website/src/impl-spec/04-fees.rst
@@ -37,11 +37,14 @@ The ``fees`` block of the GenVM config (see ``doc/schemas/default-config.json``,
per-bucket expressions can reference.
``bucket_no``
- Index into the ``Vec`` of bucket totals the host passes to
- ``DataLimit::new``. Two bucket configs MAY share the same ``bucket_no``; in that
- case both charge against the same reservoir. The ``message_fee`` /
- ``message_receipt`` pair has special atomic-debit behaviour when they share a
- bucket (``consume_message_fee`` in ``fees.rs:332``).
+ Either a single index, or an array of indices, into the ``Vec`` of bucket
+ totals the host passes to ``DataLimit::new``. With an array, a scalar ``delta_expr``
+ result is charged against every listed bucket, while an array result is charged
+ element-wise (lengths must match); all debits in one charge are atomic
+ (all-or-nothing). Two bucket configs MAY share the same index; in that case both
+ charge against the same reservoir. The ``message_fee`` / ``message_receipt`` pair
+ has special atomic-debit behaviour when they share a bucket (``consume_message_fee``
+ in ``fees.rs:332``).
``subtract_on_start_expr``
A bare numeric expression. Evaluated once at startup with ``node`` bound to the
diff --git a/doc/website/src/impl-spec/appendix/manager-api.yaml b/doc/website/src/impl-spec/appendix/manager-api.yaml
index f21fcc5e0..fdae60607 100644
--- a/doc/website/src/impl-spec/appendix/manager-api.yaml
+++ b/doc/website/src/impl-spec/appendix/manager-api.yaml
@@ -617,7 +617,6 @@ components:
- major
- message
- is_sync
- - capture_output
- storage_pages
- host_data
- timestamp
@@ -633,9 +632,19 @@ components:
is_sync:
type: boolean
description: Whether execution is synchronous
- capture_output:
- type: boolean
- description: Whether to capture execution output
+ debug_mode:
+ type: string
+ default: disabled
+ enum: [disabled, safe, safe-unbounded, unsafe, unsafe-tracing]
+ description: |
+ Executor debug level (see the Executor "Debug modes" section). Each level
+ adds to the previous one. Replaces the former `capture_output` boolean,
+ from which the output `Capture` (disabled/bounded/unbounded) is now derived.
+
+ On a consensus network the manager MUST reject (or clamp) `unsafe` and
+ `unsafe-tracing` from untrusted callers: `unsafe` enables `:latest`/`:test`
+ resolution (different nodes may diverge) and `unsafe-tracing` exposes real
+ time (breaks single-machine determinism). An unrecognized value is rejected.
max_execution_minutes:
type: integer
format: uint64
diff --git a/doc/website/src/python-sdk/changelog-notes/v0.3.rst b/doc/website/src/python-sdk/changelog-notes/v0.3.rst
index b54bd1dfc..9798de297 100644
--- a/doc/website/src/python-sdk/changelog-notes/v0.3.rst
+++ b/doc/website/src/python-sdk/changelog-notes/v0.3.rst
@@ -7,7 +7,7 @@ Migration Guide
v0.3.0 introduces a major restructuring of the standard library. The ``genlayer.gl`` and ``genlayer.py`` intermediate packages are removed. All public API is now accessible directly under the ``genlayer`` namespace.
Import Pattern
-~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~
The recommended import pattern has changed:
@@ -23,7 +23,7 @@ The recommended import pattern has changed:
The ``from genlayer import *`` star-import previously brought ``gl`` (a lazy proxy to ``genlayer.gl``), all types, and storage names into scope. Now ``import genlayer as gl`` gives you direct access to submodules (``gl.contract``, ``gl.vm``, ``gl.message``, ``gl.chain``, etc.), decorators (``gl.public``, ``gl.private``), and storage types (``gl.TreeMap``, ``gl.DynArray``, ``gl.Array``). The ``from genlayer.types import *`` import brings the type aliases (``u8``..``u256``, ``i8``..``i256``, ``Address``, ``bigint``, etc.) into local scope.
Module Path Changes
-~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~
All ``genlayer.py.*`` and ``genlayer.gl.*`` paths are removed. Here is the mapping:
@@ -35,6 +35,8 @@ All ``genlayer.py.*`` and ``genlayer.gl.*`` paths are removed. Here is the mappi
- v0.3.0
* - ``genlayer.py.types``
- ``genlayer.types``
+ * - ``genlayer.py.keccak``
+ - ``genlayer.types.keccak``
* - ``genlayer.py.calldata``
- ``genlayer.calldata``
* - ``genlayer.py.storage``
@@ -54,8 +56,19 @@ All ``genlayer.py.*`` and ``genlayer.gl.*`` paths are removed. Here is the mappi
* - ``genlayer.gl.advanced``
- removed (see below)
+.. note::
+ Type aliases (``u8``..``u256``, ``i8``..``i256``) are no longer ``typing.NewType`` instances; they are now ``typing.Annotated[int, ...]``. They still work as type annotations, but they are no longer callable, so the ``u256(0)`` wrapping is gone — pass a plain ``int`` instead:
+
+ .. code-block:: python
+
+ # v0.2.x
+ gl.contract.deploy(code=source, value=u256(1000), salt_nonce=u256(42))
+
+ # v0.3.0
+ gl.contract.deploy(code=source, value=1000, salt_nonce=42)
+
Contract Declaration
-~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~
``gl.Contract`` is now ``gl.contract.Contract``:
@@ -70,7 +83,7 @@ Contract Declaration
...
Contract Interaction
-~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~
Functions for interacting with other contracts have been renamed and moved into ``gl.contract``:
@@ -99,7 +112,7 @@ The ``ContractProxy`` type is renamed to ``gl.contract.Proxy``.
Contract proxies, ``Contract`` itself, and the new ``gl.chain.Account`` all implement the ``gl.chain.IAccount`` protocol, exposing ``address``, ``balance``, and ``emit_transfer(value, *, on=...)``.
Value Transfers and ``on=`` Parameter
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Write-call and deploy APIs now take an ``on`` keyword controlling when the message is applied, with values ``'accepted'`` or ``'finalized'`` (default ``'finalized'``):
@@ -160,7 +173,7 @@ The ``Event`` class has moved from ``genlayer.gl`` to ``genlayer.chain``:
``gl.advanced.emit_raw_event(topics, blob)`` is now ``gl.chain.Event.emit_raw(topics, blob)``.
Advanced / Error Handling
-~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
The ``genlayer.gl.advanced`` module is removed. Its functionality has been relocated:
@@ -202,6 +215,45 @@ Trace functions have moved from ``genlayer.gl`` to ``genlayer.vm``:
gl.vm.trace("debug message")
gl.vm.trace_time_micro()
+Non-deterministic Execution (``gl.vm``)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. warning::
+ ``gl.vm.run_nondet`` **changed meaning**. The two ``run_nondet`` functions were renamed:
+
+ * the old ``run_nondet_unsafe`` (no validator sandbox) is now ``run_nondet``;
+ * the old ``run_nondet`` (safe, validator runs in a sandbox) is now ``run_nondet_default``.
+
+ Code that called ``gl.vm.run_nondet`` still compiles but now silently uses the
+ *unsafe* variant. To keep the previous behavior, switch to ``gl.vm.run_nondet_default``:
+
+ .. code-block:: python
+
+ # v0.2.x (safe) # v0.3.0 (same behavior)
+ gl.vm.run_nondet(...) gl.vm.run_nondet_default(...)
+
+ # v0.2.x (unsafe) # v0.3.0 (same behavior)
+ gl.vm.run_nondet_unsafe(...) gl.vm.run_nondet(...)
+
+The high-level equivalence principles (``gl.eq_principle.*``) are unaffected — they were updated internally.
+
+``gl.vm.spawn_sandbox`` replaced the single ``allow_write_ops`` flag with three granular permissions, each effective only if the current VM holds it:
+
+.. code-block:: python
+
+ # v0.2.x
+ gl.vm.spawn_sandbox(fn, allow_write_ops=True)
+
+ # v0.3.0
+ gl.vm.spawn_sandbox(
+ fn,
+ allow_write_storage=True,
+ allow_send_messages=True,
+ allow_register_runners=True,
+ )
+
+Two runtime helpers were added: ``gl.vm.register_runner(code)`` registers a runner archive and returns its ``custom:`` id, and ``gl.vm.map_file(runner, path_in_runner, path_in_vfs)`` maps a file from a runner into the VM filesystem.
+
Storage
~~~~~~~
@@ -236,17 +288,17 @@ Decorators
def balance_of(self, owner: Address) -> u256: ...
Non-deterministic Operations
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``gl.nondet`` now raises a dedicated ``gl.nondet.NondetException`` (with ``causes`` and ``ctx``) on errors instead of a bare exception. Web request helpers (``gl.nondet.web.get``/``post``/``put``/...) accept a ``sign: bool`` keyword to sign outbound requests with the contract's identity.
Environment Detection
-~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~
-A new top-level ``gl.IS_IN_VM`` boolean indicates whether code is running inside the GenVM, which is useful for code that is shared between contracts and off-chain tooling. The raw WASI module is available as ``gl.wasi``.
+A new top-level ``gl.IS_IN_VM`` boolean indicates whether code is running inside the GenVM, which is useful for code that is shared between contracts and off-chain tooling. The raw WASI module is available as ``gl.wasi``. A ``gl.gvm32`` module provides Crockford Base32 ``encode``/``decode`` helpers (mirroring the Rust ``genlayer_sdk::gvm32`` implementation).
Summary of Renames
-~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~
.. list-table::
:header-rows: 1
@@ -274,6 +326,10 @@ Summary of Renames
- ``gl.vm.trace(...)``
* - ``gl.trace_time_micro()``
- ``gl.vm.trace_time_micro()``
+ * - ``gl.vm.run_nondet_unsafe(...)``
+ - ``gl.vm.run_nondet(...)``
+ * - ``gl.vm.run_nondet(...)``
+ - ``gl.vm.run_nondet_default(...)``
* - ``gl.message_raw``
- ``gl.message.raw``
* - ``gl.storage.allow_storage``
diff --git a/doc/website/src/spec/02-execution-environment/02-wasip1.rst b/doc/website/src/spec/02-execution-environment/02-wasip1.rst
index 1fd508363..beeabe004 100644
--- a/doc/website/src/spec/02-execution-environment/02-wasip1.rst
+++ b/doc/website/src/spec/02-execution-environment/02-wasip1.rst
@@ -144,8 +144,10 @@ runs with identical stdin see the same ``random_get`` stream, while differing in
produce diverging streams. Non-deterministic randomness MUST be obtained via
:ref:`gvm-def-non-det-mode` calls.
-Non-deterministic mode: cryptographically secure random number generator,
-with optional fallback to pseudo-random numbers, if secure source is exhausted or unavailable.
+Non-deterministic mode: a cryptographically secure random number generator. If the
+secure source is unavailable or fails, ``random_get`` MUST fail with ``errno::io``
+instead of falling back to a predictable stream (pseudo-random or zeroed output). A
+caller that wants a graceful fallback must handle that error explicitly.
``proc_exit`` Function
~~~~~~~~~~~~~~~~~~~~~~
diff --git a/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/02-gl_call.rst b/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/02-gl_call.rst
index 9b8621314..7e100c853 100644
--- a/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/02-gl_call.rst
+++ b/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/02-gl_call.rst
@@ -179,13 +179,69 @@ Payload
{
"Sandbox": {
- "data": Bytes, // Code/data for sandbox execution
- "allow_write_ops": Bool // Whether to allow write operations
+ "data": Bytes, // Code/data for sandbox execution
+ "allow_write_storage": Bool, // Whether to allow storage writes
+ "allow_send_messages": Bool, // Whether to allow sending messages
+ "allow_register_runners": Bool // Whether to allow registering runners
}
}
Creates isolated VM instance. See :ref:`gvm-permissions` for permission inheritance details.
+``RegisterRunner`` Message
+--------------------------
+
+Registers a runner archive at runtime, making it available under the
+``custom:`` runner id. The ```` is the SHA3-256 of the supplied
+``code`` encoded with :doc:`../../04-contract-interface/06-gvm32`, and the parsed
+archive is charged against the memory limit. Returns the resulting runner id
+(calldata-encoded string).
+
+Payload
+~~~~~~~
+
+.. code-block::
+
+ {
+ "RegisterRunner": {
+ "code": Bytes // runner archive (ustar/zip or commented text)
+ }
+ }
+
+Requirements
+~~~~~~~~~~~~
+
+#. :ref:`gvm-perm-deterministic`
+#. :ref:`gvm-perm-register-runners`
+
+``MapFile`` Message
+-------------------
+
+Maps a file from a runner into the VM filesystem at runtime, behaving the same as
+the ``MapFile`` runner action (see :doc:`../../../python-sdk` runners). If
+``path_in_runner`` ends with ``/`` the whole directory subtree is mapped.
+
+Payload
+~~~~~~~
+
+.. code-block::
+
+ {
+ "MapFile": {
+ "runner": String, // runner id (name:hash, contract, chain:..., custom:...)
+ "path_in_runner": String, // path within the runner archive
+ "path_in_vfs": String // absolute destination path in the VM filesystem
+ }
+ }
+
+Mapping into ``/vm/`` is forbidden. Resolving a ``chain:`` runner reads another
+contract's storage, so this requires :ref:`gvm-perm-read-storage`.
+
+Requirements
+~~~~~~~~~~~~
+
+#. :ref:`gvm-perm-read-storage`
+
``WebRender`` Message
---------------------
@@ -349,11 +405,6 @@ Requirements
Topics must be exactly 32 bytes each.
-``Rollback`` Message
---------------------
-
-Deprecated. Use ``UserError`` instead.
-
``UserError`` Message
---------------------
@@ -422,7 +473,7 @@ Requirements
-------------------------------------
In :ref:`gvm-def-non-det-mode` returns the elapsed execution time in microseconds since VM start.
-In :ref:`gvm-def-det-mode`, it always returns ``0``.
+In :ref:`gvm-def-det-mode`, it returns ``0`` — exposing real elapsed time there would break determinism. The sole exception is the ``unsafe-tracing`` debug level (see the executor "Debug modes" section), which returns real elapsed time even in deterministic mode; that level is for local debugging only and must never be used on a consensus network.
Payload
~~~~~~~
@@ -442,3 +493,53 @@ Requirements
#. GenVM version 0.1.10 or higher
#. :term:`GenVM` implementation is allowed ignore this message
+
+``Yield`` Message
+-----------------
+
+Cooperative yield. Currently a no-op and returns no value; it is reserved for future use in waiting loops.
+
+Payload
+~~~~~~~
+
+.. code-block::
+
+ {
+ "Yield": null
+ }
+
+.. note::
+
+ Implementations may choose to ignore this message.
+
+Requirements
+~~~~~~~~~~~~
+
+#. GenVM version 0.3.0 or higher
+#. :term:`GenVM` implementation is allowed ignore this message
+
+.. _get-timestamp:
+
+``GetTimestamp`` Message
+------------------------
+
+Returns the current timestamp as the number of seconds since the Unix epoch.
+
+In :ref:`gvm-def-det-mode` it returns the transaction timestamp, keeping the value deterministic across validators.
+In :ref:`gvm-def-non-det-mode` it returns the real wall-clock time.
+
+Payload
+~~~~~~~
+
+.. code-block::
+
+ {
+ "GetTimestamp": null
+ }
+
+Returns the timestamp encoded as a :ref:`Calldata Encoded ` number.
+
+Requirements
+~~~~~~~~~~~~
+
+#. GenVM version 0.3.0 or higher
diff --git a/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst b/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst
index 469f97039..f127af46c 100644
--- a/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst
+++ b/doc/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst
@@ -19,13 +19,13 @@ results through a file descriptor with the following binary format:
Where ``result_code`` is a :ref:`gvm-def-enum-result-code`:
- :ref:`gvm-def-enum-value-result-code-return` (``0``): Successful execution
-- :ref:`gvm-def-enum-value-result-code-user-error` (``1``): Contract-initiated error via ``Rollback``
+- :ref:`gvm-def-enum-value-result-code-user-error` (``1``): Contract-initiated error
- :ref:`gvm-def-enum-value-result-code-vm-error` (``2``): VM-level error
The ``data`` portion depends on the result code:
- **return**: :ref:`Calldata Encoded ` return value
-- **user_error**: UTF-8 encoded error message string (from ``Rollback`` message)
+- **user_error**: :ref:`Calldata Encoded ` error value
- **vm_error**: UTF-8 encoded :ref:`gvm-def-str-trie-vm-error` string
.. _gvm-def-module-result-encoding:
diff --git a/doc/website/src/spec/02-execution-environment/04-runners.rst b/doc/website/src/spec/02-execution-environment/04-runners.rst
index 2ccc54c11..d56882e36 100644
--- a/doc/website/src/spec/02-execution-environment/04-runners.rst
+++ b/doc/website/src/spec/02-execution-environment/04-runners.rst
@@ -11,34 +11,31 @@
Identification and Packaging
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Each :term:`runner` is identified by ``:``.
-``human-readable-id`` is provided for convenience.
-``hash`` is a sha3-256 hash of its contents
+A :term:`runner` is referenced by a runner id, which has one of the following
+forms (see the ``runner-id`` definition in the runner.json schema):
+
+- ``:`` — a packaged runner. ``human-readable-id`` is
+ provided for convenience; ``hash`` is a hash of its contents (see `Hash Format`_).
+- ``contract`` — the runner of the contract currently being executed.
+- ``chain:[:[:]]`` — a runner code blob read from a storage
+ slot of an arbitrary contract (``a`` = accepted, ``f`` = finalized). ````
+ is a ``0x`` 20 byte hex address and ```` is a :term:`SlotID` encoded with
+ :doc:`../../04-contract-interface/06-gvm32`. Both ```` and ```` are
+ optional: ```` defaults to ``a`` and ```` to the target contract's
+ root code slot.
+- ``custom:`` — a runner registered at runtime via the ``RegisterRunner``
+ ``gl_call``, looked up by its hash.
+
+``contract``, ``chain`` and ``custom`` are reserved prefixes and cannot be used
+as human-readable ids.
Hash Format
~~~~~~~~~~~
-Hash is SHA3 256-bit hash, converted to a string with following algorithm:
-
-.. code-block:: python
-
- def digest_to_hash_id(got_hash: bytes) -> str:
- chars = '0123456789abcdfghijklmnpqrsvwxyz'
-
- bytes_count = len(got_hash)
- base32_len = (bytes_count * 8 - 1) // 5 + 1
-
- my_hash_arr = []
- for n in range(base32_len - 1, -1, -1):
- b = n * 5
- i = b // 8
- j = b % 8
- c = (got_hash[i] >> j) | (0 if i >= bytes_count - 1 else got_hash[i + 1] << (8 - j))
- my_hash_arr.append(chars[c & 0x1F])
-
- return ''.join(my_hash_arr)
-
-This ensures that it contains no fs-illegal characters and is case insensitive.
+``hash`` is the SHA3 256-bit hash of the runner's contents, encoded with
+:doc:`../../04-contract-interface/06-gvm32` (GVM32, a lowercase Crockford
+Base32). This keeps it free of filesystem-illegal characters and
+case-insensitive.
Runner Layout
-------------
diff --git a/doc/website/src/spec/03-vm/05-permissions.rst b/doc/website/src/spec/03-vm/05-permissions.rst
index ec7e18ada..1435ec1d1 100644
--- a/doc/website/src/spec/03-vm/05-permissions.rst
+++ b/doc/website/src/spec/03-vm/05-permissions.rst
@@ -28,6 +28,8 @@ Allows reading contract storage slots. When unset, any attempt to read storage w
Allows writing to contract storage slots. Requires :ref:`gvm-perm-deterministic` as well.
+This permission also gates ``EmitEvent``: a VM may emit events *iff* it can write storage, since events are state-mutating log emissions. Consequently a read-only (static) context, such as a ``CallContract`` child, cannot emit events.
+
.. _gvm-perm-send-messages:
``send_messages``
@@ -51,6 +53,14 @@ Requires :ref:`gvm-perm-deterministic` as well.
Allows spawning :ref:`gvm-def-non-det-mode` :term:`sub-VM` instances via ``RunNondet``.
+.. _gvm-perm-register-runners:
+
+``register_runners``
+--------------------
+
+Allows registering runner archives at runtime via ``RegisterRunner``, making
+them available under ``custom:`` runner ids. Requires :ref:`gvm-perm-deterministic` as well.
+
Permission Changes on Sub-VM Creation
--------------------------------------
@@ -59,9 +69,12 @@ Different operations modify permissions when creating child :term:`sub-VM` insta
``CallContract``
~~~~~~~~~~~~~~~~
-Inherits all parent permissions except:
+A ``CallContract`` child runs as a read-only (static) call. It inherits all parent permissions except:
- :ref:`gvm-perm-write-storage` is **disabled**
+- :ref:`gvm-perm-send-messages` is **disabled**
+
+A static call must not produce externally visible effects, so storage writes, message sends, and event emissions all fail: emitting an event additionally requires :ref:`gvm-perm-write-storage` (disabled here). Allowing any such emission would charge fees for effects that are discarded together with the child VM.
``RunNondet``
~~~~~~~~~~~~~
@@ -74,6 +87,7 @@ The non-deterministic :term:`sub-VM` has:
- :ref:`gvm-perm-spawn-nondet` is **disabled**
- :ref:`gvm-perm-call-others` is **disabled**
- :ref:`gvm-perm-send-messages` is **disabled**
+- :ref:`gvm-perm-register-runners` is **disabled**
``Sandbox``
~~~~~~~~~~~
@@ -82,7 +96,8 @@ The sandboxed :term:`sub-VM` has:
- :ref:`gvm-perm-deterministic` is **inherited**
- :ref:`gvm-perm-read-storage` is **inherited**
-- :ref:`gvm-perm-write-storage` is **inherited** if ``allow_write_ops`` is set, otherwise **disabled**
+- :ref:`gvm-perm-write-storage` is **inherited** *iff* ``allow_write_storage`` is set, otherwise **disabled**
- :ref:`gvm-perm-spawn-nondet` is **disabled**
- :ref:`gvm-perm-call-others` is **disabled**
-- :ref:`gvm-perm-send-messages` is **inherited** if ``allow_write_ops`` is set, otherwise **disabled**
+- :ref:`gvm-perm-send-messages` is **inherited** *iff* ``allow_send_messages`` is set, otherwise **disabled**
+- :ref:`gvm-perm-register-runners` is **inherited** *iff* ``allow_register_runners`` is set, otherwise **disabled**
diff --git a/doc/website/src/spec/04-contract-interface/03-storage.rst b/doc/website/src/spec/04-contract-interface/03-storage.rst
index 07ee1014c..58182d53b 100644
--- a/doc/website/src/spec/04-contract-interface/03-storage.rst
+++ b/doc/website/src/spec/04-contract-interface/03-storage.rst
@@ -44,19 +44,29 @@ Root Slot
the following data:
-- ``contract_instance``: (offset 0) Reference to the contract instance data.
-- ``code``: (offset 1) The contract's code. Slot contains 4 bytes little-endian length followed by data
-- ``locked_slots``: (offset 2) A list of storage :term:`SlotID`\s that cannot be modified by non-upgraders.
- Slot contains 4 bytes little-endian length followed length arrays of 32 byte :term:`SlotID`\s
-- ``upgraders``: (offset 3) A list of addresses that are authorized to modify the contract code and locked slots.
- Slot contains 4 bytes little-endian length followed length arrays of 20 byte addresses
-- ``major``: (offset 4) Single octet (``u8``) identifying the major version of the public ABI
+- ``major``: (offset 0) Single octet (``u8``) identifying the major version of the public ABI
that the contract was built against. It is written at deploy time (the value is detected
from the contract package — see :doc:`04-upgradability` and the impl-spec for the detection flow)
and read on every load so :term:`GenVM` can refuse to execute a contract whose public ABI
major does not match the host's ``CURRENT_MAJOR``.
-
-Offsets at and above ``5`` are unused by :term:`GenVM` itself and are available to the contract
-runtime for bootstrapping its own storage (see ``Root.get_vacant_slot`` in the Python SDK).
+- ``contract_instance``: (offset 1) Reference to the contract instance data.
+- ``code``: (offset 2) The contract's code. Slot contains 4 bytes little-endian length followed by data
+- ``locked_slots``: (offset 3) A list of storage :term:`SlotID`\s that cannot be modified by non-upgraders.
+ Slot contains 4 bytes little-endian length followed length arrays of 32 byte :term:`SlotID`\s
+- ``upgraders``: (offset 4) A list of addresses that are authorized to modify the contract code and locked slots.
+ Slot contains 4 bytes little-endian length followed length arrays of 20 byte addresses
+- ``code_slot``: (offset 5) A raw 32-byte :term:`SlotID`. If it is all-zero (the default),
+ the contract code is read from the ``code`` slot (offset 2); otherwise the code is read from
+ the slot it points to (same 4-byte-length-prefixed layout). This lets a contract serve its
+ code from an arbitrary slot, including one shared via a ``chain:::`` runner id.
+
+Offsets are byte offsets into the root slot, so ``code_slot`` (32 bytes) occupies offsets ``5``
+through ``36``; the next field would start at offset ``37``.
+
+Offsets at and above ``37`` are reserved for future :term:`GenVM` use and should remain zero:
+a contract must not store data in them, otherwise a future :term:`GenVM` version that starts
+reading those offsets may break the contract. Contract runtimes that need scratch space for
+bootstrapping should derive a separate sub-slot instead (see ``Root.get_vacant_slot`` in the
+Python SDK).
Upgrade permissions and slot locking are described in :doc:`04-upgradability`.
diff --git a/doc/website/src/spec/04-contract-interface/04-upgradability.rst b/doc/website/src/spec/04-contract-interface/04-upgradability.rst
index e27226018..f75607c6e 100644
--- a/doc/website/src/spec/04-contract-interface/04-upgradability.rst
+++ b/doc/website/src/spec/04-contract-interface/04-upgradability.rst
@@ -47,7 +47,7 @@ Sub-VMs created by ``Sandbox`` and ``RunNondet`` cannot reach storage at all
Contract Major Version
----------------------
-The ``major`` field of :ref:`genvm-def-root-slot` (offset 4) stores the public-ABI major version
+The ``major`` field of :ref:`genvm-def-root-slot` (offset 0) stores the public-ABI major version
that the contract was built against. On every load :term:`GenVM` compares this byte to its own
``CURRENT_MAJOR`` constant and refuses to execute a contract whose major differs.
diff --git a/doc/website/src/spec/04-contract-interface/05-execution-flow.rst b/doc/website/src/spec/04-contract-interface/05-execution-flow.rst
index 72208c602..5ba584229 100644
--- a/doc/website/src/spec/04-contract-interface/05-execution-flow.rst
+++ b/doc/website/src/spec/04-contract-interface/05-execution-flow.rst
@@ -79,3 +79,19 @@ Extended-Message Format
pub entry_stage_data: calldata::Value,
}
+
+``CallContract`` semantics
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``CallContract`` performs a read-only (view) call into another contract. It **preserves** ``sender_address`` and ``origin_address``: the callee observes the *same* ``sender_address`` as the caller did, **not** the address of the immediate caller.
+
+The immediate caller — the contract that issued the ``CallContract`` — is pushed onto ``stack`` instead, so the callee always sees it as ``stack[-1]`` (the last element). For the top-level entrypoint ``stack`` is empty (refer to ``contract_address``); each nested ``CallContract`` appends one entry.
+
+Therefore a callee that needs to authorize its *immediate* caller MUST inspect ``stack[-1]`` rather than ``sender_address`` — the latter identifies the original transaction sender shared across the whole view-call chain.
+
+State visibility
+""""""""""""""""
+
+A ``CallContract`` child reads **committed** on-chain storage (the latest accepted / non-final state); it does **not** see the calling transaction's uncommitted writes. Consequently a contract does not observe its own in-transaction storage delta through a ``CallContract`` — including a self-call ``get_at(self.address)`` — even though a *direct* read does.
+
+This is intentional: it preserves causality and avoids exposing stale-then-fresh reads within one call. It is also a footgun: relying on a self-``CallContract`` to read state written earlier in the same transaction returns the old value. Use direct reads for in-transaction data, and treat ``get_at(self.address)`` as a read of the committed snapshot only.
diff --git a/doc/website/src/spec/04-contract-interface/06-gvm32.rst b/doc/website/src/spec/04-contract-interface/06-gvm32.rst
new file mode 100644
index 000000000..dd658944a
--- /dev/null
+++ b/doc/website/src/spec/04-contract-interface/06-gvm32.rst
@@ -0,0 +1,68 @@
+GVM32 (Base32)
+==============
+
+:term:`GenVM` encodes hash-like binary values — runner content hashes,
+``custom:`` ids and :term:`SlotID`\s — as text using **GVM32**, a variant
+of `Crockford's Base32 `_.
+
+Alphabet
+--------
+
+The 32-symbol alphabet is::
+
+ 0123456789abcdefghjkmnpqrstvwxyz
+
+That is, the digits ``0`` -- ``9`` followed by the lowercase letters ``a`` -- ``z`` with
+``i``, ``l``, ``o`` and ``u`` removed. The symbol at index ``v`` (``0 <= v < 32``)
+encodes the 5-bit value ``v``.
+
+Encoding
+--------
+
+The input is treated as a big-endian bit stream (the most significant bit of the
+first octet first). It is split into groups of 5 bits, each group emitted as the
+alphabet symbol for its value. If the final group has fewer than 5 bits it is
+padded on the **low** side with zero bits. There is **no** ``=`` padding.
+
+An input of ``N`` octets therefore produces ``ceil(N * 8 / 5)`` symbols. The
+canonical (and default) encoding is **lowercase**.
+
+.. code-block:: python
+
+ ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'
+
+ def encode(data: bytes) -> str:
+ out = []
+ value = bits = 0
+ for byte in data:
+ value = (value << 8) | byte
+ bits += 8
+ while bits >= 5:
+ bits -= 5
+ out.append(ALPHABET[(value >> bits) & 0x1F])
+ if bits > 0:
+ out.append(ALPHABET[(value << (5 - bits)) & 0x1F])
+ return ''.join(out)
+
+Decoding
+--------
+
+Decoding is **case-insensitive**. Following Crockford's rules, the letters ``i``
+and ``l`` (and ``I``, ``L``) are read as ``1`` and ``o`` (and ``O``) as ``0``.
+Hyphens (``-``) may appear for readability and are ignored. Any other character
+makes the string invalid.
+
+The 5-bit values are concatenated big-endian; every full group of 8 bits is one
+output octet. A well-formed string has its trailing (sub-octet) bits all zero —
+a decoder must reject a string whose leftover bits are non-zero.
+
+Usage
+-----
+
+- **Runner content hashes** — the ``hash`` component of a ``name:hash`` runner id
+ (see :doc:`../02-execution-environment/04-runners`) is the GVM32 encoding of the
+ runner archive's hash.
+- **Custom runner ids** — ``custom:`` where ```` is the GVM32 encoding
+ of the SHA3-256 of the registered code.
+- **Slot ids** — a :term:`SlotID` is rendered in GVM32, including the ````
+ component of a ``chain:::`` runner id.
diff --git a/doc/website/src/spec/04-contract-interface/index.rst b/doc/website/src/spec/04-contract-interface/index.rst
index 5d468c7fe..8ef110031 100644
--- a/doc/website/src/spec/04-contract-interface/index.rst
+++ b/doc/website/src/spec/04-contract-interface/index.rst
@@ -11,3 +11,4 @@ Interfaces and protocols for contract interaction and data management.
03-storage
04-upgradability
05-execution-flow
+ 06-gvm32
diff --git a/doc/website/src/spec/appendix/constants.rst b/doc/website/src/spec/appendix/constants.rst
new file mode 100644
index 000000000..0a4140517
--- /dev/null
+++ b/doc/website/src/spec/appendix/constants.rst
@@ -0,0 +1,384 @@
+Constants
+=========
+
+.. _gvm-def-enum-result-code:
+
+result_code
+-----------
+
+Type: u8
+
+.. _gvm-def-enum-value-result-code-return:
+
+return
+~~~~~~
+
+Value: ``0``
+
+.. _gvm-def-enum-value-result-code-user-error:
+
+user_error
+~~~~~~~~~~
+
+Value: ``1``
+
+.. _gvm-def-enum-value-result-code-vm-error:
+
+vm_error
+~~~~~~~~
+
+Value: ``2``
+
+.. _gvm-def-enum-value-result-code-internal-error:
+
+internal_error
+~~~~~~~~~~~~~~
+
+Value: ``3``
+
+.. _gvm-def-enum-storage-type:
+
+storage_type
+------------
+
+Type: u8
+
+.. _gvm-def-enum-value-storage-type-default:
+
+default
+~~~~~~~
+
+Value: ``0``
+
+.. _gvm-def-enum-value-storage-type-latest-final:
+
+latest_final
+~~~~~~~~~~~~
+
+Value: ``1``
+
+.. _gvm-def-enum-value-storage-type-latest-non-final:
+
+latest_non_final
+~~~~~~~~~~~~~~~~
+
+Value: ``2``
+
+.. _gvm-def-enum-entry-kind:
+
+entry_kind
+----------
+
+Type: u8
+
+.. _gvm-def-enum-value-entry-kind-main:
+
+main
+~~~~
+
+Value: ``0``
+
+.. _gvm-def-enum-value-entry-kind-sandbox:
+
+sandbox
+~~~~~~~
+
+Value: ``1``
+
+.. _gvm-def-enum-value-entry-kind-consensus-stage:
+
+consensus_stage
+~~~~~~~~~~~~~~~
+
+Value: ``2``
+
+.. _gvm-def-consts-memory-limiter-consts:
+
+memory_limiter_consts
+---------------------
+
+Type: u32
+
+.. _gvm-def-consts-value-memory-limiter-consts-table-entry:
+
+table_entry
+~~~~~~~~~~~
+
+Value: ``64``
+
+.. _gvm-def-consts-value-memory-limiter-consts-file-mapping:
+
+file_mapping
+~~~~~~~~~~~~
+
+Value: ``256``
+
+.. _gvm-def-consts-value-memory-limiter-consts-fd-allocation:
+
+fd_allocation
+~~~~~~~~~~~~~
+
+Value: ``96``
+
+.. _gvm-def-consts-root-offsets:
+
+root_offsets
+------------
+
+Type: u32
+
+.. _gvm-def-consts-value-root-offsets-major:
+
+major
+~~~~~
+
+Value: ``0``
+
+.. _gvm-def-consts-value-root-offsets-contract:
+
+contract
+~~~~~~~~
+
+Value: ``1``
+
+.. _gvm-def-consts-value-root-offsets-code:
+
+code
+~~~~
+
+Value: ``2``
+
+.. _gvm-def-consts-value-root-offsets-locked-slots:
+
+locked_slots
+~~~~~~~~~~~~
+
+Value: ``3``
+
+.. _gvm-def-consts-value-root-offsets-upgraders:
+
+upgraders
+~~~~~~~~~
+
+Value: ``4``
+
+.. _gvm-def-consts-value-root-offsets-code-slot:
+
+code_slot
+~~~~~~~~~
+
+Value: ``5``
+
+.. _gvm-def-consts-top-limits:
+
+top_limits
+----------
+
+Type: u32
+
+.. _gvm-def-consts-value-top-limits-nondet-blocks:
+
+nondet_blocks
+~~~~~~~~~~~~~
+
+Value: ``4096``
+
+.. _gvm-def-consts-value-top-limits-locked-slots:
+
+locked_slots
+~~~~~~~~~~~~
+
+Value: ``256``
+
+.. _gvm-def-consts-value-top-limits-upgraders:
+
+upgraders
+~~~~~~~~~
+
+Value: ``32``
+
+.. _gvm-def-consts-value-top-limits-vm-recursion:
+
+vm_recursion
+~~~~~~~~~~~~
+
+Value: ``512``
+
+.. _gvm-def-consts-value-top-limits-web-request-min-space:
+
+web_request_min_space
+~~~~~~~~~~~~~~~~~~~~~
+
+Value: ``65536``
+
+.. _gvm-def-consts-value-top-limits-web-render-min-space:
+
+web_render_min_space
+~~~~~~~~~~~~~~~~~~~~
+
+Value: ``134217728``
+
+.. _gvm-def-consts-value-top-limits-max-fds:
+
+max_fds
+~~~~~~~
+
+Value: ``1024``
+
+.. _gvm-def-enum-special-method:
+
+special_method
+--------------
+
+Type: str
+
+.. _gvm-def-enum-value-special-method-get-schema:
+
+get_schema
+~~~~~~~~~~
+
+Value: ``#get-schema``
+
+.. _gvm-def-enum-value-special-method-errored-message:
+
+errored_message
+~~~~~~~~~~~~~~~
+
+Value: ``#error``
+
+.. _gvm-def-str-trie-vm-error:
+
+vm_error
+--------
+
+Type: str_trie
+
+.. _gvm-def-str-trie-value-vm-error-timeout:
+
+``timeout``
+~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-absent-leader-nondet-output:
+
+``absent_leader_nondet_output``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-exit-code:
+
+``exit_code``
+~~~~~~~~~~~~~
+
+Param: i32
+
+.. _gvm-def-str-trie-value-vm-error-wasm-trap:
+
+``wasm_trap``
+~~~~~~~~~~~~~
+
+Param: str
+
+.. _gvm-def-str-trie-value-vm-error-OOM-RAM:
+
+``OOM RAM``
+~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-RAM-table:
+
+``OOM RAM table``
+~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-RAM-memory:
+
+``OOM RAM memory``
+~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-RAM-limit:
+
+``OOM RAM limit``
+~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-storage:
+
+``OOM storage``
+~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-receipt-nondet-output:
+
+``OOM receipt nondet_output``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-receipt-message-internal:
+
+``OOM receipt message internal``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-receipt-message-external:
+
+``OOM receipt message external``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-fees-internal:
+
+``OOM fees internal``
+~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-OOM-fees-external:
+
+``OOM fees external``
+~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract:
+
+``invalid_contract``
+~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract-absent-runner-comment:
+
+``invalid_contract absent_runner_comment``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract-not-utf8-text:
+
+``invalid_contract not_utf8_text``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract-malformed-runner:
+
+``invalid_contract malformed_runner``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract-major-mismatch:
+
+``invalid_contract major_mismatch``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract-wasm-validating:
+
+``invalid_contract wasm validating``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract-wasm-linking:
+
+``invalid_contract wasm linking``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-invalid-contract-wasm-entrypoint:
+
+``invalid_contract wasm entrypoint``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. _gvm-def-str-trie-value-vm-error-host:
+
+``host``
+~~~~~~~~
+
+Param: str
+
+.. _gvm-def-const-event-max-topics:
+
+event_max_topics
+----------------
+
+Type: u32
+
+Value: ``4``
diff --git a/executor/Cargo.lock b/executor/Cargo.lock
index aa0cca546..a347bf38a 100644
--- a/executor/Cargo.lock
+++ b/executor/Cargo.lock
@@ -958,6 +958,7 @@ dependencies = [
"num-bigint",
"primitive-types",
"serde",
+ "sha3",
]
[[package]]
@@ -1026,7 +1027,6 @@ dependencies = [
"serde_with",
"serde_yaml",
"sha3",
- "signal-hook",
"symbol_table",
"tokio",
"tokio-mpmc",
@@ -1044,7 +1044,7 @@ dependencies = [
[[package]]
name = "genvm-common"
-version = "0.1.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"arbitrary",
@@ -1066,6 +1066,7 @@ dependencies = [
"rpds",
"rustix 0.38.44",
"serde",
+ "serde_bytes",
"serde_json",
"serde_yaml",
"symbol_table",
@@ -2141,16 +2142,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
-[[package]]
-name = "signal-hook"
-version = "0.3.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
-dependencies = [
- "libc",
- "signal-hook-registry",
-]
-
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
diff --git a/executor/Cargo.toml b/executor/Cargo.toml
index c7a49b2ae..6814eeb25 100644
--- a/executor/Cargo.toml
+++ b/executor/Cargo.toml
@@ -49,7 +49,6 @@ clap = { version = "4.5.35", features = ["derive"] }
serde_with = { version = "3.12.0", features = ["base64"] }
itertools = "0.14.0"
zip = "4.0.0"
-signal-hook = "0.3.17"
once_cell = "1.21.3"
chrono = { version = "0.4.41", default-features = false, features = ["clock", "std", "serde"] }
base64 = "0.22.1"
diff --git a/executor/codegen/data/public-abi.json b/executor/codegen/data/public-abi.json
index 9d49a4582..ad7993c6c 100644
--- a/executor/codegen/data/public-abi.json
+++ b/executor/codegen/data/public-abi.json
@@ -45,11 +45,12 @@
"name": "root_offsets",
"repr": "u32",
"values": {
- "contract": 0,
- "code": 1,
- "locked_slots": 2,
- "upgraders": 3,
- "major": 4
+ "major": 0,
+ "contract": 1,
+ "code": 2,
+ "locked_slots": 3,
+ "upgraders": 4,
+ "code_slot": 5
}
},
{
@@ -122,6 +123,7 @@
"absent_runner_comment",
"not_utf8_text",
"malformed_runner",
+ "major_mismatch",
{
"head": "wasm",
"tail": ["validating", "linking", "entrypoint"]
diff --git a/executor/codegen/templates/rs.rb b/executor/codegen/templates/rs.rb
index dbdb78234..a058f763e 100755
--- a/executor/codegen/templates/rs.rb
+++ b/executor/codegen/templates/rs.rb
@@ -130,9 +130,9 @@ def gen_rust_trie_builder(entries, root_name, buf)
buf << "#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n"
buf << "pub struct #{root_name}(pub Cow<'static, str>);\n\n"
- buf << "impl Into for #{root_name} {\n"
- buf << " fn into(self) -> String {\n"
- buf << " self.0.into()\n"
+ buf << "impl From<#{root_name}> for String {\n"
+ buf << " fn from(val: #{root_name}) -> String {\n"
+ buf << " val.0.into()\n"
buf << " }\n"
buf << "}\n"
buf << "#[rustfmt::skip]\n"
diff --git a/executor/crates/calldata/Cargo.lock b/executor/crates/calldata/Cargo.lock
index 434876756..5d204f1f7 100644
--- a/executor/crates/calldata/Cargo.lock
+++ b/executor/crates/calldata/Cargo.lock
@@ -53,6 +53,15 @@ dependencies = [
"wyz",
]
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -136,12 +145,41 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -172,6 +210,16 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
[[package]]
name = "genlayer_calldata"
version = "0.0.2"
@@ -185,6 +233,7 @@ dependencies = [
"num-bigint",
"primitive-types",
"serde",
+ "sha3",
]
[[package]]
@@ -301,6 +350,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures",
+]
+
[[package]]
name = "libc"
version = "0.2.183"
@@ -523,6 +581,16 @@ dependencies = [
"syn",
]
+[[package]]
+name = "sha3"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -582,6 +650,12 @@ dependencies = [
"winnow",
]
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "uint"
version = "0.10.0"
@@ -606,6 +680,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
diff --git a/executor/crates/calldata/Cargo.toml b/executor/crates/calldata/Cargo.toml
index 6d439c06a..78b591c35 100644
--- a/executor/crates/calldata/Cargo.toml
+++ b/executor/crates/calldata/Cargo.toml
@@ -30,6 +30,7 @@ arbitrary = ["dep:arbitrary"]
genlayer_calldata_derive = { path = "../calldata-derive" }
hex = "0.4.3"
num-bigint = {version = "0.4.6", features = ["serde"] }
+sha3 = { version = "0.10" }
serde = { version = "1.0", features = ["derive"] }
arbitrary = { version = "1.0", optional = true }
primitive-types = { version = "0.13", features = ["impl-serde"] }
@@ -38,3 +39,4 @@ chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
afl = { version = "0.15.18", features = ["no_cfg_fuzzing"] }
+hex = "0.4.3"
diff --git a/executor/crates/calldata/src/types.rs b/executor/crates/calldata/src/types.rs
index 3329f449b..f94215d50 100644
--- a/executor/crates/calldata/src/types.rs
+++ b/executor/crates/calldata/src/types.rs
@@ -7,13 +7,13 @@ pub struct Address(pub(super) [u8; ADDRESS_SIZE]);
impl std::fmt::Debug for Address {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_fmt(format_args!("addr#{}", hex::encode(self.0)))
+ f.write_fmt(format_args!("addr#{}", self.checksum_hex_string()))
}
}
impl std::fmt::Display for Address {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "addr#{}", hex::encode(self.0))
+ f.write_fmt(format_args!("addr#{}", self.checksum_hex_string()))
}
}
@@ -37,6 +37,37 @@ impl Address {
self.0
}
+ /// Returns the address as an [EIP-55](https://eips.ethereum.org/EIPS/eip-55)
+ /// mixed-case checksummed hex, as 40 ASCII bytes (no `0x` prefix).
+ pub fn checksum_hex(self) -> [u8; 40] {
+ use sha3::Digest as _;
+
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut out = [0u8; 40];
+ for (i, &b) in self.0.iter().enumerate() {
+ out[i * 2] = HEX[(b >> 4) as usize];
+ out[i * 2 + 1] = HEX[(b & 0x0f) as usize];
+ }
+
+ // hash of the lowercase hex; a hex letter is upper-cased when the
+ // matching hash nibble is >= 8
+ let hash = sha3::Keccak256::digest(out);
+ for i in 0..out.len() {
+ if out[i].is_ascii_alphabetic() {
+ let nibble = hash[i / 2] >> (if i % 2 == 0 { 4 } else { 0 }) & 0x0f;
+ if nibble >= 8 {
+ out[i] = out[i].to_ascii_uppercase();
+ }
+ }
+ }
+ out
+ }
+
+ /// Like [`Address::checksum_hex`], but as an owned `String`.
+ pub fn checksum_hex_string(self) -> String {
+ self.checksum_hex().iter().map(|&b| b as char).collect()
+ }
+
pub fn ref_mut(&mut self) -> &mut [u8; ADDRESS_SIZE] {
&mut self.0
}
diff --git a/executor/crates/calldata/tests/address_checksum.rs b/executor/crates/calldata/tests/address_checksum.rs
new file mode 100644
index 000000000..287932b87
--- /dev/null
+++ b/executor/crates/calldata/tests/address_checksum.rs
@@ -0,0 +1,22 @@
+use genlayer_calldata::Address;
+
+fn addr(lower_hex: &str) -> Address {
+ let mut b = [0u8; 20];
+ hex::decode_to_slice(lower_hex, &mut b).unwrap();
+ Address::from(b)
+}
+
+#[test]
+fn eip55_checksum() {
+ // vectors from EIP-55 (https://eips.ethereum.org/EIPS/eip-55)
+ for expected in [
+ "5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
+ "fB6916095ca1df60bB79Ce92cE3Ea74c37c5d359",
+ "dbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB",
+ "D1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb",
+ ] {
+ let a = addr(&expected.to_lowercase());
+ assert_eq!(a.checksum_hex().as_slice(), expected.as_bytes());
+ assert_eq!(a.checksum_hex_string(), expected);
+ }
+}
diff --git a/executor/crates/common/Cargo.lock b/executor/crates/common/Cargo.lock
index e27cb8e67..68c55320b 100644
--- a/executor/crates/common/Cargo.lock
+++ b/executor/crates/common/Cargo.lock
@@ -485,6 +485,7 @@ dependencies = [
"num-bigint",
"primitive-types",
"serde",
+ "sha3",
]
[[package]]
@@ -514,7 +515,7 @@ dependencies = [
[[package]]
name = "genvm-common"
-version = "0.1.0"
+version = "0.3.0"
dependencies = [
"anyhow",
"arbitrary",
diff --git a/executor/crates/common/Cargo.toml b/executor/crates/common/Cargo.toml
index d1ff41dfb..76418ca91 100644
--- a/executor/crates/common/Cargo.toml
+++ b/executor/crates/common/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "genvm-common"
-version = "0.1.0"
+version = "0.3.0"
edition = "2021"
[profile.release]
@@ -16,6 +16,7 @@ regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive", "rc"] }
serde_json = "1.0.140"
serde_yaml = "0.9.34"
+serde_bytes = "0.11.17"
tokio = { version = "1.44.1", features = ["rt", "rt-multi-thread", "net", "time", "macros", "sync"], default-features = false}
num-bigint = { version = "0.4.6", features = ["serde"] }
num-rational = { version = "0.4", default-features = false, features = ["num-bigint"] }
diff --git a/executor/crates/common/build.rs b/executor/crates/common/build.rs
index b55b44a33..609fd1368 100644
--- a/executor/crates/common/build.rs
+++ b/executor/crates/common/build.rs
@@ -16,7 +16,7 @@ fn main() -> std::io::Result<()> {
let profile = std::env::var("PROFILE").unwrap();
println!("cargo::rustc-env=PROFILE={profile}");
- let tag = tag.replace("-", "_");
+ // tag can have `-`, parse from the right
let arch = arch.replace("-", "_");
let os = os.replace("-", "_");
let profile = profile.replace("-", "_");
diff --git a/executor/crates/common/src/bytes32.rs b/executor/crates/common/src/bytes32.rs
new file mode 100644
index 000000000..a3d9223ff
--- /dev/null
+++ b/executor/crates/common/src/bytes32.rs
@@ -0,0 +1,109 @@
+//! A 32-byte hash (runner content hash, `custom:` hash, …) with GVM32 formatting.
+
+/// A 32-byte hash value. Formats (and parses) as GVM32 / Crockford Base32 (see
+/// [`genlayer_sdk::gvm32`]).
+#[derive(
+ Clone,
+ Copy,
+ PartialEq,
+ Eq,
+ Hash,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+ genlayer_calldata::Encode,
+ genlayer_calldata::Decode,
+ arbitrary::Arbitrary,
+)]
+#[repr(transparent)]
+pub struct Bytes32Hash(
+ #[serde(with = "serde_bytes")]
+ #[calldata(
+ serialize_with = genlayer_calldata::codec::as_bytes::serialize,
+ deserialize_with = genlayer_calldata::codec::as_bytes::deserialize,
+ )]
+ pub [u8; 32],
+);
+
+impl Bytes32Hash {
+ pub const ZERO: Self = Self([0; 32]);
+
+ pub const fn from_bytes(bytes: [u8; 32]) -> Self {
+ Self(bytes)
+ }
+
+ pub fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+
+ pub fn raw(&self) -> [u8; 32] {
+ self.0
+ }
+
+ /// The GVM32 (Crockford Base32) textual form.
+ pub fn to_gvm32(&self) -> String {
+ genlayer_sdk::gvm32::encode(&self.0)
+ }
+
+ /// Parses a hash from its GVM32 form.
+ ///
+ /// Fails with [`FromGvm32Error::InvalidEncoding`] for a malformed encoding or
+ /// [`FromGvm32Error::WrongLength`] for one that does not decode to exactly 32
+ /// bytes.
+ pub fn from_gvm32(s: &str) -> Result {
+ let bytes = genlayer_sdk::gvm32::decode(s).map_err(FromGvm32Error::InvalidEncoding)?;
+ let got = bytes.len();
+ let bytes: [u8; 32] = bytes
+ .try_into()
+ .map_err(|_| FromGvm32Error::WrongLength { got })?;
+ Ok(Self(bytes))
+ }
+}
+
+/// Error returned by [`Bytes32Hash::from_gvm32`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum FromGvm32Error {
+ /// The string is not valid GVM32.
+ InvalidEncoding(genlayer_sdk::gvm32::DecodeError),
+ /// Decoded successfully, but not to exactly 32 bytes.
+ WrongLength { got: usize },
+}
+
+impl std::fmt::Display for FromGvm32Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ FromGvm32Error::InvalidEncoding(e) => write!(f, "invalid gvm32 encoding: {e}"),
+ FromGvm32Error::WrongLength { got } => {
+ write!(f, "expected 32 bytes, got {got}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for FromGvm32Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ FromGvm32Error::InvalidEncoding(e) => Some(e),
+ FromGvm32Error::WrongLength { .. } => None,
+ }
+ }
+}
+
+impl From<[u8; 32]> for Bytes32Hash {
+ fn from(bytes: [u8; 32]) -> Self {
+ Self(bytes)
+ }
+}
+
+impl std::fmt::Display for Bytes32Hash {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.to_gvm32())
+ }
+}
+
+impl std::fmt::Debug for Bytes32Hash {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_fmt(format_args!("hash#{}", self.to_gvm32()))
+ }
+}
diff --git a/executor/crates/common/src/debug_mode.rs b/executor/crates/common/src/debug_mode.rs
new file mode 100644
index 000000000..0aee8c128
--- /dev/null
+++ b/executor/crates/common/src/debug_mode.rs
@@ -0,0 +1,100 @@
+use serde::{Deserialize, Serialize};
+
+/// How the manager captures a per-execution's logs and stdout/stderr.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Capture {
+ /// Nothing is captured: stdout/stderr go to `/dev/null` and logs are routed
+ /// to the manager's own log.
+ #[default]
+ Disabled,
+ /// Captured, but bounded: the oldest log entries are dropped past a cap and
+ /// stdout/stderr are truncated to a tail.
+ Bounded,
+ /// Captured in full.
+ Unbounded,
+}
+
+/// Executor debug level. Levels are ordered and each *adds* to the previous one.
+///
+/// - `Disabled`: production. No capture, no debug aids.
+/// - `Safe`: captures logs and stdout/stderr (bounded). Deterministic.
+/// - `SafeUnbounded`: capture is unbounded and `Trace` gl_call output is emitted.
+/// Deterministic.
+/// - `Unsafe`: also resolves the `:latest` / `:test` runner ids. This is
+/// *unsafe across machines* — different nodes may resolve different code — so
+/// it can diverge consensus, but a single node stays deterministic.
+/// - `UnsafeTracing`: also exposes real wall-clock time to the contract
+/// (`RuntimeMicroSec`). This can break determinism even on a single machine.
+/// Local debugging only.
+#[derive(
+ Debug,
+ Clone,
+ Copy,
+ PartialEq,
+ Eq,
+ PartialOrd,
+ Ord,
+ Default,
+ clap::ValueEnum,
+ Serialize,
+ Deserialize,
+ ::genlayer_calldata::Encode,
+ ::genlayer_calldata::Decode,
+)]
+#[clap(rename_all = "kebab-case")]
+#[serde(rename_all = "kebab-case")]
+pub enum DebugMode {
+ #[default]
+ #[calldata(rename = "disabled")]
+ Disabled,
+ #[calldata(rename = "safe")]
+ Safe,
+ #[calldata(rename = "safe-unbounded")]
+ SafeUnbounded,
+ #[calldata(rename = "unsafe")]
+ Unsafe,
+ #[calldata(rename = "unsafe-tracing")]
+ UnsafeTracing,
+}
+
+impl DebugMode {
+ /// How the manager should capture logs and stdout/stderr.
+ pub fn capture(self) -> Capture {
+ match self {
+ DebugMode::Disabled => Capture::Disabled,
+ DebugMode::Safe => Capture::Bounded,
+ DebugMode::SafeUnbounded | DebugMode::Unsafe | DebugMode::UnsafeTracing => {
+ Capture::Unbounded
+ }
+ }
+ }
+
+ /// `SafeUnbounded`+: emit `Trace` gl_call output instead of skipping it.
+ pub fn allows_tracing(self) -> bool {
+ self >= DebugMode::SafeUnbounded
+ }
+
+ /// `Unsafe`+: the `:latest` / `:test` runner ids may be resolved. Unsafe
+ /// across machines (consensus may diverge).
+ pub fn allows_latest_resolution(self) -> bool {
+ self >= DebugMode::Unsafe
+ }
+
+ /// `UnsafeTracing`: exposes non-deterministic data to the contract, such as
+ /// real wall-clock time metering. Can break determinism on a single machine.
+ pub fn allows_nondeterminism(self) -> bool {
+ self >= DebugMode::UnsafeTracing
+ }
+
+ /// CLI value passed to the executor's `--debug-mode` flag.
+ pub fn as_arg(self) -> &'static str {
+ match self {
+ DebugMode::Disabled => "disabled",
+ DebugMode::Safe => "safe",
+ DebugMode::SafeUnbounded => "safe-unbounded",
+ DebugMode::Unsafe => "unsafe",
+ DebugMode::UnsafeTracing => "unsafe-tracing",
+ }
+ }
+}
diff --git a/executor/crates/common/src/lib.rs b/executor/crates/common/src/lib.rs
index c4e6132cc..ed150c507 100644
--- a/executor/crates/common/src/lib.rs
+++ b/executor/crates/common/src/lib.rs
@@ -5,7 +5,12 @@ use serde::{Deserialize, Serialize};
pub use genlayer_sdk::calldata;
+pub mod bytes32;
+pub use bytes32::Bytes32Hash;
+
pub mod cancellation;
+pub mod debug_mode;
+pub use debug_mode::DebugMode;
pub mod io;
pub mod logger;
pub mod stats;
diff --git a/executor/crates/common/src/sync.rs b/executor/crates/common/src/sync.rs
index 4f5593eca..2159e512f 100644
--- a/executor/crates/common/src/sync.rs
+++ b/executor/crates/common/src/sync.rs
@@ -444,6 +444,11 @@ impl CacheMap {
Self(dashmap::DashMap::new())
}
+ pub fn insert(&self, key: symbol_table::GlobalSymbol, value: T) {
+ self.0
+ .insert(key, DArc::new(tokio::sync::OnceCell::new_with(Some(value))));
+ }
+
pub async fn get_or_create(
&self,
key: symbol_table::GlobalSymbol,
diff --git a/executor/crates/common/src/version.rs b/executor/crates/common/src/version.rs
index 647d77ec4..c344f56b7 100644
--- a/executor/crates/common/src/version.rs
+++ b/executor/crates/common/src/version.rs
@@ -71,24 +71,81 @@ impl Version {
}
}
-pub static CURRENT: std::sync::LazyLock = std::sync::LazyLock::new(|| {
- if crate::VERSION.starts_with("vTEST") {
+/// Parses a `u16` starting at `i`, returning the value and the index past the
+/// last digit consumed.
+const fn parse_u16(s: &[u8], mut i: usize) -> (u16, usize, bool) {
+ let mut n: u16 = 0;
+ let mut consumed = false;
+ while i < s.len() && s[i] >= b'0' && s[i] <= b'9' {
+ n = n * 10 + (s[i] - b'0') as u16;
+ i += 1;
+ consumed = true;
+ }
+ (n, i, consumed)
+}
+
+const fn const_starts_with(s: &[u8], prefix: &[u8]) -> bool {
+ if s.len() < prefix.len() {
+ return false;
+ }
+ let mut i = 0;
+ while i < prefix.len() {
+ if s[i] != prefix[i] {
+ return false;
+ }
+ i += 1;
+ }
+ true
+}
+
+const fn current_version() -> Version {
+ if const_starts_with(crate::VERSION.as_bytes(), b"vTEST") {
+ // vTEST has no numeric build id, so report this crate's compile-time
+ // version (kept at the active dev major/minor via `.genvm-monorepo-root`)
+ // with a max patch so it sorts newer than any real release of that line.
+ let pkg = env!("CARGO_PKG_VERSION").as_bytes();
+ let (major, i, ok_major) = parse_u16(pkg, 0);
+ if !ok_major || i >= pkg.len() || pkg[i] != b'.' {
+ panic!("invalid CARGO_PKG_VERSION: missing major");
+ }
+ let (minor, _, ok_minor) = parse_u16(pkg, i + 1); // skip the '.'
+ if !ok_minor {
+ panic!("invalid CARGO_PKG_VERSION: missing minor");
+ }
return Version {
- major: 99,
- minor: 0,
- patch: 0,
+ major,
+ minor,
+ patch: u16::MAX,
};
}
- regex::Regex::new(r"^v(\d+)\.(\d+)\.(\d+)")
- .unwrap()
- .captures(crate::VERSION)
- .and_then(|caps| {
- Some(Version {
- major: caps[1].parse().ok()?,
- minor: caps[2].parse().ok()?,
- patch: caps[3].parse().ok()?,
- })
- })
- .unwrap()
-});
+ // release build id is `v..-...`
+ let id = crate::VERSION.as_bytes();
+ let start = if !id.is_empty() && id[0] == b'v' {
+ 1
+ } else {
+ 0
+ };
+ let (major, i, ok_major) = parse_u16(id, start);
+ if !ok_major || i >= id.len() || id[i] != b'.' {
+ panic!("invalid VERSION: missing major");
+ }
+ let (minor, i, ok_minor) = parse_u16(id, i + 1); // skip '.'
+ if !ok_minor || i >= id.len() || id[i] != b'.' {
+ panic!("invalid VERSION: missing minor");
+ }
+ let (patch, i, ok_patch) = parse_u16(id, i + 1); // skip '.'
+ if !ok_patch {
+ panic!("invalid VERSION: missing patch");
+ }
+ if i < id.len() && id[i] != b'-' && id[i] != b'\n' {
+ panic!("invalid VERSION: unexpected character after patch");
+ }
+ Version {
+ major,
+ minor,
+ patch,
+ }
+}
+
+pub const CURRENT: Version = current_version();
diff --git a/executor/crates/common/tests/bytes32.rs b/executor/crates/common/tests/bytes32.rs
new file mode 100644
index 000000000..22db664a2
--- /dev/null
+++ b/executor/crates/common/tests/bytes32.rs
@@ -0,0 +1,45 @@
+use genlayer_sdk::gvm32::DecodeError;
+use genvm_common::bytes32::FromGvm32Error;
+use genvm_common::Bytes32Hash;
+
+#[test]
+fn gvm32_round_trip() {
+ let bytes = [0xabu8; 32];
+ let h = Bytes32Hash::from_bytes(bytes);
+
+ let s = h.to_gvm32();
+ assert_eq!(s.len(), 52); // ceil(256 / 5)
+ assert_eq!(Bytes32Hash::from_gvm32(&s), Ok(h));
+ assert_eq!(h.to_string(), s); // Display == gvm32
+ assert_eq!(h.as_bytes(), &bytes);
+}
+
+#[test]
+fn from_gvm32_rejects_invalid() {
+ // valid gvm32, but decodes to fewer than 32 bytes
+ assert_eq!(
+ Bytes32Hash::from_gvm32("00"), // decodes to 1 byte
+ Err(FromGvm32Error::WrongLength { got: 1 })
+ );
+ assert_eq!(
+ Bytes32Hash::from_gvm32(""), // decodes to 0 bytes
+ Err(FromGvm32Error::WrongLength { got: 0 })
+ );
+ // '!' is not in the alphabet
+ assert_eq!(
+ Bytes32Hash::from_gvm32("!"),
+ Err(FromGvm32Error::InvalidEncoding(DecodeError::InvalidChar(
+ '!'
+ )))
+ );
+ // valid alphabet, but non-zero trailing padding bits
+ assert_eq!(
+ Bytes32Hash::from_gvm32("abc"),
+ Err(FromGvm32Error::InvalidEncoding(
+ DecodeError::NonZeroPadding {
+ bits: 7,
+ value: 108
+ }
+ ))
+ );
+}
diff --git a/executor/crates/sdk-rs/Cargo.lock b/executor/crates/sdk-rs/Cargo.lock
index 4fac2d5cf..0fb63fa8d 100644
--- a/executor/crates/sdk-rs/Cargo.lock
+++ b/executor/crates/sdk-rs/Cargo.lock
@@ -246,6 +246,7 @@ dependencies = [
"num-bigint",
"primitive-types",
"serde",
+ "sha3",
]
[[package]]
diff --git a/executor/crates/sdk-rs/examples/fetch_webpage.rs b/executor/crates/sdk-rs/examples/fetch_webpage.rs
index 59bf9a4ac..8ef1e27c0 100644
--- a/executor/crates/sdk-rs/examples/fetch_webpage.rs
+++ b/executor/crates/sdk-rs/examples/fetch_webpage.rs
@@ -167,7 +167,11 @@ fn run_nondet(entry_data: &[u8]) -> Result {
match result {
LeaderResult::Return(value) => Ok(value),
- LeaderResult::UserError(msg) => {
+ LeaderResult::UserError(value) => {
+ let msg = match value {
+ Value::Str(s) => s,
+ other => format!("{other:?}"),
+ };
genlayer_sdk::abi::entry::user_error_immediately(msg);
}
LeaderResult::VmError(msg) => {
diff --git a/executor/crates/sdk-rs/src/abi/consts.rs b/executor/crates/sdk-rs/src/abi/consts.rs
index ec17ae01a..7d84c7869 100644
--- a/executor/crates/sdk-rs/src/abi/consts.rs
+++ b/executor/crates/sdk-rs/src/abi/consts.rs
@@ -158,11 +158,12 @@ pub mod memory_limiter_consts {
}
pub mod root_offsets {
- pub const CONTRACT: u32 = 0;
- pub const CODE: u32 = 1;
- pub const LOCKED_SLOTS: u32 = 2;
- pub const UPGRADERS: u32 = 3;
- pub const MAJOR: u32 = 4;
+ pub const MAJOR: u32 = 0;
+ pub const CONTRACT: u32 = 1;
+ pub const CODE: u32 = 2;
+ pub const LOCKED_SLOTS: u32 = 3;
+ pub const UPGRADERS: u32 = 4;
+ pub const CODE_SLOT: u32 = 5;
}
pub mod top_limits {
@@ -276,6 +277,7 @@ pub mod __VmError {
pub const fn absent_runner_comment(&self) -> VmError { VmError(Cow::Borrowed("invalid_contract absent_runner_comment")) }
pub const fn not_utf8_text(&self) -> VmError { VmError(Cow::Borrowed("invalid_contract not_utf8_text")) }
pub const fn malformed_runner(&self) -> VmError { VmError(Cow::Borrowed("invalid_contract malformed_runner")) }
+ pub const fn major_mismatch(&self) -> VmError { VmError(Cow::Borrowed("invalid_contract major_mismatch")) }
pub const fn wasm(&self) -> InvalidContractWasm { InvalidContractWasm }
}
@@ -309,7 +311,7 @@ pub mod __VmError {
pub struct VmError(pub Cow<'static, str>);
impl From for String {
- fn from(val: VmError) -> Self {
+ fn from(val: VmError) -> String {
val.0.into()
}
}
diff --git a/executor/crates/sdk-rs/src/abi/entry.rs b/executor/crates/sdk-rs/src/abi/entry.rs
index fd22e65f0..d21c6025d 100644
--- a/executor/crates/sdk-rs/src/abi/entry.rs
+++ b/executor/crates/sdk-rs/src/abi/entry.rs
@@ -406,7 +406,7 @@ pub mod contract_def {
#[derive(Debug, Clone)]
pub enum LeaderResult {
Return(Value),
- UserError(String),
+ UserError(Value),
VmError(String),
}
@@ -428,11 +428,9 @@ pub mod contract_def {
Ok(LeaderResult::Return(value))
}
ResultCode::UserError => {
- // FIXME: deprecate in next release (raw-string user error encoding;
- // switch to the tagged calldata form)
- let msg = String::from_utf8(rest.to_vec())
- .map_err(|e| ConsensusStageParseError::InvalidUtf8(e.to_string()))?;
- Ok(LeaderResult::UserError(msg))
+ let value = calldata::decode(rest)
+ .map_err(|e| ConsensusStageParseError::DecodeError(e.to_string()))?;
+ Ok(LeaderResult::UserError(value))
}
ResultCode::VmError | ResultCode::InternalError => {
let msg = String::from_utf8(rest.to_vec())
diff --git a/executor/crates/sdk-rs/src/abi/gl_call.rs b/executor/crates/sdk-rs/src/abi/gl_call.rs
index 0a2ce6992..78d571ca2 100644
--- a/executor/crates/sdk-rs/src/abi/gl_call.rs
+++ b/executor/crates/sdk-rs/src/abi/gl_call.rs
@@ -231,13 +231,9 @@ pub mod llm_iface {
#[serde(rename = "text")]
#[calldata(rename = "text")]
Text,
- // FIXME: deprecate in next release (json will adopt json2 behavior)
#[serde(rename = "json")]
#[calldata(rename = "json")]
JSON,
- #[serde(rename = "json2")]
- #[calldata(rename = "json2")]
- JSON2,
}
fn default_text() -> OutputFormat {
@@ -573,7 +569,22 @@ pub enum Message {
#[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_bytes))]
data: Bytes,
- allow_write_ops: bool,
+ runner: String,
+
+ allow_write_storage: bool,
+ allow_send_messages: bool,
+ allow_register_runners: bool,
+ },
+
+ RegisterRunner {
+ #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_bytes))]
+ code: Bytes,
+ },
+
+ MapFile {
+ runner: String,
+ path_in_runner: String,
+ path_in_vfs: String,
},
WebRender(web_iface::RenderPayload),
@@ -581,11 +592,18 @@ pub enum Message {
ExecPrompt(llm_iface::PromptPayload),
ExecPromptTemplate(llm_iface::PromptTemplatePayload),
- // FIXME: deprecate in next release
- #[deprecated(note = "Use UserError. Will be removed before 1.0 release")]
- Rollback(calldata::unparsed::Maybe),
UserError(calldata::unparsed::Maybe),
Return(calldata::unparsed::Maybe),
Trace(TracePayload),
+
+ /// Cooperative yield. Currently a no-op; reserved for future use in
+ /// waiting loops.
+ Yield,
+
+ /// Get the current timestamp as seconds since the Unix epoch.
+ ///
+ /// In [deterministic mode](super) returns the transaction timestamp; in
+ /// non-deterministic mode returns the real wall-clock time.
+ GetTimestamp,
}
diff --git a/executor/crates/sdk-rs/src/gvm32.rs b/executor/crates/sdk-rs/src/gvm32.rs
new file mode 100644
index 000000000..142db82c1
--- /dev/null
+++ b/executor/crates/sdk-rs/src/gvm32.rs
@@ -0,0 +1,92 @@
+//! # GenVM base32 encoder/decoder
+//!
+//! Uses [Crockford's Base32](https://www.crockford.com/base32.html): the
+//! alphabet `0123456789abcdefghjkmnpqrstvwxyz` (excludes `i`, `l`, `o`, `u`),
+//! no padding, big-endian bit packing. Encoding is lowercase by default;
+//! decoding is case-insensitive, treats `i`/`l` as `1` and `o` as `0`, and
+//! ignores `-`. Used for encoding hashes.
+
+const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
+
+/// Encodes a byte slice as a Crockford Base32 string (lowercase, no padding).
+pub fn encode(bytes: &[u8]) -> String {
+ let mut out = String::with_capacity(bytes.len() * 8 / 5 + 1);
+ let mut value: u32 = 0;
+ let mut bits: u32 = 0;
+ for &b in bytes {
+ value = (value << 8) | b as u32;
+ bits += 8;
+ while bits >= 5 {
+ bits -= 5;
+ out.push(ALPHABET[((value >> bits) & 0x1f) as usize] as char);
+ }
+ value &= (1 << bits) - 1;
+ }
+ if bits > 0 {
+ out.push(ALPHABET[((value << (5 - bits)) & 0x1f) as usize] as char);
+ }
+ out
+}
+
+fn decode_char(c: u8) -> Option {
+ let c = match c.to_ascii_lowercase() {
+ b'o' => b'0',
+ b'i' | b'l' => b'1',
+ other => other,
+ };
+ ALPHABET.iter().position(|&b| b == c).map(|p| p as u8)
+}
+
+/// Reason a [`decode`] call failed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum DecodeError {
+ /// A character outside the Crockford Base32 alphabet (after the `i`/`l`/`o`
+ /// aliases and `-` handling).
+ InvalidChar(char),
+ /// The final group carried non-zero padding bits, so the input is not a
+ /// canonical encoding of any byte sequence. Carries the number of leftover
+ /// `bits` and their non-zero `value` to aid debugging.
+ NonZeroPadding { bits: u32, value: u32 },
+}
+
+impl core::fmt::Display for DecodeError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ DecodeError::InvalidChar(c) => write!(f, "invalid gvm32 character `{c}`"),
+ DecodeError::NonZeroPadding { bits, value } => write!(
+ f,
+ "non-zero gvm32 trailing padding: {bits} bit(s) = {value:#x}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for DecodeError {}
+
+/// Decodes a Crockford Base32 string. Case-insensitive; `i`/`l` are read as `1`,
+/// `o` as `0` and `-` is ignored.
+///
+/// Fails with [`DecodeError::InvalidChar`] on a character outside the alphabet
+/// or [`DecodeError::NonZeroPadding`] on non-zero trailing padding bits.
+pub fn decode(s: &str) -> Result, DecodeError> {
+ let mut out = Vec::with_capacity(s.len() * 5 / 8);
+ let mut value: u32 = 0;
+ let mut bits: u32 = 0;
+ for &c in s.as_bytes() {
+ if c == b'-' {
+ continue;
+ }
+ let digit = decode_char(c).ok_or(DecodeError::InvalidChar(c as char))?;
+ value = (value << 5) | digit as u32;
+ bits += 5;
+ if bits >= 8 {
+ bits -= 8;
+ out.push((value >> bits) as u8);
+ value &= (1 << bits) - 1;
+ }
+ }
+ if value != 0 {
+ return Err(DecodeError::NonZeroPadding { bits, value });
+ }
+ Ok(out)
+}
diff --git a/executor/crates/sdk-rs/src/lib.rs b/executor/crates/sdk-rs/src/lib.rs
index ac89b1cc2..eb6236d62 100644
--- a/executor/crates/sdk-rs/src/lib.rs
+++ b/executor/crates/sdk-rs/src/lib.rs
@@ -1,4 +1,5 @@
pub mod abi;
+pub mod gvm32;
pub use genlayer_calldata as calldata;
#[cfg(feature = "storage")]
diff --git a/executor/crates/sdk-rs/src/storage/array.rs b/executor/crates/sdk-rs/src/storage/array.rs
index 19712ae76..f5896d88e 100644
--- a/executor/crates/sdk-rs/src/storage/array.rs
+++ b/executor/crates/sdk-rs/src/storage/array.rs
@@ -23,6 +23,10 @@ impl StorageArray {
N
}
+ pub const fn is_empty(&self) -> bool {
+ N == 0
+ }
+
pub fn index(&self, idx: usize) -> T::Handle {
assert!(idx < N, "index {idx} out of bounds for array of length {N}");
T::handle_at(self.slot, self.offset + idx as u32 * T::SIZE)
diff --git a/executor/crates/sdk-rs/src/storage/mod.rs b/executor/crates/sdk-rs/src/storage/mod.rs
index 33ebfaba9..e534b6e86 100644
--- a/executor/crates/sdk-rs/src/storage/mod.rs
+++ b/executor/crates/sdk-rs/src/storage/mod.rs
@@ -11,12 +11,16 @@ pub use self::tree_map::*;
use crate::calldata::Address;
+// Field order defines slot offsets; must match `root_offsets` in the public ABI
+// and `Root` in runners/genlayer-py-std/.../storage/root.py:
+// MAJOR=0, CONTRACT=1, CODE=2, LOCKED_SLOTS=3, UPGRADERS=4, CODE_SLOT=5.
crate::record!(Root[T] {
+ major: u8,
contract_instance: Indirection,
code: Indirection>,
locked_slots: Indirection>,
upgraders: Indirection>,
- major: u8,
+ code_slot: primitive_types::U256,
});
impl Root {
@@ -27,4 +31,19 @@ impl Root {
pub fn get() -> Self {
::handle_at(Self::SLOT, 0)
}
+
+ /// Resolve the handle to the contract code.
+ ///
+ /// If `code_slot` is zero (the default), the code is read from the default
+ /// `code` indirection; otherwise it is read from the slot pointed to by
+ /// `code_slot` (see `chain:` runner ids). Mirrors `Root.get_resolved_code`
+ /// in the Python runner.
+ pub fn get_resolved_code(&self) -> StorageVLA {
+ let code_slot = self.code_slot().get();
+ if code_slot.is_zero() {
+ self.code().get()
+ } else {
+ VLA::::handle_at(Slot(code_slot.to_little_endian()), 0)
+ }
+ }
}
diff --git a/executor/crates/sdk-rs/tests/gvm32.rs b/executor/crates/sdk-rs/tests/gvm32.rs
new file mode 100644
index 000000000..befd1d7fb
--- /dev/null
+++ b/executor/crates/sdk-rs/tests/gvm32.rs
@@ -0,0 +1,71 @@
+use genlayer_sdk::gvm32;
+
+#[test]
+fn round_trip() {
+ let cases = [
+ // hex, gvm32 (Crockford Base32, lowercase)
+ (
+ "ab335240fd942ab8191c5e628cd4ff3903c577bda961fb75df08e0303a00527b",
+ "ncsn4g7xjgnbg68wbsh8sn7z741waxxxn5gzpxez13g30eg0a9xg",
+ ),
+ (
+ "47b2d8f260c2d48116044bc43fe3de0f",
+ "8ysdhwk0rba825g49f23zryy1w",
+ ),
+ (
+ "1f74d74729abdc08f4f84e8f7f8c808c8ed92ee5",
+ "3xtdehs9nfe0hx7r9t7qz340hj7djbq5",
+ ),
+ (
+ "a315ab26a0c4829321730c44a26f4497f7da0631402669caa4e24bdcd9db7c87",
+ "mcatp9n0rj1968bk1h2a4vt4jzvxm1hh80k6kjn4w95xspevfj3g",
+ ),
+ (
+ "296a445bfa5e1990af299ec74582468ab7a77e495861691ff79ab21234e514b64fc72b294d7305ecdd0febaa13b1bc1a3f359a711bb93dfb2b82804c64354dab",
+ "55n48pztbrcs1bs9kv3mb0j6havtezj9b1gpj7zqkas14d752jv4zhsb556q61fcvm7yqagkp6y1mfsnk9rhqe9xzcnr502ccgtmvar",
+ ),
+ (
+ "99a2da84cec54d17325bcee0a079669c1b15eb7ead32246514b75b97862f1e00",
+ "k6hdn16ern6hecjvsvga0yb6kgdhbtvynms28s8mpxdsf1hf3r00",
+ ),
+ ];
+
+ for (hex, b32) in cases {
+ let bytes = hex::decode(hex).unwrap();
+ assert_eq!(gvm32::encode(&bytes), b32, "encode {hex}");
+ assert_eq!(gvm32::decode(b32).unwrap(), bytes, "decode {b32}");
+ }
+}
+
+#[test]
+fn empty() {
+ assert_eq!(gvm32::encode(&[]), "");
+ assert_eq!(gvm32::decode("").unwrap(), Vec::::new());
+}
+
+#[test]
+fn case_insensitive_and_aliases() {
+ let bytes = hex::decode("47b2d8f260c2d48116044bc43fe3de0f").unwrap();
+ let canonical = "8ysdhwk0rba825g49f23zryy1w";
+ // uppercase decodes the same
+ assert_eq!(gvm32::decode(&canonical.to_uppercase()).unwrap(), bytes);
+ // hyphens are ignored
+ assert_eq!(
+ gvm32::decode("8ysd-hwk0-rba8-25g4-9f23-zryy-1w").unwrap(),
+ bytes
+ );
+ // crockford aliases: i/l -> 1, o -> 0
+ assert_eq!(gvm32::decode("OoIiLl"), gvm32::decode("001111"));
+}
+
+#[test]
+fn rejects_invalid() {
+ use gvm32::DecodeError;
+ // `u` is not in the alphabet and is not an alias
+ assert_eq!(gvm32::decode("u"), Err(DecodeError::InvalidChar('u')));
+ // non-zero trailing padding bits
+ assert_eq!(
+ gvm32::decode("01"),
+ Err(DecodeError::NonZeroPadding { bits: 2, value: 1 })
+ );
+}
diff --git a/executor/fuzz/genvm-storage.rs b/executor/fuzz/genvm-storage.rs
index e26de76aa..cde883e4f 100644
--- a/executor/fuzz/genvm-storage.rs
+++ b/executor/fuzz/genvm-storage.rs
@@ -155,27 +155,27 @@ async fn run_storage_fuzz(input: FuzzInput) -> anyhow::Result<()> {
genvm::config::FeesConfig {
expr_prelude: String::new(),
storage: genvm::config::FeesBucketConfig {
- bucket_no: 0,
+ bucket_no: vec![0],
subtract_on_start_expr: "0".into(),
delta_expr: r"\attrs = 0".into(),
},
message_receipt: genvm::config::FeesBucketConfig {
- bucket_no: 0,
+ bucket_no: vec![0],
subtract_on_start_expr: "0".into(),
delta_expr: r"\attrs = 0".into(),
},
nondet_output: genvm::config::FeesBucketConfig {
- bucket_no: 0,
+ bucket_no: vec![0],
subtract_on_start_expr: "0".into(),
delta_expr: r"\attrs = 0".into(),
},
message_fee: genvm::config::FeesBucketConfig {
- bucket_no: 0,
+ bucket_no: vec![0],
subtract_on_start_expr: "0".into(),
delta_expr: r"\attrs = 0".into(),
},
event: genvm::config::FeesBucketConfig {
- bucket_no: 0,
+ bucket_no: vec![0],
subtract_on_start_expr: "0".into(),
delta_expr: r"\attrs = 0".into(),
},
diff --git a/executor/src/config.rs b/executor/src/config.rs
index da0466b26..6483f3d59 100644
--- a/executor/src/config.rs
+++ b/executor/src/config.rs
@@ -15,9 +15,43 @@ fn default_fee_expr_zero() -> String {
"0".to_owned()
}
+fn deserialize_bucket_nos<'de, D>(d: D) -> Result, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ use serde::de;
+
+ struct Visitor;
+ impl<'de> de::Visitor<'de> for Visitor {
+ type Value = Vec;
+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ f.write_str("an integer or array of integers")
+ }
+ fn visit_u64(self, v: u64) -> Result, E> {
+ u8::try_from(v)
+ .map(|b| vec![b])
+ .map_err(|_| E::custom(format!("bucket_no {v} exceeds u8 range")))
+ }
+ fn visit_i64(self, v: i64) -> Result, E> {
+ u8::try_from(v)
+ .map(|b| vec![b])
+ .map_err(|_| E::custom(format!("bucket_no {v} out of u8 range")))
+ }
+ fn visit_seq>(self, mut seq: A) -> Result, A::Error> {
+ let mut v = Vec::new();
+ while let Some(n) = seq.next_element::()? {
+ v.push(n);
+ }
+ Ok(v)
+ }
+ }
+ d.deserialize_any(Visitor)
+}
+
#[derive(Clone, Deserialize, Debug)]
pub struct FeesBucketConfig {
- pub bucket_no: u8,
+ #[serde(deserialize_with = "deserialize_bucket_nos")]
+ pub bucket_no: Vec,
/// Cost charged once, up-front, when the bucket is created
/// (the fixed part of `start + sum of per-change`).
#[serde(default = "default_fee_expr_zero")]
diff --git a/executor/src/exe/run.rs b/executor/src/exe/run.rs
index 17ab463ab..44a81157f 100644
--- a/executor/src/exe/run.rs
+++ b/executor/src/exe/run.rs
@@ -17,9 +17,11 @@ const EXECUTION_DATA_HELP: &str = "path to file containing encoded execution dat
pub struct Args {
#[arg(
long,
- help = "whenever to allow `:latest` and `:test` as runners version, tracing, etc."
+ value_enum,
+ default_value = "disabled",
+ help = "debug level: disabled|safe|safe-unbounded|unsafe|unsafe-tracing. safe captures (bounded); safe-unbounded adds unbounded capture and tracing; unsafe adds `:latest`/`:test` resolution (diverges across machines); unsafe-tracing adds real time metering (non-deterministic)"
)]
- debug_mode: bool,
+ debug_mode: genvm_common::DebugMode,
#[arg(long, default_value = "-", help = EXECUTION_DATA_HELP)]
execution_data: String,
@@ -35,7 +37,7 @@ pub struct Args {
#[clap(
long,
default_value = "rwscn",
- help = "r?w?s?c?n?, read/write/send messages/call contracts/spawn nondet"
+ help = "r?w?s?c?n?u?, read/write/send messages/call contracts/spawn nondet/register runners"
)]
permissions: String,
#[clap(long, help = "override LLM module address from config")]
@@ -85,17 +87,6 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> {
.create_rt()
.with_context(|| "creating tokio runtime")?;
- let (token, canceller) = genvm_common::cancellation::make();
-
- let handle_sigterm = move || {
- log_warn!("sigterm received");
- canceller();
- };
- unsafe {
- signal_hook::low_level::register(signal_hook::consts::SIGTERM, handle_sigterm.clone())?;
- signal_hook::low_level::register(signal_hook::consts::SIGINT, handle_sigterm)?;
- }
-
let genvm_id = match &args.genvm_id {
None => {
let mut random_bytes = [0; 8];
@@ -135,13 +126,14 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> {
.collect::>>()?;
let max_bucket_no = [
- config.fees.storage.bucket_no,
- config.fees.message_receipt.bucket_no,
- config.fees.nondet_output.bucket_no,
- config.fees.message_fee.bucket_no,
- config.fees.event.bucket_no,
+ &config.fees.storage.bucket_no,
+ &config.fees.message_receipt.bucket_no,
+ &config.fees.nondet_output.bucket_no,
+ &config.fees.message_fee.bucket_no,
+ &config.fees.event.bucket_no,
]
.into_iter()
+ .flat_map(|v| v.iter().copied())
.max()
.unwrap_or(0);
@@ -152,7 +144,6 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> {
);
let shared_data = sync::DArc::new(genvm::rt::SharedData {
- cancellation: token,
is_sync: args.sync,
genvm_id: genvm_modules_interfaces::GenVMId(genvm_id),
debug_mode: args.debug_mode,
@@ -178,6 +169,16 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> {
let method_hosts = execution_data.method_hosts.clone();
+ // Validate every method->host mapping up front, once, so all later host
+ // selection (here and inside the VM) can index `hosts` safely instead of
+ // panicking on a misconfigured index.
+ if let Some(&bad) = method_hosts.iter().find(|&&h| h as usize >= hosts.len()) {
+ anyhow::bail!(
+ "method_hosts references host index {bad} but only {} host(s) are configured",
+ hosts.len()
+ );
+ }
+
// Charge the per-bucket up-front fees now that the hosts are connected. If a
// bucket cannot cover its `subtract_on_start`, report the resulting VM error
// to the host as a normal receipt instead of crashing during setup.
@@ -222,7 +223,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> {
}
let mut perm_size = 0;
- for perm in ["r", "w", "s", "c", "n"] {
+ for perm in ["r", "w", "s", "c", "n", "u"] {
if args.permissions.contains(perm) {
perm_size += 1;
}
diff --git a/executor/src/host/message.rs b/executor/src/host/message.rs
index 4f2dd51f0..c718f8213 100644
--- a/executor/src/host/message.rs
+++ b/executor/src/host/message.rs
@@ -40,10 +40,15 @@ impl AccountAddress {
#[repr(C)]
pub struct SlotID(#[serde_as(as = "Base64")] pub [u8; 32]);
-pub mod root_offsets {
- pub const CODE: u32 = 1;
- pub const LOCKED_SLOTS: u32 = 2;
- pub const UPGRADERS: u32 = 3;
+impl genvm_common::calldata::codec::Encode for SlotID {
+ type Error = W::Error;
+
+ fn encode(
+ &self,
+ enc: &mut genvm_common::calldata::Encoder,
+ ) -> std::result::Result<(), Self::Error> {
+ enc.push_bytes(&self.0)
+ }
}
impl SlotID {
@@ -89,3 +94,9 @@ impl From<&[u8; 32]> for SlotID {
SlotID(*value)
}
}
+
+impl std::fmt::Display for SlotID {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&genlayer_sdk::gvm32::encode(&self.0))
+ }
+}
diff --git a/executor/src/host/mod.rs b/executor/src/host/mod.rs
index 38147c374..b1e547486 100644
--- a/executor/src/host/mod.rs
+++ b/executor/src/host/mod.rs
@@ -5,10 +5,10 @@ use genlayer_sdk::abi;
use genvm_common::*;
use crate::public_abi;
+use crate::public_abi::root_offsets;
use crate::public_abi::{ResultCode, StorageType};
use genvm_common::calldata::Address;
use genvm_common::calldata::ADDRESS_SIZE;
-use message::root_offsets;
use core::str;
use std::os::fd::FromRawFd;
diff --git a/executor/src/lib.rs b/executor/src/lib.rs
index e142e41ee..8cc54f8ae 100644
--- a/executor/src/lib.rs
+++ b/executor/src/lib.rs
@@ -93,7 +93,6 @@ pub fn create_supervisor(
gas_data: named.gas_data.clone(),
initial_time_units_allocation: named.initial_time_units_allocation,
},
- shared_data.cancellation.clone(),
shared_data.genvm_id,
role,
host_data.clone(),
@@ -106,7 +105,6 @@ pub fn create_supervisor(
gas_data: named.gas_data,
initial_time_units_allocation: named.initial_time_units_allocation,
},
- shared_data.cancellation.clone(),
shared_data.genvm_id,
role,
host_data,
@@ -167,18 +165,63 @@ pub async fn run_with_impl(
),
);
- if let Some(code) = &entry_data.code {
- log_debug!("using provided code for execution");
+ let topmost_runner_id = match async {
+ anyhow::Ok(if let Some(code) = &entry_data.code {
+ log_debug!("using provided code for execution");
+
+ topmost_storage.write_code(code).await?;
+ // Record the major this contract is deployed for, so later runs can
+ // verify major compatibility (see `read_major` / major_mismatch check).
+ topmost_storage
+ .write_major(genvm_common::version::CURRENT.major as u8)
+ .await?;
+
+ let code_slot = rt::vm::storage::default_code_slot();
+ let archive = runners::parse(code.clone()).map_err(|e| {
+ rt::errors::VMError::wrap(public_abi::VmError::invalid_contract().val(), e)
+ })?;
+ supervisor.prepopulate_deploy_runner(
+ entry_data.message.contract_address,
+ code_slot,
+ archive,
+ );
+
+ runners::Id::Chain {
+ address: entry_data.message.contract_address,
+ on: runners::ChainState::Deploy,
+ slot: code_slot,
+ }
+ } else {
+ log_debug!("code is null");
- topmost_storage.write_code(code).await?;
- } else {
- log_debug!("code is null");
+ let code_slot = topmost_storage.check_major_and_resolve_code_slot().await?;
+
+ runners::Id::Chain {
+ address: entry_data.message.contract_address,
+ on: runners::ChainState::Accepted,
+ slot: code_slot,
+ }
+ })
}
+ .await
+ {
+ Ok(id) => id,
+ // A VMError raised while preparing the contract (bad code, major
+ // mismatch, missing code slot) is a contract error, not an internal
+ // failure: surface it as a VMError result. Genuine internal errors still
+ // propagate via `?`.
+ Err(e) => {
+ return Ok(rt::vm::FullResult::empty_from(
+ rt::errors::unwrap_vm_errors(e.into())?,
+ ));
+ }
+ };
let data_fees_limit = supervisor.shared_data.gep(|x| &x.data_fees_limit);
let essential_data = Box::new(wasi::genlayer_sdk::SingleVMData {
depth: 0,
+ // Permission model: doc/website/src/spec/03-vm/05-permissions.rst
conf: wasi::base::Config {
needs_error_fingerprint: true,
is_deterministic: true,
@@ -187,7 +230,9 @@ pub async fn run_with_impl(
can_send_messages: permissions.contains("s"),
can_call_others: permissions.contains("c"),
can_spawn_nondet: permissions.contains("n"),
+ can_register_runners: permissions.contains("u"),
state_mode: crate::public_abi::StorageType::Default,
+ topmost_runner_id,
},
message_data: ExtendedMessage {
message: entry_data.message,
@@ -203,6 +248,7 @@ pub async fn run_with_impl(
messages_value_decremented: primitive_types::U256::zero(),
emissions: Vec::new(),
message_fee_allocation: entry_data.message_fee_allocation,
+ custom_runners: Default::default(),
},
det_subvm_hashes: Default::default(),
});
@@ -254,21 +300,7 @@ pub async fn run_with(
(Ok(res), Ok(c)) => Ok((res, c)),
};
- let res = if supervisor.shared_data.cancellation.is_cancelled() {
- match merged_result {
- Ok(mut r) => {
- if r.0.kind == public_abi::ResultCode::VmError {
- r.0.data = calldata::Value::Str(public_abi::VmError::timeout().0.into()).into();
- }
- Ok(r)
- }
- Err(_e) => Ok((rt::vm::FullResult::timeout(), None)),
- }
- } else {
- merged_result
- };
-
- let res = res.inspect_err(|e| {
+ let res = merged_result.inspect_err(|e| {
log_error!(error:ah = &e; "internal error");
});
@@ -281,66 +313,11 @@ pub async fn run_with(
.context("notify non-deterministic disagreement")?;
}
- log_debug!("all executions done, collecting stats");
-
- let is_timeout = supervisor.shared_data.cancellation.is_cancelled();
-
- let web_metrics = if is_timeout {
- None
- } else {
- supervisor
- .modules
- .web
- .get_stats(genvm_modules_interfaces::web::Message::GetStats)
- .await
- .ok()
- };
- let llm_metrics = if is_timeout {
- None
- } else {
- supervisor
- .modules
- .llm
- .get_stats(genvm_modules_interfaces::llm::Message::GetStats)
- .await
- .ok()
- };
-
- #[derive(serde::Serialize)]
- struct AllMetrics<'a> {
- web: Option,
- llm: Option,
- gvm: &'a crate::Metrics,
- }
-
- impl calldata::codec::Encode for AllMetrics<'_> {
- type Error = W::Error;
-
- fn encode(&self, enc: &mut calldata::Encoder) -> Result<(), Self::Error> {
- enc.start_map(3)?;
-
- enc.push_map_k("gvm")?;
- calldata::codec::Encode::encode(self.gvm, enc)?;
-
- enc.push_map_k("llm")?;
- calldata::codec::Encode::encode(&self.llm, enc)?;
-
- enc.push_map_k("web")?;
- calldata::codec::Encode::encode(&self.web, enc)?;
-
- Ok(())
- }
- }
-
- let all_metrics = AllMetrics {
- web: web_metrics,
- llm: llm_metrics,
- gvm: &supervisor.shared_data.metrics,
- };
-
- let all_metrics = calldata::to_value(&all_metrics);
+ // Module (llm/web) metrics are collected by the manager from its own
+ // execution context; the executor only reports its own counters here.
+ let gvm_metrics = calldata::to_value(&supervisor.shared_data.metrics);
- log_info!(metrics:serde = all_metrics; "metrics");
+ log_info!(metrics:serde = gvm_metrics; "metrics");
log_debug!("sending final result");
diff --git a/executor/src/modules.rs b/executor/src/modules.rs
index abd3f1545..aaff5db6d 100644
--- a/executor/src/modules.rs
+++ b/executor/src/modules.rs
@@ -17,7 +17,6 @@ struct ModuleImpl {
pub struct Module {
name: String,
- cancellation: Arc,
imp: tokio::sync::Mutex,
genvm_id: genvm_modules_interfaces::GenVMId,
role: genvm_modules_interfaces::Role,
@@ -60,10 +59,21 @@ const FD_PREFIX: &str = "fd://";
/// Connect to a module endpoint
/// Supports:
-/// - TCP: "host:port" or "ws://host:port" or "wss://host:port"
+/// - TCP: "host:port" or "ws://host:port" (the latter is a plaintext alias)
/// - Unix socket: "unix:///path/to/socket"
/// - File descriptor: "fd://fd" (for bidirectional fd like socketpair)
async fn connect(url: &str) -> anyhow::Result {
+ // `wss://` implies TLS, but this connector only speaks the plaintext
+ // length-prefixed protocol. Reject it rather than silently downgrading to
+ // an unencrypted link an operator believes is secure. Terminate TLS in a
+ // proxy and point the executor at the proxy's plain `host:port` instead.
+ if url.starts_with("wss://") {
+ anyhow::bail!(
+ "module scheme 'wss://' is not supported (no TLS is performed); \
+ use 'host:port', 'ws://host:port', 'unix://', or 'fd://', and \
+ terminate TLS in a proxy"
+ );
+ }
if let Some(socket_path) = url.strip_prefix(UNIX_PREFIX) {
let stream = tokio::net::UnixStream::connect(socket_path)
.await
@@ -77,11 +87,8 @@ async fn connect(url: &str) -> anyhow::Result {
let stream = unsafe { genvm_common::io::AsyncCustomFD::from_raw_fd(fd)? };
Ok(Box::new(stream))
} else {
- // Strip ws:// or wss:// prefix if present for TCP connection
- let addr = url
- .strip_prefix("ws://")
- .or_else(|| url.strip_prefix("wss://"))
- .unwrap_or(url);
+ // `ws://` is a plaintext alias for a raw TCP connection.
+ let addr = url.strip_prefix("ws://").unwrap_or(url);
let stream = tokio::net::TcpStream::connect(addr)
.await
@@ -100,7 +107,6 @@ pub struct ModuleNamedArgs {
impl Module {
pub fn new(
named: ModuleNamedArgs,
- cancellation: Arc,
genvm_id: genvm_modules_interfaces::GenVMId,
role: genvm_modules_interfaces::Role,
host_data: genvm_modules_interfaces::HostData,
@@ -111,7 +117,6 @@ impl Module {
url: named.url,
stream: None,
}),
- cancellation,
genvm_id,
role,
name: named.name,
@@ -197,39 +202,14 @@ impl Module {
}
}
- pub async fn get_stats(&self, val: V) -> anyhow::Result
- where
- V: calldata::codec::Encode, Error = std::convert::Infallible>,
- {
- let zelf = self.imp.lock().await;
- let mut zelf = sync::Lock::new(zelf, self.metrics.gep(|x| &x.time));
-
- match &mut zelf.stream {
- None => Ok(calldata::Value::Null),
- Some(stream) => {
- write_message(stream, &calldata::encode_obj(&val)).await?;
- let response = read_message(stream).await?;
-
- let response = calldata::decode(&response)?;
-
- Ok(response)
- }
- }
- }
-
pub async fn send(&self, val: V) -> anyhow::Result>
where
V: calldata::codec::Encode, Error = std::convert::Infallible>,
R: calldata::codec::Decode,
{
- tokio::select! {
- _ = self.cancellation.chan.closed() => {
- anyhow::bail!("timeout") // it will be replaced later
- }
- res = self.send_impl(val) => {
- res.with_context(|| "sending request to module")
- }
- }
+ self.send_impl(val)
+ .await
+ .with_context(|| "sending request to module")
}
}
diff --git a/executor/src/rt/errors.rs b/executor/src/rt/errors.rs
index f3771741a..40194d6cd 100644
--- a/executor/src/rt/errors.rs
+++ b/executor/src/rt/errors.rs
@@ -11,14 +11,29 @@ impl std::error::Error for VMError {}
impl std::fmt::Display for VMError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "VMError({})", self.0 .0)
+ if f.alternate() {
+ f.write_fmt(format_args!("VMError({},", self.0 .0))?;
+ if let Some(cause) = &self.1 {
+ f.write_fmt(format_args!("{})", cause))?;
+ } else {
+ f.write_str("None)")?;
+ return Ok(());
+ };
+ } else {
+ f.write_fmt(format_args!("VMError({})", self.0 .0))?;
+ }
+
+ Ok(())
}
}
impl VMError {
pub fn wrap>(message: abi::consts::VmError, cause: E) -> Self {
match cause.into().downcast::() {
- Err(cause) => Self(message, Some(cause)),
+ Err(cause) => {
+ log_debug!(vm_error = message.0, cause:ah = cause; "wrapping VMError");
+ Self(message, Some(cause))
+ }
Ok(v) => v,
}
}
diff --git a/executor/src/rt/fees.rs b/executor/src/rt/fees.rs
index 6d35bd49a..150471822 100644
--- a/executor/src/rt/fees.rs
+++ b/executor/src/rt/fees.rs
@@ -85,10 +85,7 @@ pub fn fee_params_value_external(
Value::Object(Arc::new(m))
}
-fn value_to_u256(value: genvm_common::expr::Value) -> anyhow::Result {
- let rational = value
- .into_rational()
- .map_err(|e| anyhow::anyhow!("fee expression must yield a number: {e}"))?;
+fn rational_to_u256(rational: num_rational::BigRational) -> anyhow::Result {
anyhow::ensure!(
rational.is_integer(),
"fee expression must yield an integer, got {rational}"
@@ -105,6 +102,35 @@ fn value_to_u256(value: genvm_common::expr::Value) -> anyhow::Result anyhow::Result> {
+ match value {
+ genvm_common::expr::Value::Rational(r) => {
+ let cost = rational_to_u256(r)?;
+ Ok(vec![cost; bucket_count])
+ }
+ genvm_common::expr::Value::Array(arr) => {
+ anyhow::ensure!(
+ arr.len() == bucket_count,
+ "fee expression returned array of length {} but bucket_no has {bucket_count} entries",
+ arr.len(),
+ );
+ arr.iter()
+ .map(|v| {
+ let r = v
+ .clone()
+ .into_rational()
+ .map_err(|e| anyhow::anyhow!("fee array element must be a number: {e}"))?;
+ rational_to_u256(r)
+ })
+ .collect()
+ }
+ other => anyhow::bail!("fee expression must yield a number or array, got {other:?}",),
+ }
+}
+
/// Parses `\node = ` and applies it to `node`, so the result
/// closes over `node` (and the prelude). The `delta` form additionally
/// returns a `\attrs = ...` function ready to be applied per charge.
@@ -127,20 +153,24 @@ fn eval_with_node(
/// A fee bucket: total = `subtract_on_start` (charged once by
/// [`DataLimit::consume_initial`]) + Σ `delta(attrs)`. `delta` is a function
/// closing over `node`/the prelude.
+///
+/// `bucket_nos` can target multiple on-chain buckets. When the delta expression
+/// returns a scalar it is charged identically against every bucket; when it
+/// returns an array the lengths must match and each element is charged to the
+/// corresponding bucket. All subtractions are atomic (all-or-nothing).
struct Bucket {
- bucket_no: u8,
- subtract_on_start: primitive_types::U256,
+ bucket_nos: Vec,
+ subtract_on_start: Vec,
delta: genvm_common::expr::Value,
- /// VM error reported when `subtract_on_start` underflows the bucket.
oom_error: abi::consts::VmError,
- total_consumed: tokio::sync::Mutex,
+ total_consumed: Vec>,
}
impl std::fmt::Debug for Bucket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Bucket")
- .field("bucket_no", &self.bucket_no)
+ .field("bucket_nos", &self.bucket_nos)
.field("subtract_on_start", &self.subtract_on_start)
.finish()
}
@@ -152,27 +182,46 @@ fn build_bucket(
node: &genvm_common::expr::Value,
oom_error: abi::consts::VmError,
) -> anyhow::Result {
- let subtract_on_start = value_to_u256(eval_with_node(
- prelude,
- "subtract_on_start",
- &cfg.subtract_on_start_expr,
- node,
- )?)?;
+ let n = cfg.bucket_no.len();
+ anyhow::ensure!(n > 0, "bucket_no must have at least one entry");
+
+ let subtract_on_start = value_to_u256_vec(
+ eval_with_node(
+ prelude,
+ "subtract_on_start",
+ &cfg.subtract_on_start_expr,
+ node,
+ )?,
+ n,
+ )?;
let delta = eval_with_node(prelude, "delta", &cfg.delta_expr, node)?;
Ok(Bucket {
- bucket_no: cfg.bucket_no,
+ bucket_nos: cfg.bucket_no.clone(),
subtract_on_start,
delta,
oom_error,
- total_consumed: Default::default(),
+ total_consumed: (0..n).map(|_| Default::default()).collect(),
})
}
-#[derive(Debug, Clone, Copy)]
+/// Per-bucket cost vector returned by `calculate_*` methods.
+#[derive(Debug, Clone)]
+pub struct CostVec(pub Vec);
+
+impl CostVec {
+ pub fn sum(&self) -> primitive_types::U256 {
+ self.0
+ .iter()
+ .copied()
+ .fold(primitive_types::U256::zero(), |a, b| a + b)
+ }
+}
+
+#[derive(Debug, Clone)]
pub struct MessageFeeConsumption {
- pub message_fee: primitive_types::U256,
- pub receipt_fee: primitive_types::U256,
+ pub message_fee: CostVec,
+ pub receipt_fee: CostVec,
}
#[derive(Debug, Clone, Copy, Default, genlayer_calldata::Encode)]
@@ -269,14 +318,14 @@ impl DataLimit {
&self,
bucket: &Bucket,
vars: &[(&str, genvm_common::expr::Value)],
- ) -> anyhow::Result {
+ ) -> anyhow::Result {
match bucket
.delta
.apply_with(attrs_object(vars), no_free_vars)
.map_err(|e| anyhow::anyhow!("{e}"))
- .and_then(value_to_u256)
+ .and_then(|v| value_to_u256_vec(v, bucket.bucket_nos.len()))
{
- Ok(cost) => Ok(cost),
+ Ok(costs) => Ok(CostVec(costs)),
Err(e) => {
log_error!(error:ah = e; "failed to evaluate fee expression");
Err(e).context("failed to evaluate fee expression")
@@ -289,50 +338,78 @@ impl DataLimit {
bucket: &Bucket,
vars: &[(&str, genvm_common::expr::Value)],
) -> anyhow::Result {
- let cost = self.calculate_bucket(bucket, vars)?;
- Ok(self.consume_bucket_raw(bucket, cost).await)
+ let costs = self.calculate_bucket(bucket, vars)?;
+ Ok(self.consume_bucket_raw(bucket, &costs.0).await)
}
- async fn consume_bucket_raw(&self, bucket: &Bucket, cost: primitive_types::U256) -> bool {
+ async fn consume_bucket_raw(&self, bucket: &Bucket, costs: &[primitive_types::U256]) -> bool {
let mut buckets = self.buckets.lock().await;
- let Some(remaining) = buckets.get_mut(bucket.bucket_no as usize) else {
- log_warn!(bucket = bucket.bucket_no; "consume_bucket: bucket index out of range");
- return false;
- };
- if *remaining >= cost {
- *remaining -= cost;
+ for (idx, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() {
+ let Some(remaining) = buckets.get(bno as usize) else {
+ log_warn!(bucket = bno; "consume_bucket: bucket index out of range");
+ return false;
+ };
+ if *remaining < cost {
+ log_warn!(
+ bucket = bno,
+ cost:display = cost,
+ remaining:display = *remaining;
+ "consume_bucket: insufficient funds"
+ );
+ return false;
+ }
+ // when the same bucket_no appears more than once, verify
+ // cumulative cost fits
+ let mut cumulative = cost;
+ for (&prev_bno, &prev_cost) in bucket.bucket_nos[..idx].iter().zip(costs[..idx].iter())
+ {
+ if prev_bno == bno {
+ cumulative += prev_cost;
+ }
+ }
+ if *remaining < cumulative {
+ log_warn!(
+ bucket = bno,
+ cumulative:display = cumulative,
+ remaining:display = *remaining;
+ "consume_bucket: insufficient funds (cumulative)"
+ );
+ return false;
+ }
+ }
+ for (i, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() {
+ buckets[bno as usize] -= cost;
log_debug!(
- bucket = bucket.bucket_no,
+ bucket = bno,
cost:display = cost,
- remaining:display = *remaining;
+ remaining:display = buckets[bno as usize];
"consume_bucket: ok"
);
- std::mem::drop(buckets);
- *bucket.total_consumed.lock().await += cost;
-
- true
- } else {
- log_warn!(
- bucket = bucket.bucket_no,
- cost:display = cost,
- remaining:display = *remaining;
- "consume_bucket: insufficient funds"
- );
- false
+ *bucket.total_consumed[i].lock().await += cost;
}
+ std::mem::drop(buckets);
+ true
}
pub async fn remaining(&self) -> Vec {
self.buckets.lock().await.clone()
}
+ async fn sum_consumed(bucket: &Bucket) -> primitive_types::U256 {
+ let mut total = primitive_types::U256::zero();
+ for tc in &bucket.total_consumed {
+ total += *tc.lock().await;
+ }
+ total
+ }
+
pub async fn consumed(&self) -> BucketsConsumed {
BucketsConsumed {
- storage: *self.storage.total_consumed.lock().await,
- message_receipt: *self.message_receipt.total_consumed.lock().await,
- nondet_output: *self.nondet_output.total_consumed.lock().await,
- message_fee: *self.message_fee.total_consumed.lock().await,
- event: *self.event.total_consumed.lock().await,
+ storage: Self::sum_consumed(&self.storage).await,
+ message_receipt: Self::sum_consumed(&self.message_receipt).await,
+ nondet_output: Self::sum_consumed(&self.nondet_output).await,
+ message_fee: Self::sum_consumed(&self.message_fee).await,
+ event: Self::sum_consumed(&self.event).await,
}
}
@@ -348,31 +425,17 @@ impl DataLimit {
&self.message_fee,
&self.event,
] {
- let mut buckets = self.buckets.lock().await;
- let Some(remaining) = buckets.get_mut(bucket.bucket_no as usize) else {
+ if !self
+ .consume_bucket_raw(bucket, &bucket.subtract_on_start.clone())
+ .await
+ {
return Some(rt::errors::VMError(
bucket.oom_error.clone(),
Some(anyhow::anyhow!(
- "consume_initial: bucket {} index out of range",
- bucket.bucket_no
+ "subtract_on_start exceeds bucket {:?} total",
+ bucket.bucket_nos
)),
));
- };
- match remaining.checked_sub(bucket.subtract_on_start) {
- Some(v) => {
- *remaining = v;
- std::mem::drop(buckets);
- *bucket.total_consumed.lock().await += bucket.subtract_on_start;
- }
- None => {
- return Some(rt::errors::VMError(
- bucket.oom_error.clone(),
- Some(anyhow::anyhow!(
- "subtract_on_start exceeds bucket {} total",
- bucket.bucket_no
- )),
- ));
- }
}
}
None
@@ -386,7 +449,7 @@ impl DataLimit {
pub fn calculate_message_receipt(
&self,
params: MessageReceiptParams,
- ) -> anyhow::Result {
+ ) -> anyhow::Result {
self.calculate_bucket(
&self.message_receipt,
&[
@@ -414,15 +477,15 @@ impl DataLimit {
blob_size: u64,
topics_count: u64,
) -> anyhow::Result