From a75b0e150978b81335f20fd4f906b2f79792eb44 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 1 Jul 2026 02:48:17 +0900 Subject: [PATCH 01/25] =?UTF-8?q?chore(codegen):=20drop=20ruby=20templates?= =?UTF-8?q?=20(moved=20to=20genvm-tool=20codegen)=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/codegen/templates/py.rb | 178 --------------------- executor/codegen/templates/rs.rb | 251 ------------------------------ executor/codegen/templates/rst.rb | 114 -------------- 3 files changed, 543 deletions(-) delete mode 100755 executor/codegen/templates/py.rb delete mode 100755 executor/codegen/templates/rs.rb delete mode 100644 executor/codegen/templates/rst.rb diff --git a/executor/codegen/templates/py.rb b/executor/codegen/templates/py.rb deleted file mode 100755 index 9f87d446..00000000 --- a/executor/codegen/templates/py.rb +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env ruby -require 'erb' -require 'pathname' -require 'json' -require 'ostruct' - -def to_camel(s) - s.split('_').map { |x| if x.size() == 0 then x else x[0].upcase + x[1..].downcase end }.join('') -end - -def dump(s) - s.kind_of?(String) ? "'#{s}'" : s.to_s -end - -def py_repr(s) - if s =~ /^(u|i)\d+$/ - "int" - else - s - end -end - -def unfold_trie_entry(entry) - if entry.nil? - nil - elsif entry.is_a?(String) - {"head" => entry, "tail" => []} - elsif entry.is_a?(Hash) - tail = entry["tail"] - if tail.is_a?(Array) - {"head" => entry["head"], "tail" => tail.map { |e| unfold_trie_entry(e) }} - else - entry - end - end -end - -def gen_py_trie_inner(entries, struct_name, prefix_parts, buf, root_name, self_terminal_parts = nil) - leaves = [] - methods = [] - param_nodes = [] - - entries.each { |entry| - next if entry.nil? - head = entry["head"] - tail = entry["tail"] - current_parts = prefix_parts + [head] - child_struct_name = struct_name + to_camel(head) - - if tail.is_a?(String) && tail.start_with?('$') - param_type = tail[1..] - methods << [head, child_struct_name] - param_nodes << [child_struct_name, param_type, current_parts] - elsif tail.is_a?(Array) - non_null = tail.compact - is_terminal = (tail.length != non_null.length) || non_null.empty? - - if non_null.empty? - leaves << [head, current_parts] - else - methods << [head, child_struct_name] - child_terminal = is_terminal ? current_parts : nil - gen_py_trie_inner(non_null, child_struct_name, current_parts, buf, root_name, child_terminal) - end - end - } - - unless struct_name.empty? - buf << "class _#{root_name}#{struct_name}:\n" - if self_terminal_parts - str_val = self_terminal_parts.join(" ") - buf << "\t@staticmethod\n" - buf << "\tdef val() -> '#{root_name}':\n" - buf << "\t\treturn #{root_name}('#{str_val}')\n" - end - leaves.each { |name, parts| - str_val = parts.join(" ") - buf << "\t@staticmethod\n" - buf << "\tdef #{name.downcase}() -> '#{root_name}':\n" - buf << "\t\treturn #{root_name}('#{str_val}')\n" - } - methods.each { |name, child_struct| - buf << "\t@staticmethod\n" - buf << "\tdef #{name.downcase}() -> '_#{root_name}#{child_struct}':\n" - buf << "\t\treturn _#{root_name}#{child_struct}()\n" - } - buf << "\n" - end - - param_nodes.each { |cs_name, param, parts| - fmt_str = parts.join(" ") - buf << "class _#{root_name}#{cs_name}:\n" - buf << "\t@staticmethod\n" - buf << "\tdef val_#{param}(v: #{py_repr param}) -> '#{root_name}':\n" - buf << "\t\treturn #{root_name}(f'#{fmt_str} {v}')\n" - buf << "\n" - } - - [leaves, methods] -end - -def gen_py_trie_builder(entries, root_name, buf) - inner_buf = String.new - leaves, methods = gen_py_trie_inner(entries, "", [], inner_buf, root_name) - - buf << inner_buf - - buf << "class #{root_name}:\n" - buf << "\t__slots__ = ('value',)\n" - buf << "\tdef __init__(self, value: str):\n" - buf << "\t\tself.value = value\n" - buf << "\tdef __str__(self) -> str:\n" - buf << "\t\treturn self.value\n" - leaves.each { |name, parts| - str_val = parts.join(" ") - buf << "\t@staticmethod\n" - buf << "\tdef #{name.downcase}() -> '#{root_name}':\n" - buf << "\t\treturn #{root_name}('#{str_val}')\n" - } - methods.each { |name, child_struct| - buf << "\t@staticmethod\n" - buf << "\tdef #{name.downcase}() -> '_#{root_name}#{child_struct}':\n" - buf << "\t\treturn _#{root_name}#{child_struct}()\n" - } - buf << "\n" -end - -# editorconfig-checker-disable -ENUM_TEMPLATE_STR = <<-EOF - - -class <%= to_camel name %>(<%= repr == "str" ? "StrEnum" : "IntEnum" %>): -% values.each { |k, v| - <%= k.upcase %> = <%= dump v %> -% } -EOF -# editorconfig-checker-enable - -ENUM_TEMPLATE = ERB.new(ENUM_TEMPLATE_STR, trim_mode: "%") - -json_path, out_path = ARGV - -json_data = JSON.load_file(Pathname.new(json_path)) - -buf = String.new - -buf << <<-EOF -# This file is auto-generated. Do not edit! - -# fmt: off - -from enum import IntEnum, StrEnum -import typing -EOF - -json_data.each { |t| - t_os = OpenStruct.new(t) - case t_os.type - when "enum" - buf << ENUM_TEMPLATE.result(t_os.instance_eval { binding }) - when "const" - buf << "\n\n#{t_os.name.upcase}: typing.Final[#{py_repr t_os.repr}] = #{dump t_os.value}\n" - when "consts" - buf << "\n\nclass _#{to_camel t_os.name}(typing.NamedTuple):\n" - t_os.values.each { |k, v| - buf << "\t#{k.upcase}: #{py_repr t_os.repr} = #{dump v}\n" - } - buf << "\n#{t_os.name}: typing.Final = _#{to_camel t_os.name}()\n" - when "str_trie" - entries = t_os.values.map { |e| unfold_trie_entry(e) } - buf << "\n" - gen_py_trie_builder(entries, to_camel(t_os.name), buf) - else - raise "unknown type #{t_os.type}" - end -} - -File.write(Pathname.new(out_path), buf) diff --git a/executor/codegen/templates/rs.rb b/executor/codegen/templates/rs.rb deleted file mode 100755 index a058f763..00000000 --- a/executor/codegen/templates/rs.rb +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env ruby -require 'erb' -require 'pathname' -require 'json' -require 'ostruct' - -def to_camel(s) - s.split('_').map { |x| if x.size() == 0 then x else x[0].upcase + x[1..].downcase end }.join('') -end - -def rust_repr(s) - if s == "str" - "&'static str" - else - s - end -end - -def rust_repr_from(s) - if s == "str" - "&str" - else - s - end -end - -def dump(s) - s.kind_of?(String) ? s.dump : s.to_s -end - -def unfold_trie_entry(entry) - if entry.nil? - nil - elsif entry.is_a?(String) - {"head" => entry, "tail" => []} - elsif entry.is_a?(Hash) - tail = entry["tail"] - if tail.is_a?(Array) - {"head" => entry["head"], "tail" => tail.map { |e| unfold_trie_entry(e) }} - else - entry - end - end -end - -def rust_param_type(param) - case param - when "str" then "&str" - else param - end -end - -def gen_rust_trie_inner(entries, struct_name, prefix_parts, buf, root_name, self_terminal_parts = nil) - leaves = [] - methods = [] - param_nodes = [] - - entries.each { |entry| - next if entry.nil? - head = entry["head"] - tail = entry["tail"] - current_parts = prefix_parts + [head] - child_struct_name = struct_name + to_camel(head) - - if tail.is_a?(String) && tail.start_with?('$') - param_type = tail[1..] - methods << [head, child_struct_name] - param_nodes << [child_struct_name, param_type, current_parts] - elsif tail.is_a?(Array) - non_null = tail.compact - is_terminal = (tail.length != non_null.length) || non_null.empty? - - if non_null.empty? - leaves << [head, current_parts] - else - methods << [head, child_struct_name] - child_terminal = is_terminal ? current_parts : nil - gen_rust_trie_inner(non_null, child_struct_name, current_parts, buf, root_name, child_terminal) - end - end - } - - unless struct_name.empty? - buf << "pub struct #{struct_name};\n\n" - buf << "impl #{struct_name} {\n" - if self_terminal_parts - str_val = self_terminal_parts.join(" ") - buf << " pub const fn val(&self) -> #{root_name} { #{root_name}(Cow::Borrowed(\"#{str_val}\")) }\n" - end - leaves.each { |name, parts| - str_val = parts.join(" ") - buf << " pub const fn #{name.downcase}(&self) -> #{root_name} { #{root_name}(Cow::Borrowed(\"#{str_val}\")) }\n" - } - methods.each { |name, child_struct| - buf << " pub const fn #{name.downcase}(&self) -> #{child_struct} { #{child_struct} }\n" - } - buf << "}\n\n" - end - - param_nodes.each { |cs_name, param, parts| - fmt_str = parts.join(" ") + " {v}" - buf << "pub struct #{cs_name};\n\n" - buf << "impl #{cs_name} {\n" - buf << " pub fn val_#{param}(&self, v: #{rust_param_type param}) -> #{root_name} {\n" - buf << " #{root_name}(Cow::Owned(format!(\"#{fmt_str}\")))\n" - buf << " }\n" - buf << "}\n\n" - } - - [leaves, methods] -end - -def gen_rust_trie_builder(entries, root_name, buf) - mod_buf = String.new - leaves, methods = gen_rust_trie_inner(entries, "", [], mod_buf, root_name) - - mod_name = "__#{root_name}" - - unless mod_buf.empty? - buf << "#[allow(non_snake_case)]\n" - buf << "#[rustfmt::skip]\n" - buf << "pub mod #{mod_name} {\n" - buf << " use std::borrow::Cow;\n" - buf << " use super::#{root_name};\n\n" - mod_buf.each_line { |line| - buf << (line.strip.empty? ? "\n" : " #{line}") - } - buf << "}\n\n" - end - - buf << "#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n" - buf << "pub struct #{root_name}(pub Cow<'static, str>);\n\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" - buf << "impl #{root_name} {\n" - leaves.each { |name, parts| - str_val = parts.join(" ") - buf << " pub const fn #{name.downcase}() -> Self { Self(Cow::Borrowed(\"#{str_val}\")) }\n" - } - methods.each { |name, child_struct| - buf << " pub const fn #{name.downcase}() -> #{mod_name}::#{child_struct} { #{mod_name}::#{child_struct} }\n" - } - buf << "}\n\n" -end - -# editorconfig-checker-disable -ENUM_TEMPLATE_STR = <<-EOF -#[derive( - Debug, - PartialEq, - Clone, - Copy, - Serialize, - Deserialize, - ::genlayer_calldata::Encode, - ::genlayer_calldata::Decode, -)] -% if repr != "str" -#[repr(<%= repr %>)] -pub enum <%= to_camel name %> { -% values.each { |k, v| - <%= to_camel k %> = <%= dump v %>, -% } -} -% else -pub enum <%= to_camel name %> { -% values.each { |k, v| - <%= to_camel k %>, -% } -} -% end - -impl <%= to_camel name %> { -% if repr != "str" && values.values.all? { |v| v.is_a?(Integer) } && values.values.sort == (0...values.length).to_a - pub const SIZE: usize = <%= values.length %>; -% end - pub fn value(self) -> <%= rust_repr repr %> { - match self { -% values.each { |k, v| - <%= to_camel name %>::<%= to_camel k %> => <%= dump v %>, -% } - } - } - pub fn str_snake_case(self) -> &'static str { - match self { -% values.each { |k, v| - <%= to_camel name %>::<%= to_camel k %> => "<%= k %>", -% } - } - } -} - -impl TryFrom<<%= rust_repr_from repr %>> for <%= to_camel name %> { - type Error = (); - - fn try_from(value: <%= rust_repr_from repr %>) -> Result { - match value { -% values.each { |k, v| - <%= dump v %> => Ok(<%= to_camel name %>::<%= to_camel k %>), -% } - _ => Err(()), - } - } -} -EOF -# editorconfig-checker-enable - -ENUM_TEMPLATE = ERB.new(ENUM_TEMPLATE_STR, trim_mode: "%") - -json_path, out_path = ARGV - -json_data = JSON.load_file(Pathname.new(json_path)) -has_str_trie = json_data.any? { |t| t["type"] == "str_trie" } - -buf = String.new - -buf << "// This file is auto-generated. Do not edit!\n\n" -buf << "#![allow(dead_code, clippy::redundant_static_lifetimes)]\n"; -buf << "\n" -buf << "use serde::{Deserialize, Serialize};\n\n" - -if has_str_trie - buf << "use std::borrow::Cow;\n\n" -end - -json_data.each { |t| - t_os = OpenStruct.new(t) - case t_os.type - when "enum" - buf << ENUM_TEMPLATE.result(t_os.instance_eval { binding }) - when "const" - buf << "pub const #{t_os.name.upcase}: #{rust_repr t_os.repr} = #{dump t_os.value};\n" - when "consts" - buf << "pub mod #{t_os.name} {\n" - t_os.values.each { |k, v| - buf << " pub const #{k.upcase}: #{rust_repr t_os.repr} = #{dump v};\n" - } - buf << "}\n\n" - when "str_trie" - entries = t_os.values.map { |e| unfold_trie_entry(e) } - gen_rust_trie_builder(entries, to_camel(t_os.name), buf) - else - raise "unknown type #{t_os.type}" - end -} - -File.write(Pathname.new(out_path), buf) diff --git a/executor/codegen/templates/rst.rb b/executor/codegen/templates/rst.rb deleted file mode 100644 index 10e622eb..00000000 --- a/executor/codegen/templates/rst.rb +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env ruby - -require 'pathname' -require 'json' -require 'ostruct' - -def unfold_trie_entry(entry) - if entry.nil? - nil - elsif entry.is_a?(String) - {"head" => entry, "tail" => []} - elsif entry.is_a?(Hash) - tail = entry["tail"] - if tail.is_a?(Array) - {"head" => entry["head"], "tail" => tail.map { |e| unfold_trie_entry(e) }} - else - entry - end - end -end - -def enumerate_trie_paths(entries, prefix = "") - result = [] - entries.each { |entry| - next if entry.nil? - head = entry["head"] - tail = entry["tail"] - current = prefix.empty? ? head : "#{prefix} #{head}" - if tail.is_a?(String) && tail.start_with?('$') - result << [current, tail[1..]] - elsif tail.is_a?(Array) - non_null = tail.compact - is_terminal = (tail.length != non_null.length) || non_null.empty? - result << [current, nil] if is_terminal - result.concat(enumerate_trie_paths(non_null, current)) - end - } - result -end - -json_path, out_path = ARGV - -buf = String.new - -buf << <<-EOF -Constants -========= - -EOF - -JSON.load_file(Pathname.new(json_path)).each { |t| - t_os = OpenStruct.new(t) - case t_os.type - when "enum" - buf << '.. _gvm-def-enum-' << t_os.name.gsub('_', '-') << ":\n\n" - buf << t_os.name << "\n" - buf << '-' * t_os.name.size << "\n\n" - - buf << "Type: " << t_os.repr << "\n\n" - - t_os.values.each { |k, v| - buf << ".. _gvm-def-enum-value-" << t_os.name.gsub('_', '-') << '-' << k.gsub('_', '-') << ":\n\n" - - buf << k << "\n" - buf << '~' * k.size << "\n\n" - buf << "Value: ``" << v.to_s << "``\n\n" - } - - when "const" - buf << '.. _gvm-def-const-' << t_os.name.gsub('_', '-') << ":\n\n" - buf << t_os.name << "\n" - buf << '-' * t_os.name.size << "\n\n" - - buf << "Type: " << t_os.repr << "\n\n" - buf << "Value: ``" << t_os.value.to_s << "``\n\n" - - when "consts" - buf << '.. _gvm-def-consts-' << t_os.name.gsub('_', '-') << ":\n\n" - buf << t_os.name << "\n" - buf << '-' * t_os.name.size << "\n\n" - - buf << "Type: " << t_os.repr << "\n\n" - - t_os.values.each { |k, v| - buf << '.. _gvm-def-consts-value-' << t_os.name.gsub('_', '-') << '-' << k.gsub('_', '-') << ":\n\n" - buf << k << "\n" - buf << '~' * k.size << "\n\n" - buf << "Value: ``" << v.to_s << "``\n\n" - } - - when "str_trie" - entries = t_os.values.map { |e| unfold_trie_entry(e) } - buf << '.. _gvm-def-str-trie-' << t_os.name.gsub('_', '-') << ":\n\n" - buf << t_os.name << "\n" - buf << '-' * t_os.name.size << "\n\n" - - buf << "Type: str_trie\n\n" - - enumerate_trie_paths(entries).each { |path, param| - rst_name = path.gsub('_', '-').gsub(' ', '-') - buf << '.. _gvm-def-str-trie-value-' << t_os.name.gsub('_', '-') << '-' << rst_name << ":\n\n" - buf << "``#{path}``\n" - buf << '~' * (path.size + 4) << "\n\n" - if param - buf << "Param: #{param}\n\n" - end - } - - else - raise "unknown type #{t_os.type}" - end -} - -File.write(Pathname.new(out_path), buf) From 3bcc7f485b956c300b33ea349f73ea15dfc3acb9 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 1 Jul 2026 04:08:04 +0900 Subject: [PATCH 02/25] =?UTF-8?q?feat(modules-interfaces):=20decouple=20fr?= =?UTF-8?q?om=20sdk-rs=20with=20own=20payload=20types=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .genvm-tool.py | 23 ++++ .taplo.toml | 13 ++ executor/Cargo.lock | 2 +- executor/Cargo.toml | 97 +++++++++------ executor/crates/calldata-derive/Cargo.toml | 4 +- executor/crates/calldata/Cargo.toml | 18 ++- executor/crates/common/Cargo.toml | 51 +++++--- executor/crates/modules-interfaces/Cargo.toml | 28 +++++ executor/crates/sdk-rs/Cargo.toml | 22 ++-- executor/default.nix | 4 +- executor/src/wasi/genlayer_sdk.rs | 115 +++++++++++++++++- manifest.json | 4 +- ruff.toml | 21 +--- runners/genlayer-py-std/pyproject.toml | 4 +- .../tools/genvm-floats-to-soft/Cargo.toml | 2 +- .../tools/genvm-wasm-add-mod-name/Cargo.toml | 2 +- support/nix/precommit/flake.nix | 1 + 17 files changed, 304 insertions(+), 107 deletions(-) create mode 100644 .taplo.toml create mode 100644 executor/crates/modules-interfaces/Cargo.toml diff --git a/.genvm-tool.py b/.genvm-tool.py index 5c994d3c..1773d0f2 100644 --- a/.genvm-tool.py +++ b/.genvm-tool.py @@ -54,6 +54,15 @@ def hooks(ctx): 'entry': 'check-toml', 'types_or': ['toml'], }, + { + 'id': 'toml-format', + 'nix': 'taplo', + 'entry': 'taplo', + 'args': ['format', '--check'], + 'fix_args': ['format'], + 'types_or': ['toml'], + 'pass_filenames': True, + }, { 'id': 'check-merge-conflict', 'nix': 'pre-commit-hooks', @@ -117,3 +126,17 @@ def hooks(ctx): 'pass_filenames': False, }, ] + + +def configure(line): + """Per-line build configuration for `genvm-tool configure`. + + ``line`` is a ``genvm_tool_plugins.ninja.LineContext`` the configure command + builds for this executor. This is a live line: its runner ``latest``/``all`` + manifests are derived through the umbrella nix machinery (``nix_manifests``), + which imports every active line's runners. + """ + line.register_standard_codegen() + line.register_standard_crates() + line.nix_manifests() + line.install_tree() diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 00000000..baf88894 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,13 @@ +[formatting] +align_comments = false +indent_string = " " +reorder_keys = true +trailing_newline = true + +[[rule]] +exclude = ["executors/**"] +include = ["**/Cargo.toml"] +keys = ["package"] + +[rule.formatting] +reorder_keys = false diff --git a/executor/Cargo.lock b/executor/Cargo.lock index a347bf38..e58fc922 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -1078,8 +1078,8 @@ name = "genvm-modules-interfaces" version = "0.1.0" dependencies = [ "anyhow", + "bytes", "genlayer_calldata", - "genlayer_sdk", "num-bigint", "num-traits", "primitive-types", diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 590840ef..39d30dfc 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -20,7 +20,6 @@ name = "fuzz-genvm-storage" path = "fuzz/genvm-storage.rs" [dependencies.wasmtime] -path = "third-party/wasmtime/crates/wasmtime" default-features = false features = [ "anyhow", @@ -28,13 +27,18 @@ features = [ "std", "parallel-compilation", "cache", - "demangle" + "demangle", ] +path = "third-party/wasmtime/crates/wasmtime" [dependencies] +wasmparser = { path = "third-party/wasm-tools/crates/wasmparser", default-features = false, features = [ + "simd", +] } wasmtime-cache = { path = "third-party/wasmtime/crates/cache", package = "wasmtime-internal-cache" } -wiggle = { path = "third-party/wasmtime/crates/wiggle", default-features = false, features = ["wasmtime_async"] } -wasmparser = { path = "third-party/wasm-tools/crates/wasmparser", default-features = false, features = ["simd"] } +wiggle = { path = "third-party/wasmtime/crates/wiggle", default-features = false, features = [ + "wasmtime_async", +] } genvm-common = { path = "crates/common" } # NOTE: path dependency that climbs UP into the genvm-manager umbrella repo. @@ -42,53 +46,72 @@ genvm-common = { path = "crates/common" } # ../../../crates/modules-interfaces resolves to /crates/modules-interfaces . # The host<->executor interface lives in the manager (many executors depend on # it). The executor is intentionally NOT standalone-buildable on its own. -genvm-modules-interfaces = { path = "../../../crates/modules-interfaces" } -genlayer_sdk = { path = "crates/sdk-rs", default-features = false } genlayer_calldata = { path = "crates/calldata" } +genlayer_sdk = { path = "crates/sdk-rs", default-features = false } +genvm-modules-interfaces = { path = "crates/modules-interfaces" } anyhow = { version = "1.0.97", default-features = false } -tracing = "0.1.41" -serde = { version = "1.0.219", features = ["rc"]} -serde_json = "1.0.140" -clap = { version = "4.5.35", features = ["derive"] } -serde_with = { version = "3.12.0", features = ["base64"] } -itertools = "0.14.0" -zip = "4.0.0" -once_cell = "1.21.3" -chrono = { version = "0.4.41", default-features = false, features = ["clock", "std", "serde"] } -base64 = "0.22.1" -symbol_table = { version = "0.4.0", features = ["global", "serde"] } -serde_derive = "1.0.219" -sha3 = { version = "0.10.8", features = ["asm"] } -base32 = "0.5.1" -tokio = { version = "1.44.1", features = ["rt", "rt-multi-thread", "net", "time", "macros", "sync", "fs"], default-features = false} +arbitrary = { version = "1.0.3", features = ["derive"] } +archery = "1.2.2" async-trait = "0.1.88" -dashmap = "6.1.0" -primitive-types = { version = "0.13.1", default-features = false, features = ["impl-serde", "std"] } +base32 = "0.5.1" +base64 = "0.22.1" bufreaderwriter = "0.2.4" -serde_yaml = "0.9.34" -getrandom = { version = "0.3.2", default-features = false, features = ["std"] } -tokio-tungstenite = "0.26.2" +bytes = { version = "1.11.0", features = ["serde"] } +chrono = { version = "0.4.41", default-features = false, features = [ + "clock", + "std", + "serde", +] } +clap = { version = "4.5.35", features = ["derive"] } +const-lru = "1.0.0" +crossbeam = "0.8.4" +dashmap = "6.1.0" futures-util = "0.3.31" -num-bigint = { version = "0.4.6", features = ["serde"] } -num-rational = { version = "0.4.2", default-features = false, features = ["num-bigint"] } +getrandom = { version = "0.3.2", default-features = false, features = ["std"] } hex = "0.4.3" -serde_bytes = "0.11.17" +itertools = "0.14.0" mt19937 = "3.1.0" +num-bigint = { version = "0.4.6", features = ["serde"] } +num-rational = { version = "0.4.2", default-features = false, features = [ + "num-bigint", +] } +once_cell = "1.21.3" +primitive-types = { version = "0.13.1", default-features = false, features = [ + "impl-serde", + "std", +] } rand_core = "0.9.3" -crossbeam = "0.8.4" -arbitrary = { version = "1.0.3", features = [ "derive" ] } rpds = "1.2.0" -archery = "1.2.2" -bytes = { version = "1.11.0", features = ["serde"] } +serde = { version = "1.0.219", features = ["rc"] } +serde_bytes = "0.11.17" +serde_derive = "1.0.219" +serde_json = "1.0.140" +serde_with = { version = "3.12.0", features = ["base64"] } +serde_yaml = "0.9.34" +sha3 = { version = "0.10.8", features = ["asm"] } +symbol_table = { version = "0.4.0", features = ["global", "serde"] } +tokio = { version = "1.44.1", features = [ + "rt", + "rt-multi-thread", + "net", + "time", + "macros", + "sync", + "fs", +], default-features = false } tokio-mpmc = "0.2.4" -const-lru = "1.0.0" +tokio-tungstenite = "0.26.2" +tracing = "0.1.41" +zip = "4.0.0" [dev-dependencies] afl = { version = "0.15.18", features = ["no_cfg_fuzzing"] } -wat = { path = "third-party/wasm-tools/crates/wat" } -wasm-smith = { path = "third-party/wasm-tools/crates/wasm-smith", features = ["wasmparser"]} -wasm-encoder = { path = "third-party/wasm-tools/crates/wasm-encoder"} libc = "0.2.162" +wasm-encoder = { path = "third-party/wasm-tools/crates/wasm-encoder" } +wasm-smith = { path = "third-party/wasm-tools/crates/wasm-smith", features = [ + "wasmparser", +] } +wat = { path = "third-party/wasm-tools/crates/wat" } #wasm-encoder = { path = "third-party/wasm-tools/crates/wasm-encoder", default-features = false, features = ["wasmparser"]} #wasmtime-fuzzing = { path = "third-party/wasmtime/crates/fuzzing", default-features = false } diff --git a/executor/crates/calldata-derive/Cargo.toml b/executor/crates/calldata-derive/Cargo.toml index b68270e3..e1dfc035 100644 --- a/executor/crates/calldata-derive/Cargo.toml +++ b/executor/crates/calldata-derive/Cargo.toml @@ -10,6 +10,6 @@ license-file = "../calldata/LICENSE.txt" proc-macro = true [dependencies] -syn = { version = "2", features = ["full", "extra-traits"] } -quote = "1" proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/executor/crates/calldata/Cargo.toml b/executor/crates/calldata/Cargo.toml index 78b591c3..f5ccadcd 100644 --- a/executor/crates/calldata/Cargo.toml +++ b/executor/crates/calldata/Cargo.toml @@ -9,9 +9,7 @@ license-file = "LICENSE.txt" homepage = "https://genlayer.com" keywords = ["genlayer", "blockchain", "smart-contracts", "wasm", "wasi"] categories = ["encoding", "parser-implementations"] -exclude = [ - "/tests/**", "/fuzz/**" -] +exclude = ["/tests/**", "/fuzz/**"] [[example]] name = "fuzz-genvm-common-decode" @@ -23,19 +21,19 @@ path = "fuzz/genvm-common-encode.rs" required-features = ["arbitrary"] [features] -default = [] arbitrary = ["dep:arbitrary"] +default = [] [dependencies] +arbitrary = { version = "1.0", optional = true } +bytes = { version = "1.11.0", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } 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 } +num-bigint = { version = "0.4.6", features = ["serde"] } primitive-types = { version = "0.13", features = ["impl-serde"] } -bytes = {version = "1.11.0", features = ["serde"] } -chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +sha3 = { version = "0.10" } [dev-dependencies] afl = { version = "0.15.18", features = ["no_cfg_fuzzing"] } diff --git a/executor/crates/common/Cargo.toml b/executor/crates/common/Cargo.toml index 76418ca9..4e8e3051 100644 --- a/executor/crates/common/Cargo.toml +++ b/executor/crates/common/Cargo.toml @@ -7,29 +7,46 @@ edition = "2021" debug = true [dependencies] -genlayer_sdk = { path = "../sdk-rs", default-features = false, features = ["arbitrary"] } genlayer_calldata = { path = "../calldata" } +genlayer_sdk = { path = "../sdk-rs", default-features = false, features = [ + "arbitrary", +] } anyhow = { version = "1.0.97", features = ["backtrace"] } +arbitrary = "1.4.2" +archery = "1.2.2" +bytes = { version = "1.11.0", features = ["serde"] } +chrono = { version = "0.4.42", features = ["serde"] } +clap = { version = "4.5.35", features = ["derive"] } +crossbeam = "0.8.4" +dashmap = "6.1.0" +hex = "0.4.3" libc = "0.2" +num-bigint = { version = "0.4.6", features = ["serde"] } +num-rational = { version = "0.4", default-features = false, features = [ + "num-bigint", +] } +num-traits = "0.2" +primitive-types = { version = "0.13.1", default-features = false, features = [ + "impl-serde", + "std", +] } regex = "1.11.1" +rpds = "1.2.0" +rustix = { version = "0.38.44", features = [ + "mm", + "std", +], default-features = false } serde = { version = "1.0.219", features = ["derive", "rc"] } +serde_bytes = "0.11.17" 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"] } -num-traits = "0.2" -hex = "0.4.3" -primitive-types = { version = "0.13.1", default-features = false, features = ["impl-serde", "std"] } -crossbeam = "0.8.4" -rustix = { version = "0.38.44", features = ["mm", "std"], default-features = false} symbol_table = { version = "0.4.0", features = ["global"] } -dashmap = "6.1.0" -clap = { version = "4.5.35", features = ["derive"] } -arbitrary = "1.4.2" -chrono = { version = "0.4.42", features = ["serde"] } -bytes = {version = "1.11.0", features = ["serde"] } -rpds = "1.2.0" -archery = "1.2.2" +tokio = { version = "1.44.1", features = [ + "rt", + "rt-multi-thread", + "net", + "time", + "macros", + "sync", +], default-features = false } diff --git a/executor/crates/modules-interfaces/Cargo.toml b/executor/crates/modules-interfaces/Cargo.toml new file mode 100644 index 00000000..fa764370 --- /dev/null +++ b/executor/crates/modules-interfaces/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "genvm-modules-interfaces" +version = "0.1.0" +edition = "2021" + +[lib] +path = "../../../../../crates/modules-interfaces/src/lib.rs" + +[dependencies] +anyhow = "1.0.97" +bytes = { version = "1.11.0", features = ["serde"] } +genlayer_calldata = { path = "../calldata" } +num-bigint = "0.4" +num-traits = "0.2" +primitive-types = { version = "0.13", features = ["impl-serde"] } +regex = { version = "1.11.1", default-features = false, features = ["std"] } +serde = { version = "1.0.219", features = ["rc", "derive"] } +serde_bytes = "0.11.17" +serde_derive = "1.0.219" +serde_json = "1.0.140" +tokio = { version = "1.44.1", features = [ + "rt", + "rt-multi-thread", + "net", + "time", + "macros", + "sync", +], default-features = false } diff --git a/executor/crates/sdk-rs/Cargo.toml b/executor/crates/sdk-rs/Cargo.toml index 8dcfc198..bfd19c73 100644 --- a/executor/crates/sdk-rs/Cargo.toml +++ b/executor/crates/sdk-rs/Cargo.toml @@ -9,9 +9,7 @@ license-file = "LICENSE.txt" homepage = "https://genlayer.com" keywords = ["genlayer", "blockchain", "smart-contracts", "wasm", "wasi"] categories = ["api-bindings", "wasm"] -exclude = [ - "/tests/**", "/fuzz/**" -] +exclude = ["/tests/**", "/fuzz/**"] # Example contract that fetches a webpage # Build with: cargo build --example fetch_webpage --target wasm32-wasip1 @@ -38,21 +36,21 @@ path = "fuzz/gvm-gl-call-roundtrip.rs" required-features = ["arbitrary"] [features] +arbitrary = ["dep:arbitrary", "genlayer_calldata/arbitrary"] default = ["wasi", "storage"] -wasi = [] dont_define_wasi_storage = [] storage = ["wasi"] -arbitrary = ["dep:arbitrary", "genlayer_calldata/arbitrary"] +wasi = [] [dependencies] -hex = "0.4.3" -num-bigint = {version = "0.4.6", features = ["serde"] } -serde = { version = "1.0", features = ["derive"] } arbitrary = { version = "1.0", optional = true, features = ["derive"] } -primitive-types = { version = "0.13", features = ["impl-serde"] } -serde_bytes = { version = "0.11.19"} -bytes = {version = "1.11.0", features = ["serde"] } +bytes = { version = "1.11.0", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] } +hex = "0.4.3" +num-bigint = { version = "0.4.6", features = ["serde"] } +primitive-types = { version = "0.13", features = ["impl-serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_bytes = { version = "0.11.19" } sha3 = { version = "0.10" } [dependencies.genlayer_calldata] @@ -60,5 +58,5 @@ path = "../calldata" version = "0.0.2" [dev-dependencies] -primitive-types = { version = "0.13", features = ["impl-serde"] } afl = { version = "0.15.18", features = ["no_cfg_fuzzing"] } +primitive-types = { version = "0.13", features = ["impl-serde"] } diff --git a/executor/default.nix b/executor/default.nix index 34c059ce..d82f6203 100644 --- a/executor/default.nix +++ b/executor/default.nix @@ -39,8 +39,10 @@ let src = get-root-subtree [ "${exec-prefix}/executor/src" - # host<->executor interface and schemas live in the manager root + # host<->executor interface and shared calldata live in the manager root "crates/modules-interfaces" + "crates/calldata" + "crates/calldata-derive" "${exec-prefix}/executor/crates" "${exec-prefix}/executor/third-party" "${exec-prefix}/executor/Cargo.toml" diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs index c9103fd6..cb616a2a 100644 --- a/executor/src/wasi/genlayer_sdk.rs +++ b/executor/src/wasi/genlayer_sdk.rs @@ -1144,7 +1144,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { let task = taskify(async move { web.send::( genvm_modules_interfaces::web::Message::Render( - render_payload, + gl_call_to_mi::render_payload(render_payload), space_left_with_overhead, ), ) @@ -1182,7 +1182,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { let task = taskify(async move { web.send::( genvm_modules_interfaces::web::Message::Request( - request_payload, + gl_call_to_mi::request_payload(request_payload), space_left_with_overhead, ), ) @@ -1220,7 +1220,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { .llm .send::( genvm_modules_interfaces::llm::Message::Prompt { - payload: prompt_payload, + payload: gl_call_to_mi::prompt_payload(prompt_payload), remaining_fuel_as_gen, }, ) @@ -1284,7 +1284,9 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { .llm .send::( genvm_modules_interfaces::llm::Message::PromptTemplate { - payload: prompt_template_payload, + payload: gl_call_to_mi::prompt_template_payload( + prompt_template_payload, + ), remaining_fuel_as_gen, }, ) @@ -1981,3 +1983,108 @@ impl ContextVFS<'_> { self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) } } + +/// Conversions from sdk-rs `gl_call` payload types to the self-contained +/// `genvm_modules_interfaces` payload types. +/// +/// These types are structurally identical but distinct: `genvm-modules-interfaces` +/// no longer depends on `sdk-rs`, so it carries its own copies. The orphan rule +/// prevents `From`/`Into` impls here (both types are foreign to this crate), so +/// free functions bridge the executor<->module boundary instead. +mod gl_call_to_mi { + use genlayer_sdk::abi::gl_call::{llm_iface, web_iface}; + use genvm_modules_interfaces::{llm as mi_llm, web as mi_web}; + + pub fn render_mode(m: web_iface::RenderMode) -> mi_web::RenderMode { + match m { + web_iface::RenderMode::Text => mi_web::RenderMode::Text, + web_iface::RenderMode::HTML => mi_web::RenderMode::HTML, + web_iface::RenderMode::Screenshot => mi_web::RenderMode::Screenshot, + } + } + + pub fn wait_after_loaded(w: web_iface::WaitAfterLoaded) -> mi_web::WaitAfterLoaded { + match w { + web_iface::WaitAfterLoaded::Seconds(s) => mi_web::WaitAfterLoaded::Seconds(s), + web_iface::WaitAfterLoaded::Millis(ms) => mi_web::WaitAfterLoaded::Millis(ms), + } + } + + pub fn render_payload(p: web_iface::RenderPayload) -> mi_web::RenderPayload { + mi_web::RenderPayload { + mode: render_mode(p.mode), + url: p.url, + wait_after_loaded: wait_after_loaded(p.wait_after_loaded), + } + } + + pub fn request_method(m: web_iface::RequestMethod) -> mi_web::RequestMethod { + match m { + web_iface::RequestMethod::GET => mi_web::RequestMethod::GET, + web_iface::RequestMethod::POST => mi_web::RequestMethod::POST, + web_iface::RequestMethod::HEAD => mi_web::RequestMethod::HEAD, + web_iface::RequestMethod::PUT => mi_web::RequestMethod::PUT, + web_iface::RequestMethod::DELETE => mi_web::RequestMethod::DELETE, + web_iface::RequestMethod::OPTIONS => mi_web::RequestMethod::OPTIONS, + web_iface::RequestMethod::PATCH => mi_web::RequestMethod::PATCH, + } + } + + pub fn request_payload(p: web_iface::RequestPayload) -> mi_web::RequestPayload { + mi_web::RequestPayload { + method: request_method(p.method), + url: p.url, + headers: p.headers, + body: p.body, + sign: p.sign, + } + } + + pub fn output_format(f: llm_iface::OutputFormat) -> mi_llm::OutputFormat { + match f { + llm_iface::OutputFormat::Text => mi_llm::OutputFormat::Text, + llm_iface::OutputFormat::JSON => mi_llm::OutputFormat::JSON, + } + } + + pub fn prompt_payload(p: llm_iface::PromptPayload) -> mi_llm::PromptPayload { + mi_llm::PromptPayload { + response_format: output_format(p.response_format), + prompt: p.prompt, + images: p.images, + } + } + + pub fn prompt_template_payload( + p: llm_iface::PromptTemplatePayload, + ) -> mi_llm::PromptTemplatePayload { + match p { + llm_iface::PromptTemplatePayload::EqComparative(x) => { + mi_llm::PromptTemplatePayload::EqComparative(mi_llm::PromptEqComparativePayload { + leader_answer: x.leader_answer, + validator_answer: x.validator_answer, + principle: x.principle, + }) + } + llm_iface::PromptTemplatePayload::EqNonComparativeValidator(x) => { + mi_llm::PromptTemplatePayload::EqNonComparativeValidator( + mi_llm::PromptEqNonComparativeValidatorPayload { + task: x.task, + criteria: x.criteria, + input: x.input, + output: x.output, + }, + ) + } + llm_iface::PromptTemplatePayload::EqNonComparativeLeader(x) => { + mi_llm::PromptTemplatePayload::EqNonComparativeLeader( + mi_llm::PromptEqNonComparativeLeaderPayload { + task: x.task, + criteria: x.criteria, + input: x.input, + }, + ) + } + } + } +} diff --git a/manifest.json b/manifest.json index fdc3091e..e278c09e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,4 @@ { "executor-version": "v0.3.0-rc7", - "available-after": "2024-09-01T00:00:00Z", - "repo-url": "https://github.com/genlayerlabs/genvm.git", - "head-revision": "fake" + "available-after": "2024-09-01T00:00:00Z" } diff --git a/ruff.toml b/ruff.toml index 7bd3751c..53cef838 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,7 +1,4 @@ -exclude = [ - "runners/genlayer-py-std/src-emb/onnx", - "runners/py-libs/**/*", -] +exclude = ["runners/genlayer-py-std/src-emb/onnx", "runners/py-libs/**/*"] # The hook passes an explicit file list; ruff only honors `exclude` for # explicitly-named files when force-exclude is set (otherwise exclude applies # only to directory discovery, and vendored/generated files get reformatted). @@ -10,21 +7,15 @@ indent-width = 2 [format] indent-style = "tab" -quote-style = "single" line-ending = "lf" +quote-style = "single" -skip-magic-trailing-comma = false docstring-code-format = true +skip-magic-trailing-comma = false [lint] -ignore = [ - "E741" -] +ignore = ["E741"] -extend-select = [ - "I" -] +extend-select = ["I"] -exclude = [ - "runners/cpython/modules/**", -] +exclude = ["runners/cpython/modules/**"] diff --git a/runners/genlayer-py-std/pyproject.toml b/runners/genlayer-py-std/pyproject.toml index ce547419..a7f4ef13 100644 --- a/runners/genlayer-py-std/pyproject.toml +++ b/runners/genlayer-py-std/pyproject.toml @@ -13,7 +13,5 @@ concurrency = ["multiprocessing"] sigterm = true [tool.pytest.ini_options] -testpaths = [ - "tests", -] addopts = "--cov=genlayer.types --cov=genlayer.calldata --cov=genlayer.storage --cov=genlayer.evm --cov=genlayer._internal --cov=genlayer_embeddings --cov-report term-missing --cov-fail-under=75" +testpaths = ["tests"] diff --git a/runners/support/tools/genvm-floats-to-soft/Cargo.toml b/runners/support/tools/genvm-floats-to-soft/Cargo.toml index 07732f06..d1db942a 100644 --- a/runners/support/tools/genvm-floats-to-soft/Cargo.toml +++ b/runners/support/tools/genvm-floats-to-soft/Cargo.toml @@ -5,5 +5,5 @@ edition = "2021" [dependencies] anyhow = "1.0.86" -wasm-encoder = { version = "0.216.0", features = ["wasmparser"]} +wasm-encoder = { version = "0.216.0", features = ["wasmparser"] } wasmparser = "0.216.0" diff --git a/runners/support/tools/genvm-wasm-add-mod-name/Cargo.toml b/runners/support/tools/genvm-wasm-add-mod-name/Cargo.toml index 7bee1594..3a404e04 100644 --- a/runners/support/tools/genvm-wasm-add-mod-name/Cargo.toml +++ b/runners/support/tools/genvm-wasm-add-mod-name/Cargo.toml @@ -5,5 +5,5 @@ edition = "2021" [dependencies] anyhow = "1.0.86" -wasm-encoder = { version = "0.216.0", features = ["wasmparser"]} +wasm-encoder = { version = "0.216.0", features = ["wasmparser"] } wasmparser = "0.216.0" diff --git a/support/nix/precommit/flake.nix b/support/nix/precommit/flake.nix index d383b989..9c930b16 100644 --- a/support/nix/precommit/flake.nix +++ b/support/nix/precommit/flake.nix @@ -25,6 +25,7 @@ rustfmt nixfmt cargo + taplo ; pre-commit-hooks = pkgs.python3Packages.pre-commit-hooks; # v19 to match the executor's historical clang-format pin. From 38f65345525a262bdf2bc0b327737a8fb9918e3f Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 1 Jul 2026 13:58:27 +0900 Subject: [PATCH 03/25] =?UTF-8?q?feat(executor):=20add=20check=20command?= =?UTF-8?q?=20to=20verify=20runners=20and=20precompile=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/Cargo.lock | 1 + executor/Cargo.toml | 1 + executor/src/exe/check.rs | 74 ++++++++++++++++++++++++++++++++++ executor/src/exe/mod.rs | 1 + executor/src/exe/precompile.rs | 22 ++++++++-- executor/src/main.rs | 2 + 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 executor/src/exe/check.rs diff --git a/executor/Cargo.lock b/executor/Cargo.lock index e58fc922..24ff74b6 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -1026,6 +1026,7 @@ dependencies = [ "serde_json", "serde_with", "serde_yaml", + "sha2", "sha3", "symbol_table", "tokio", diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 39d30dfc..1190df7a 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -89,6 +89,7 @@ serde_derive = "1.0.219" serde_json = "1.0.140" serde_with = { version = "3.12.0", features = ["base64"] } serde_yaml = "0.9.34" +sha2 = "0.10.8" sha3 = { version = "0.10.8", features = ["asm"] } symbol_table = { version = "0.4.0", features = ["global", "serde"] } tokio = { version = "1.44.1", features = [ diff --git a/executor/src/exe/check.rs b/executor/src/exe/check.rs new file mode 100644 index 00000000..e9d3da25 --- /dev/null +++ b/executor/src/exe/check.rs @@ -0,0 +1,74 @@ +use std::collections::BTreeMap; + +use anyhow::Context as _; +use genvm::{config, runners}; + +use genvm_common::*; + +#[derive(clap::Args, Debug)] +pub struct Args { + #[arg( + long, + default_value_t = false, + help = "after verification, precompile all runners into the on-disk cache" + )] + precompile: bool, +} + +fn read_registry(path: &std::path::Path) -> anyhow::Result { + let contents = std::fs::read_to_string(path).with_context(|| format!("reading {path:?}"))?; + serde_json::from_str(&contents).with_context(|| format!("parsing {path:?}")) +} + +pub fn handle(args: Args, config: config::Config) -> anyhow::Result<()> { + let registry_dir = std::path::Path::new(&config.registry_dir); + let runners_dir = std::path::Path::new(&config.runners_dir); + + log_info!(registry_dir:? = registry_dir, runners_dir:? = runners_dir; "checking install"); + + let all: BTreeMap> = read_registry(®istry_dir.join("all.json"))?; + let latest: BTreeMap = read_registry(®istry_dir.join("latest.json"))?; + + // (a) every runner `latest` resolves to must also be listed in `all`. + for (id, hash) in &latest { + let present = all + .get(id) + .is_some_and(|hashes| hashes.iter().any(|h| h == hash)); + if !present { + anyhow::bail!("latest runner {id}:{hash} is not present in all.json"); + } + } + + // (b) every runner in `all` exists on disk with the expected content hash. + let mut checked = 0usize; + for (id, hashes) in &all { + for hash in hashes { + let mut runner_path = runners_dir.to_owned(); + runners::append_runner_subpath(id, hash, &mut runner_path); + runner_path.set_extension("tar"); + + let data = std::fs::read(&runner_path) + .with_context(|| format!("reading runner {id}:{hash} at {runner_path:?}"))?; + + use sha2::Digest as _; + let digest: [u8; 32] = sha2::Sha256::digest(&data).into(); + let got = genvm_common::Bytes32Hash::from_bytes(digest).to_gvm32(); + + if got != *hash { + anyhow::bail!( + "runner {id} at {runner_path:?} has wrong content hash: expected {hash}, got {got}" + ); + } + + checked += 1; + } + } + + log_info!(runners = checked; "all runners present with correct hashes"); + + if args.precompile { + super::precompile::run(&config)?; + } + + Ok(()) +} diff --git a/executor/src/exe/mod.rs b/executor/src/exe/mod.rs index 857f973a..113e6988 100644 --- a/executor/src/exe/mod.rs +++ b/executor/src/exe/mod.rs @@ -1,3 +1,4 @@ +pub mod check; pub mod parse_version; pub mod precompile; pub mod run; diff --git a/executor/src/exe/precompile.rs b/executor/src/exe/precompile.rs index 77d1312e..ac4e8321 100644 --- a/executor/src/exe/precompile.rs +++ b/executor/src/exe/precompile.rs @@ -112,6 +112,25 @@ fn compile_single_file( } pub fn handle(args: Args, config: config::Config) -> anyhow::Result<()> { + if args.info { + log_info!(version = genvm_common::version::CURRENT.clone(); "current version"); + + let cache_dir = caching::get_cache_dir(&config.cache_dir)?; + let mut precompile_dir = cache_dir.clone(); + precompile_dir.push(caching::PRECOMPILE_DIR_NAME); + + let registry_dir = std::path::Path::new(&config.registry_dir); + + log_info!(cache_dir:? = cache_dir, precompile_dir:? = precompile_dir, registry_dir:? = registry_dir; "information"); + return Ok(()); + } + + run(&config) +} + +/// Precompiles every runner listed in `all.json` into the on-disk cache. Shared +/// by the `precompile` subcommand and `check --precompile`. +pub fn run(config: &config::Config) -> anyhow::Result<()> { log_info!(version = genvm_common::version::CURRENT.clone(); "current version"); let cache_dir = caching::get_cache_dir(&config.cache_dir)?; @@ -122,9 +141,6 @@ pub fn handle(args: Args, config: config::Config) -> anyhow::Result<()> { log_info!(cache_dir:? = cache_dir, precompile_dir:? = precompile_dir, registry_dir:? = registry_dir; "information"); - if args.info { - return Ok(()); - } let engines = genvm::rt::supervisor::create_engines(|conf| { conf.cranelift_opt_level(wasmtime::OptLevel::Speed); Ok(()) diff --git a/executor/src/main.rs b/executor/src/main.rs index bfb3904e..b5268c1d 100644 --- a/executor/src/main.rs +++ b/executor/src/main.rs @@ -11,6 +11,7 @@ mod exe; enum Commands { Run(exe::run::Args), Precompile(exe::precompile::Args), + Check(exe::check::Args), ParseVersionPattern(exe::parse_version::Args), } @@ -56,6 +57,7 @@ fn main_impl() -> Result<()> { match args.command { Commands::Run(args) => exe::run::handle(args, config), Commands::Precompile(args) => exe::precompile::handle(args, config), + Commands::Check(args) => exe::check::handle(args, config), Commands::ParseVersionPattern(args) => exe::parse_version::handle(args, config), } } From 35c3f904cdf7f222331f926bdf1983d87467e8ef Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 1 Jul 2026 14:41:03 +0900 Subject: [PATCH 04/25] =?UTF-8?q?chore(runners):=20validate=20address=20in?= =?UTF-8?q?=20runners=20parsing=20=E2=99=BB=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/src/runners/mod.rs | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/executor/src/runners/mod.rs b/executor/src/runners/mod.rs index d6cc8f5e..6cc58fb8 100644 --- a/executor/src/runners/mod.rs +++ b/executor/src/runners/mod.rs @@ -160,14 +160,35 @@ fn is_hash_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=' } -fn parse_hex_fixed(s: &str) -> Option<[u8; N]> { - let s = s.strip_prefix("0x")?; - if s.len() != N * 2 { +fn parse_safe_address(s: &str) -> Option<[u8; 20]> { + let s = s.strip_prefix("0x").unwrap_or(s); + + if s.len() != 40 { + log_warn!("address must be exactly 40 hex characters, got {}", s.len()); return None; } - let mut out = [0u8; N]; - hex::decode_to_slice(s, &mut out).ok()?; - Some(out) + + let mut address = [0u8; 20]; + if hex::decode_to_slice(s, &mut address).is_err() { + log_warn!("address contains invalid hex characters"); + return None; + } + + let is_all_lower = s.chars().all(|c| !c.is_ascii_uppercase()); + let is_all_upper = s.chars().all(|c| !c.is_ascii_lowercase()); + + if is_all_lower || is_all_upper { + return Some(address); + } + + let addr = calldata::Address::from(address); + let checksum = addr.checksum_hex_string(); + if s == checksum { + return Some(address); + } + + log_warn!("address must be all lowercase, all uppercase, or valid checksum"); + None } pub fn parse_runner_id(id: &str) -> Option { @@ -184,7 +205,7 @@ pub fn parse_runner_id(id: &str) -> Option { return None; } - let address = calldata::Address::from(parse_hex_fixed::<20>(address)?); + let address = calldata::Address::from(parse_safe_address(address)?); let on = match on_str { Some("a") | None => Some(ChainState::Accepted), Some("f") => Some(ChainState::Finalized), From 315ed39d05e5ca3c05459e26c60c18385472a33e Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 1 Jul 2026 15:24:15 +0900 Subject: [PATCH 05/25] =?UTF-8?q?chore(wasi):=20remove=20unused=20json=5Ft?= =?UTF-8?q?o=5Fcalldata=20module=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/src/wasi/json_to_calldata.rs | 112 -------------------------- executor/src/wasi/mod.rs | 1 - 2 files changed, 113 deletions(-) delete mode 100644 executor/src/wasi/json_to_calldata.rs diff --git a/executor/src/wasi/json_to_calldata.rs b/executor/src/wasi/json_to_calldata.rs deleted file mode 100644 index 9208652c..00000000 --- a/executor/src/wasi/json_to_calldata.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::collections::BTreeMap; - -use crate::calldata; - -enum Frame { - Map { - key_in_parent: String, - iter: serde_json::map::IntoIter, - built: BTreeMap, - }, - Array { - key_in_parent: String, - iter: std::vec::IntoIter, - built: Vec, - }, -} - -fn leaf_to_calldata(value: serde_json::Value) -> calldata::Value { - match value { - serde_json::Value::Null => calldata::Value::Null, - serde_json::Value::Bool(b) => calldata::Value::Bool(b), - serde_json::Value::String(s) => calldata::Value::Str(s), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - calldata::Value::Number(num_bigint::BigInt::from(i)) - } else if let Some(u) = n.as_u64() { - calldata::Value::Number(num_bigint::BigInt::from(u)) - } else { - calldata::Value::Str(n.to_string()) - } - } - serde_json::Value::Array(_) | serde_json::Value::Object(_) => unreachable!(), - } -} - -fn insert_into_parent(parent: &mut Frame, key: String, value: calldata::Value) { - match parent { - Frame::Map { built, .. } => { - built.insert(key, value); - } - Frame::Array { built, .. } => { - built.push(value); - } - } -} - -pub fn json_map_to_calldata( - input: serde_json::Map, -) -> BTreeMap { - let mut stack: Vec = Vec::with_capacity(8); - stack.push(Frame::Map { - key_in_parent: String::new(), - iter: input.into_iter(), - built: BTreeMap::new(), - }); - - loop { - let next = match stack.last_mut().expect("stack is non-empty") { - Frame::Map { iter, .. } => iter.next().map(|(k, v)| (Some(k), v)), - Frame::Array { iter, .. } => iter.next().map(|v| (None, v)), - }; - - let Some((key_opt, value)) = next else { - let popped = stack.pop().expect("stack is non-empty"); - let (key_in_parent, finished) = match popped { - Frame::Map { - key_in_parent, - built, - .. - } => (key_in_parent, calldata::Value::Map(built)), - Frame::Array { - key_in_parent, - built, - .. - } => (key_in_parent, calldata::Value::Array(built)), - }; - - if stack.is_empty() { - match finished { - calldata::Value::Map(m) => return m, - _ => unreachable!(), - } - } - - insert_into_parent(stack.last_mut().unwrap(), key_in_parent, finished); - continue; - }; - - let key_in_parent = key_opt.unwrap_or_default(); - match value { - serde_json::Value::Object(m) => { - stack.push(Frame::Map { - key_in_parent, - iter: m.into_iter(), - built: BTreeMap::new(), - }); - } - serde_json::Value::Array(a) => { - let len = a.len(); - stack.push(Frame::Array { - key_in_parent, - iter: a.into_iter(), - built: Vec::with_capacity(len), - }); - } - leaf => { - let converted = leaf_to_calldata(leaf); - insert_into_parent(stack.last_mut().unwrap(), key_in_parent, converted); - } - } - } -} diff --git a/executor/src/wasi/mod.rs b/executor/src/wasi/mod.rs index 0cd18a86..dd57136e 100644 --- a/executor/src/wasi/mod.rs +++ b/executor/src/wasi/mod.rs @@ -4,7 +4,6 @@ use ::genlayer_sdk as original_genlayer_sdk; pub mod base; pub mod genlayer_sdk; -pub mod json_to_calldata; pub mod preview1; pub mod vfs; From 7e6c2b373111d8bb03f01d736aa165896d4d7bad Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 2 Jul 2026 00:40:17 +0900 Subject: [PATCH 06/25] =?UTF-8?q?chore(ci):=20scope=20coderabbit=20to=20ve?= =?UTF-8?q?rsion=20branches,=20skip=20root=20test=20fixtures=20?= =?UTF-8?q?=F0=9F=94=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 879e40f9..c901430f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,4 +1,9 @@ reviews: + # Review PRs targeting the long-lived version branches (e.g. v0.3.x, v0.3-dev) + # and main; feature/topic branches are reviewed via their target base. + base_branches: + - "main" + - "v\\d+\\.\\d+(\\.x|-dev)?" path_filters: - "!**/*.onnx" - "!**/*.txt" @@ -10,3 +15,6 @@ reviews: - "!runners/softfloat/berkeley-softfloat-3/**" - "!executor/third-party/**" - "!**/fuzz/inputs*/**" + # Root-level system-test fixtures (contracts, expected outputs); crate and + # runner test code under */tests/ is still reviewed. + - "!tests/**" From c66900846f077556c45e70364a2c3f2ca7ea2819 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 2 Jul 2026 10:42:09 +0900 Subject: [PATCH 07/25] =?UTF-8?q?fix(wasm):=20allow=20SIMD=20float=20lane?= =?UTF-8?q?=20copies=20in=20det=20mode,=20test=20long=20embeddings=20?= =?UTF-8?q?=F0=9F=90=9B=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit genvm-floats-to-soft rewrites all float compute to softfloat calls but leaves f32x4/f64x2 splat/extract_lane/replace_lane in place (bit-exact copies); the det codegen still trapped on them, so numpy kernels hit by ~100-char embeddings died with wasm_trap DeterministicMode. --- .git-third-party/config.json | 2 +- .../patches/executor/third-party/wasmtime/3 | 51 +++++++++++++++++++ .../call_wasi_extra/call_wasi_extra.0.hash | 1 - .../_hello_world_/_hello_world_.0.hash | 1 - .../_hello_world_class_nondet.0.hash | 1 - .../hello-world/trivial/trivial.0.hash | 1 - .../hello-world/trivial/trivial.0_0.hash | 1 - .../simple_valid_err_exit.0.hash | 1 - .../simple_valid_err_exit.0_0.hash | 1 - .../simple_valid_err_exit_wrong_err.0.hash | 1 - .../simple_valid_err_exit_wrong_err.0_0.hash | 1 - .../embeddings_long/embeddings_long.0.stdout | 1 + .../embeddings_long.0_0.stdout | 2 + .../embeddings_long/embeddings_long.jsonnet | 4 ++ .../prompt/embeddings_long/embeddings_long.py | 26 ++++++++++ .../embeddings_long_det.0.stdout | 1 + .../embeddings_long_det.0_0.stdout | 2 + .../embeddings_long_det.jsonnet | 4 ++ tests/integration/prompt/simple/simple.0.hash | 1 - .../integration/prompt/simple/simple.0_0.hash | 1 - .../prompt/simple/simple_det.0.hash | 1 - .../prompt/simple/simple_det.0_0.hash | 1 - .../_hello_world_class.0.hash | 1 - .../_hello_world_class.0_0.hash | 1 - .../user-error/simple/simple.0.hash | 1 - 25 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 .git-third-party/patches/executor/third-party/wasmtime/3 delete mode 100644 tests/integration/exploit/call_wasi_extra/call_wasi_extra.0.hash delete mode 100644 tests/integration/hello-world/_hello_world_/_hello_world_.0.hash delete mode 100644 tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.0.hash delete mode 100644 tests/integration/hello-world/trivial/trivial.0.hash delete mode 100644 tests/integration/hello-world/trivial/trivial.0_0.hash delete mode 100644 tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0.hash delete mode 100644 tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0_0.hash delete mode 100644 tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0.hash delete mode 100644 tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0_0.hash create mode 100644 tests/integration/prompt/embeddings_long/embeddings_long.0.stdout create mode 100644 tests/integration/prompt/embeddings_long/embeddings_long.0_0.stdout create mode 100644 tests/integration/prompt/embeddings_long/embeddings_long.jsonnet create mode 100644 tests/integration/prompt/embeddings_long/embeddings_long.py create mode 100644 tests/integration/prompt/embeddings_long/embeddings_long_det.0.stdout create mode 100644 tests/integration/prompt/embeddings_long/embeddings_long_det.0_0.stdout create mode 100644 tests/integration/prompt/embeddings_long/embeddings_long_det.jsonnet delete mode 100644 tests/integration/prompt/simple/simple.0.hash delete mode 100644 tests/integration/prompt/simple/simple.0_0.hash delete mode 100644 tests/integration/prompt/simple/simple_det.0.hash delete mode 100644 tests/integration/prompt/simple/simple_det.0_0.hash delete mode 100644 tests/integration/storage/_hello_world_class/_hello_world_class.0.hash delete mode 100644 tests/integration/storage/_hello_world_class/_hello_world_class.0_0.hash delete mode 100644 tests/integration/user-error/simple/simple.0.hash diff --git a/.git-third-party/config.json b/.git-third-party/config.json index c3e65a7e..20bebfcf 100644 --- a/.git-third-party/config.json +++ b/.git-third-party/config.json @@ -3,7 +3,7 @@ "executor/third-party/wasmtime": { "url": "https://github.com/bytecodealliance/wasmtime.git", "commit": "6844a83b530f86ad13a65175282fe2dbcd33cad1", - "patches": 2, + "patches": 3, "submodules": [] }, "executor/third-party/wasm-tools": { diff --git a/.git-third-party/patches/executor/third-party/wasmtime/3 b/.git-third-party/patches/executor/third-party/wasmtime/3 new file mode 100644 index 00000000..43aead07 --- /dev/null +++ b/.git-third-party/patches/executor/third-party/wasmtime/3 @@ -0,0 +1,51 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: kp2pml30 +Date: Thu, 2 Jul 2026 10:35:16 +0900 +Subject: [PATCH] =?UTF-8?q?fix(wasm):=20allow=20SIMD=20float=20lane=20copy?= + =?UTF-8?q?=20ops=20in=20deterministic=20mode=20=F0=9F=90=9B?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +f32x4/f64x2 splat, extract_lane and replace_lane are bit-exact copies +(no rounding or NaN semantics), matching the existing exception for +loads/bitcasts; genvm-floats-to-soft intentionally leaves them in +place, so trapping on them broke real numpy code paths. + +Claude-Session: https://claude.ai/code/session_013QtkVNJFxWsNwCLhTp5idV +--- + crates/cranelift/src/translate/code_translator.rs | 7 ++++--- + 1 file changed, 4 insertions(+), 3 deletions(-) + +diff --git a/crates/cranelift/src/translate/code_translator.rs b/crates/cranelift/src/translate/code_translator.rs +index 9dde783c..927df0f8 100644 +--- a/crates/cranelift/src/translate/code_translator.rs ++++ b/crates/cranelift/src/translate/code_translator.rs +@@ -1750,7 +1750,8 @@ pub fn translate_operator( + } + Operator::F32x4Splat + | Operator::F64x2Splat => { +- if float_op_unreachable_check(environ, builder) { return Ok(()) } ++ // Lane duplication is a bit-exact copy (no rounding/NaN semantics), ++ // so it stays allowed in deterministic mode. + let splatted = builder.ins().splat(type_of(op), environ.stacks.pop1()); + environ.stacks.push1(splatted) + } +@@ -1839,7 +1840,7 @@ pub fn translate_operator( + } + Operator::F32x4ExtractLane { lane } + | Operator::F64x2ExtractLane { lane } => { +- if float_op_unreachable_check(environ, builder) { return Ok(()) } ++ // Bit-exact lane copy; allowed in deterministic mode. + let vector = pop1_with_bitcast(environ, type_of(op), builder); + environ + .stacks +@@ -1864,7 +1865,7 @@ pub fn translate_operator( + } + Operator::F32x4ReplaceLane { lane } + | Operator::F64x2ReplaceLane { lane } => { +- if float_op_unreachable_check(environ, builder) { return Ok(()) } ++ // Bit-exact lane copy; allowed in deterministic mode. + let (vector, replacement) = environ.stacks.pop2(); + let vector = optionally_bitcast_vector(vector, type_of(op), builder); + environ diff --git a/tests/integration/exploit/call_wasi_extra/call_wasi_extra.0.hash b/tests/integration/exploit/call_wasi_extra/call_wasi_extra.0.hash deleted file mode 100644 index 6385750e..00000000 --- a/tests/integration/exploit/call_wasi_extra/call_wasi_extra.0.hash +++ /dev/null @@ -1 +0,0 @@ -LRFkZXhpdF9jb2RlIDMwABUVowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgxynAQAAIyB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KCmltcG9ydCBfZ2VubGF5ZXJfd2FzaSBhcyB3YXNpCmltcG9ydCBnZW5sYXllci5jYWxsZGF0YSBhcyBjYWxsZGF0YQppbXBvcnQgb3MKCmltcG9ydCBjbG91ZHBpY2tsZQoKCmRlZiBmb28oKToKCXByaW50KCd3ZSB3aWxsIGhhdmUgYW4gaW5maW5pdGUgbG9vcC4uLicpCgoKdHJ5OgoJZmQgPSB3YXNpLmdsX2NhbGwoCgkJY2FsbGRhdGEuZW5jb2RlKAoJCQl7CgkJCQknU2FuZGJveCc6IHsKCQkJCQknYV9leHRyYV9maWVsZCc6IFRydWUsCgkJCQkJJ2RhdGEnOiBjbG91ZHBpY2tsZS5kdW1wcyhmb28pLAoJCQkJfQoJCQl9CgkJKSwKCSkKZXhjZXB0IEV4Y2VwdGlvbiBhcyBlOgoJcHJpbnQoZSkKCWV4aXQoMzApCgpwcmludChvcy5mZG9wZW4oZmQpLnJlYWQoKSkKCmV4aXQoMCkKAAAAAAAAAAAAAAAAAAAAAAAAAAAABQ== diff --git a/tests/integration/hello-world/_hello_world_/_hello_world_.0.hash b/tests/integration/hello-world/_hello_world_/_hello_world_.0.hash deleted file mode 100644 index 4bca7eb3..00000000 --- a/tests/integration/hello-world/_hello_world_/_hello_world_.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAABUVowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgwQ2AAAAIyB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KCnByaW50KDEpCmV4aXQoMCkKAAAAAAAABQ== diff --git a/tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.0.hash b/tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.0.hash deleted file mode 100644 index 26b60dfa..00000000 --- a/tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAB0VowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgxZPAQAAIyB2MC4xLjEwCiMgewojICAgIlNlcSI6IFsKIyAgICAgeyAiQWRkRW52IjogeyJuYW1lIjogIkdFTkxBWUVSX0VOQUJMRV9QUk9GSUxFUiIsICJ2YWwiOiAiZmFsc2UifSB9LAojICAgICB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KIyAgIF0KIyB9CmltcG9ydCBnZW5sYXllciBhcyBnbApmcm9tIGdlbmxheWVyLnR5cGVzIGltcG9ydCAqCgoKY2xhc3MgQ29udHJhY3QoZ2wuY29udHJhY3QuQ29udHJhY3QpOgoJZGVmIF9faW5pdF9fKHNlbGYpOgoJCWdsLnZtLnJ1bl9ub25kZXQobGFtYmRhOiBOb25lLCBsYW1iZGEgeDogVHJ1ZSkKCQlwcmludCgnaGVsbG8gd29ybGQnKQoAAAAAAAAAAAAAAAAABQ== diff --git a/tests/integration/hello-world/trivial/trivial.0.hash b/tests/integration/hello-world/trivial/trivial.0.hash deleted file mode 100644 index 4cce027e..00000000 --- a/tests/integration/hello-world/trivial/trivial.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAB0VowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgxQqAQAAIyB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KaW1wb3J0IGdlbmxheWVyIGFzIGdsCmZyb20gZ2VubGF5ZXIudHlwZXMgaW1wb3J0ICoKCgpjbGFzcyBDb250cmFjdChnbC5jb250cmFjdC5Db250cmFjdCk6CglAZ2wucHVibGljLndyaXRlCglkZWYgaW5pdChzZWxmKToKCQlldmFsKCJwcmludCgnaW5pdCBmcm9tIGV2YWwhJykiKQoKCQlkZWYgcnVuKCk6CgkJCXByaW50KCd3b3csIG5vbmRldCcpCgkJCXJldHVybiAnd2ViIHBhZ2U/JwoKCQlyZXR1cm4gZ2wuZXFfcHJpbmNpcGxlLnN0cmljdF9lcShydW4pCgAAAAAAAAAAAAAAAAAAAAAAAAU= diff --git a/tests/integration/hello-world/trivial/trivial.0_0.hash b/tests/integration/hello-world/trivial/trivial.0_0.hash deleted file mode 100644 index 7ee50db8..00000000 --- a/tests/integration/hello-world/trivial/trivial.0_0.hash +++ /dev/null @@ -1 +0,0 @@ -LQFMd2ViIHBhZ2U/AAUF diff --git a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0.hash b/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0.hash deleted file mode 100644 index 5e575939..00000000 --- a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAB0VowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgyIcAgAAIyB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KaW1wb3J0IGdlbmxheWVyIGFzIGdsCmZyb20gZ2VubGF5ZXIudHlwZXMgaW1wb3J0ICoKCgpjbGFzcyBDb250cmFjdChnbC5jb250cmFjdC5Db250cmFjdCk6CglAZ2wucHVibGljLndyaXRlCglkZWYgZm9vKHNlbGYpOgoJCWRlZiBkb19mbigpOgoJCQlyYWlzZSBFeGNlcHRpb24oKQoKCQlnbC5lcV9wcmluY2lwbGUuc3RyaWN0X2VxKGRvX2ZuKQoKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBiYXIoc2VsZik6CgkJZGVmIGRvX2ZuKCk6CgkJCXJldHVybgoKCQlnbC5lcV9wcmluY2lwbGUuc3RyaWN0X2VxKGRvX2ZuKQoKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBleChzZWxmKToKCQlkZWYgZG9fZm4oKToKCQkJZXhpdCgyKQoKCQlnbC5lcV9wcmluY2lwbGUuc3RyaWN0X2VxKGRvX2ZuKQoKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBleDIoc2VsZik6CgkJZGVmIGRvX2ZuKCk6CgkJCW5vbl9leGlzdGVudF9mbigpICAjIG5vcWEKCgkJZ2wuZXFfcHJpbmNpcGxlLnN0cmljdF9lcShkb19mbikKBQ== diff --git a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0_0.hash b/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0_0.hash deleted file mode 100644 index 4467e51b..00000000 --- a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit.0_0.hash +++ /dev/null @@ -1 +0,0 @@ -LRFcZXhpdF9jb2RlIDIABQU= diff --git a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0.hash b/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0.hash deleted file mode 100644 index 5e575939..00000000 --- a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAB0VowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgyIcAgAAIyB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KaW1wb3J0IGdlbmxheWVyIGFzIGdsCmZyb20gZ2VubGF5ZXIudHlwZXMgaW1wb3J0ICoKCgpjbGFzcyBDb250cmFjdChnbC5jb250cmFjdC5Db250cmFjdCk6CglAZ2wucHVibGljLndyaXRlCglkZWYgZm9vKHNlbGYpOgoJCWRlZiBkb19mbigpOgoJCQlyYWlzZSBFeGNlcHRpb24oKQoKCQlnbC5lcV9wcmluY2lwbGUuc3RyaWN0X2VxKGRvX2ZuKQoKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBiYXIoc2VsZik6CgkJZGVmIGRvX2ZuKCk6CgkJCXJldHVybgoKCQlnbC5lcV9wcmluY2lwbGUuc3RyaWN0X2VxKGRvX2ZuKQoKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBleChzZWxmKToKCQlkZWYgZG9fZm4oKToKCQkJZXhpdCgyKQoKCQlnbC5lcV9wcmluY2lwbGUuc3RyaWN0X2VxKGRvX2ZuKQoKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBleDIoc2VsZik6CgkJZGVmIGRvX2ZuKCk6CgkJCW5vbl9leGlzdGVudF9mbigpICAjIG5vcWEKCgkJZ2wuZXFfcHJpbmNpcGxlLnN0cmljdF9lcShkb19mbikKBQ== diff --git a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0_0.hash b/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0_0.hash deleted file mode 100644 index defc9c59..00000000 --- a/tests/integration/nondet-consensus/leader_errors/simple_valid_err_exit_wrong_err.0_0.hash +++ /dev/null @@ -1 +0,0 @@ -LQlcZXhpdF9jb2RlIDEABQU= diff --git a/tests/integration/prompt/embeddings_long/embeddings_long.0.stdout b/tests/integration/prompt/embeddings_long/embeddings_long.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/prompt/embeddings_long/embeddings_long.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/prompt/embeddings_long/embeddings_long.0_0.stdout b/tests/integration/prompt/embeddings_long/embeddings_long.0_0.stdout new file mode 100644 index 00000000..59b71a7d --- /dev/null +++ b/tests/integration/prompt/embeddings_long/embeddings_long.0_0.stdout @@ -0,0 +1,2 @@ +-1.1573486 +executed with `Return(null)` diff --git a/tests/integration/prompt/embeddings_long/embeddings_long.jsonnet b/tests/integration/prompt/embeddings_long/embeddings_long.jsonnet new file mode 100644 index 00000000..8fe84e86 --- /dev/null +++ b/tests/integration/prompt/embeddings_long/embeddings_long.jsonnet @@ -0,0 +1,4 @@ +local simple = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +{tags: util.features([['prompt', 'text'], ['nasty-determinism'], ['nondet']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py', 'main', [false])])} diff --git a/tests/integration/prompt/embeddings_long/embeddings_long.py b/tests/integration/prompt/embeddings_long/embeddings_long.py new file mode 100644 index 00000000..a7d6e2af --- /dev/null +++ b/tests/integration/prompt/embeddings_long/embeddings_long.py @@ -0,0 +1,26 @@ +# { +# "Seq": [ +# { "Depends": "py-lib-genlayer-embeddings:test" }, +# { "Depends": "py-genlayer:test" } +# ] +# } + +import genlayer as gl +import genlayer_embeddings as gle + +# Exactly 100 characters. +LONG_TEXT = 'GenLayer smart contracts can compute sentence embeddings for fairly long input strings inside GenVM.' + + +class Contract(gl.contract.Contract): + @gl.public.write + def main(self, det: bool): + def nd_block(): + embeddings_generator = gle.SentenceTransformer('all-MiniLM-L6-v2') + real = embeddings_generator(LONG_TEXT) + print(real.sum()) + + if det: + nd_block() + else: + gl.eq_principle.strict_eq(nd_block) diff --git a/tests/integration/prompt/embeddings_long/embeddings_long_det.0.stdout b/tests/integration/prompt/embeddings_long/embeddings_long_det.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/prompt/embeddings_long/embeddings_long_det.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/prompt/embeddings_long/embeddings_long_det.0_0.stdout b/tests/integration/prompt/embeddings_long/embeddings_long_det.0_0.stdout new file mode 100644 index 00000000..59b71a7d --- /dev/null +++ b/tests/integration/prompt/embeddings_long/embeddings_long_det.0_0.stdout @@ -0,0 +1,2 @@ +-1.1573486 +executed with `Return(null)` diff --git a/tests/integration/prompt/embeddings_long/embeddings_long_det.jsonnet b/tests/integration/prompt/embeddings_long/embeddings_long_det.jsonnet new file mode 100644 index 00000000..5746263c --- /dev/null +++ b/tests/integration/prompt/embeddings_long/embeddings_long_det.jsonnet @@ -0,0 +1,4 @@ +local simple = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +{tags: util.features([['prompt', 'text'], ['nasty-determinism'], ['nondet']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/embeddings_long.py', 'main', [true])])} diff --git a/tests/integration/prompt/simple/simple.0.hash b/tests/integration/prompt/simple/simple.0.hash deleted file mode 100644 index 5a30c2aa..00000000 --- a/tests/integration/prompt/simple/simple.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAB0VowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgyIQAgAAIyB7CiMgICAiU2VxIjogWwojICAgICB7ICJEZXBlbmRzIjogInB5LWxpYi1nZW5sYXllci1lbWJlZGRpbmdzOnRlc3QiIH0sCiMgICAgIHsgIkRlcGVuZHMiOiAicHktZ2VubGF5ZXI6dGVzdCIgfQojICAgXQojIH0KCmltcG9ydCBnZW5sYXllciBhcyBnbApmcm9tIGdlbmxheWVyLnR5cGVzIGltcG9ydCAqCmltcG9ydCBnZW5sYXllcl9lbWJlZGRpbmdzIGFzIGdsZQoKCmNsYXNzIENvbnRyYWN0KGdsLmNvbnRyYWN0LkNvbnRyYWN0KToKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBtYWluKHNlbGYsIGRldDogYm9vbCk6CgkJZGVmIG5kX2Jsb2NrKCk6CgkJCWVtYmVkZGluZ3NfZ2VuZXJhdG9yID0gZ2xlLlNlbnRlbmNlVHJhbnNmb3JtZXIoJ2FsbC1NaW5pTE0tTDYtdjInKQoJCQlyZWFsID0gZW1iZWRkaW5nc19nZW5lcmF0b3IoJ3doYXQgaXMgZ2VubGF5ZXI/JykKCQkJcHJpbnQocmVhbC5zdW0oKSkKCgkJaWYgZGV0OgoJCQluZF9ibG9jaygpCgkJZWxzZToKCQkJZ2wuZXFfcHJpbmNpcGxlLnN0cmljdF9lcShuZF9ibG9jaykKAAAAAAAAAAAAAAAABQ== diff --git a/tests/integration/prompt/simple/simple.0_0.hash b/tests/integration/prompt/simple/simple.0_0.hash deleted file mode 100644 index 9942c258..00000000 --- a/tests/integration/prompt/simple/simple.0_0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAAUF diff --git a/tests/integration/prompt/simple/simple_det.0.hash b/tests/integration/prompt/simple/simple_det.0.hash deleted file mode 100644 index 5a30c2aa..00000000 --- a/tests/integration/prompt/simple/simple_det.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAB0VowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgyIQAgAAIyB7CiMgICAiU2VxIjogWwojICAgICB7ICJEZXBlbmRzIjogInB5LWxpYi1nZW5sYXllci1lbWJlZGRpbmdzOnRlc3QiIH0sCiMgICAgIHsgIkRlcGVuZHMiOiAicHktZ2VubGF5ZXI6dGVzdCIgfQojICAgXQojIH0KCmltcG9ydCBnZW5sYXllciBhcyBnbApmcm9tIGdlbmxheWVyLnR5cGVzIGltcG9ydCAqCmltcG9ydCBnZW5sYXllcl9lbWJlZGRpbmdzIGFzIGdsZQoKCmNsYXNzIENvbnRyYWN0KGdsLmNvbnRyYWN0LkNvbnRyYWN0KToKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBtYWluKHNlbGYsIGRldDogYm9vbCk6CgkJZGVmIG5kX2Jsb2NrKCk6CgkJCWVtYmVkZGluZ3NfZ2VuZXJhdG9yID0gZ2xlLlNlbnRlbmNlVHJhbnNmb3JtZXIoJ2FsbC1NaW5pTE0tTDYtdjInKQoJCQlyZWFsID0gZW1iZWRkaW5nc19nZW5lcmF0b3IoJ3doYXQgaXMgZ2VubGF5ZXI/JykKCQkJcHJpbnQocmVhbC5zdW0oKSkKCgkJaWYgZGV0OgoJCQluZF9ibG9jaygpCgkJZWxzZToKCQkJZ2wuZXFfcHJpbmNpcGxlLnN0cmljdF9lcShuZF9ibG9jaykKAAAAAAAAAAAAAAAABQ== diff --git a/tests/integration/prompt/simple/simple_det.0_0.hash b/tests/integration/prompt/simple/simple_det.0_0.hash deleted file mode 100644 index 9942c258..00000000 --- a/tests/integration/prompt/simple/simple_det.0_0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAAUF diff --git a/tests/integration/storage/_hello_world_class/_hello_world_class.0.hash b/tests/integration/storage/_hello_world_class/_hello_world_class.0.hash deleted file mode 100644 index 55b56899..00000000 --- a/tests/integration/storage/_hello_world_class/_hello_world_class.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAACUVowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgxQ7AQAAIyB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KaW1wb3J0IGdlbmxheWVyIGFzIGdsCmZyb20gZ2VubGF5ZXIudHlwZXMgaW1wb3J0ICoKCgpjbGFzcyBDb250cmFjdChnbC5jb250cmFjdC5Db250cmFjdCk6Cgljb3VudGVyOiB1OAoKCWRlZiBfX2luaXRfXyhzZWxmKToKCQlwcmludChmJ2hlbGxvIHdvcmxkIHtzZWxmLmNvdW50ZXJ9JykKCQlzZWxmLmNvdW50ZXIgKz0gMQoKCUBnbC5wdWJsaWMud3JpdGUKCWRlZiBmb28oc2VsZik6CgkJcHJpbnQoZidoZWxsbyB3b3JsZCB7c2VsZi5jb3VudGVyfScpCgkJc2VsZi5jb3VudGVyICs9IDEKABWjAuGuI0Enkt9rapj7fJg5cB+4sCtvx+0CKBD3g0ZJRoXnAAAAAIMCAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF diff --git a/tests/integration/storage/_hello_world_class/_hello_world_class.0_0.hash b/tests/integration/storage/_hello_world_class/_hello_world_class.0_0.hash deleted file mode 100644 index 1383f83c..00000000 --- a/tests/integration/storage/_hello_world_class/_hello_world_class.0_0.hash +++ /dev/null @@ -1 +0,0 @@ -LQEAAA0VowLhriNBJ5Lfa2qY+3yYOXAfuLArb8ftAigQ94NGSUaF5wAAAACDAgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQ== diff --git a/tests/integration/user-error/simple/simple.0.hash b/tests/integration/user-error/simple/simple.0.hash deleted file mode 100644 index b4c91f65..00000000 --- a/tests/integration/user-error/simple/simple.0.hash +++ /dev/null @@ -1 +0,0 @@ -LQmkAW5haCwgSSB3b24ndCBleGVjdXRlAB0VowIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCIbngE/dGCLkpR4YSh7PedsLAdv6Dm3mUdhvZUMwudWYAAAAAgwoEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6AFYwdFrPMBSq8WLpkzBAMCygvvP1b+LXPAoI+CxhCyG54BP3Rgi5KUeGEoez3nbCwHb+g5t5lHYb2VDMLnVmJ6FedFDZ8E6cPY0thWqOnHjfoAgeosEo/ck84t8169YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFaMCugBWMHRazzAUqvFi6ZMwQDAsoL7z9W/i1zwKCPgsYQsAAAAAgw7IAAAAIyB7ICJEZXBlbmRzIjogInB5LWdlbmxheWVyOnRlc3QiIH0KaW1wb3J0IGdlbmxheWVyIGFzIGdsCmZyb20gZ2VubGF5ZXIudHlwZXMgaW1wb3J0ICoKCgpjbGFzcyBDb250cmFjdChnbC5jb250cmFjdC5Db250cmFjdCk6CglkZWYgX19pbml0X18oc2VsZik6CgkJZ2wudm0uVXNlckVycm9yLmltbWVkaWF0ZSgibmFoLCBJIHdvbid0IGV4ZWN1dGUiKQoAAAAAAAAAAAAAAAAAAAAAAAAAAAU= From 8e0f9ba114e1559e03496efc2da2df7783bcc361 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 2 Jul 2026 14:04:21 +0900 Subject: [PATCH 08/25] =?UTF-8?q?chore(hooks):=20register=20commit-msg=20v?= =?UTF-8?q?alidation=20hook=20=F0=9F=94=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .genvm-tool.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.genvm-tool.py b/.genvm-tool.py index 1773d0f2..d885c471 100644 --- a/.genvm-tool.py +++ b/.genvm-tool.py @@ -5,8 +5,10 @@ The umbrella test suite lives in the manager's ``.genvm-tool.py``. """ +import genvm_tool -def hooks(ctx): + +def hooks(ctx: 'genvm_tool.common.Context'): """Executor commit-hook definitions (was support/nix/precommit/hooks.toml). Tools resolve from the sibling flake's buildEnv (``nix = ""``); @@ -125,9 +127,33 @@ def hooks(ctx): 'files': r'^runners/support/versions/dev-mode\.nix$', 'pass_filenames': False, }, + # --- commit-msg ---------------------------------------------------- + { + 'id': 'check-commit-message', + 'local': True, + 'entry': ctx.python_command[0], + 'args': [ + *ctx.python_command[1:], + str(ctx.root / 'support/scripts/check-commit-message.py'), + '--message-file', + ], + 'stages': ['commit-msg'], + 'pass_filenames': False, + }, ] +def integration(): + """Per-executor integration test configuration. + + Returned dict is merged with the harness defaults; keys: + ``ignore-hash`` — skip hash comparison for all tests in this executor line. + """ + return { + 'ignore-hash': True, + } + + def configure(line): """Per-line build configuration for `genvm-tool configure`. From 5906261c203b0c65d38343ba818de6c0ad765b67 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 2 Jul 2026 19:10:04 +0900 Subject: [PATCH 09/25] =?UTF-8?q?feat(fees):=20add=20multi-bucket=20tracki?= =?UTF-8?q?ng=20and=20restructure=20error=20taxonomy=20=E2=9C=A8=E2=99=BB?= =?UTF-8?q?=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/Cargo.lock | 1 + executor/codegen/data/host-fns.json | 13 +- executor/codegen/data/public-abi.json | 58 +++--- executor/crates/common/Cargo.lock | 22 +++ executor/crates/common/Cargo.toml | 1 + executor/crates/common/src/expr/evaluator.rs | 172 ++++++++++-------- executor/crates/common/src/expr/value.rs | 47 ++--- executor/crates/common/src/host_fns.rs | 30 ++- executor/crates/sdk-rs/src/abi/consts.rs | 95 +++++----- executor/install/config/genvm.yaml | 56 ++++-- executor/src/exe/run.rs | 2 +- executor/src/host/mod.rs | 25 ++- executor/src/rt/errors.rs | 119 ++++++++++-- executor/src/rt/fees.rs | 104 ++++++----- executor/src/rt/memlimiter.rs | 4 +- executor/src/rt/supervisor/actions.rs | 8 +- executor/src/rt/supervisor/mod.rs | 4 +- executor/src/rt/vm/storage.rs | 10 +- executor/src/runners/cache.rs | 8 +- executor/src/wasi/genlayer_sdk.rs | 89 +++++---- executor/src/wasi/vfs.rs | 15 +- .../genlayer-py-std/src/genlayer/__init__.py | 7 - .../src/genlayer/vm/__init__.py | 23 ++- .../src/genlayer/vm/public_abi.py | 155 +++++++++++----- .../sandbox_overspend.0.stdout | 3 +- .../sandbox_overspend/sandbox_overspend.py | 5 +- .../sandbox_overspend_2.py | 5 +- .../bench/read_tree_map/read_tree_map.py | 4 +- tests/integration/exploit/flt/flt.0.stdout | 2 +- .../exploit/fork_bomb/fork_bomb.0.stdout | 2 +- tests/integration/exploit/rec/rec.0.stdout | 2 +- .../exploit/rec_1024/rec_1024.0.stdout | 2 +- .../storage_page_limit.0_1.stdout | 2 +- .../storage_page_limit.jsonnet | 4 +- .../subtract_on_start_underflow.0.stdout | 2 +- .../subtract_on_start_underflow.jsonnet | 2 +- .../exploit/unreachable/unreachable.0.stdout | 2 +- .../call_view_committed.py | 2 +- .../eth_call_view/eth_call_view.0.stdout | 2 +- tests/integration/misc/formats/formats.py | 4 +- tests/integration/misc/issue_163/issue_163.py | 2 +- .../code_slot_exec/code_slot_exec.jsonnet | 2 +- .../code_slot_redirect.jsonnet | 2 +- .../major_mismatch/major_mismatch.jsonnet | 2 +- .../integration/sandbox/det/s/exit.0_0.stdout | 2 +- .../sandbox/det/s/sandbox.0_0.stdout | 2 +- .../sandbox/non-det/s/exit.0_0.stdout | 2 +- tests/integration/storage/gvm-89/gvm-89.py | 4 +- .../integration/storage/persists/persists.py | 2 +- .../storage_tree_map/storage_tree_map.py | 2 +- .../storage/store_proxy/store_proxy.py | 2 +- .../tree_map_nested/tree_map_nested.py | 2 +- 52 files changed, 734 insertions(+), 405 deletions(-) diff --git a/executor/Cargo.lock b/executor/Cargo.lock index 24ff74b6..40e1f161 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -1071,6 +1071,7 @@ dependencies = [ "serde_json", "serde_yaml", "symbol_table", + "thiserror 2.0.18", "tokio", ] diff --git a/executor/codegen/data/host-fns.json b/executor/codegen/data/host-fns.json index ef96e7f4..ca07a54b 100644 --- a/executor/codegen/data/host-fns.json +++ b/executor/codegen/data/host-fns.json @@ -20,11 +20,18 @@ "name": "errors", "values": { "ok": 0, - "absent": 1, - "forbidden": 2, - "out_of_storage_gas": 3 + "evm_reverted": 1, + "forbidden": 2 } }, + { + "type": "str_trie", + "name": "vm_error_detail", + "values": [ + "internal", + "external" + ] + }, { "type": "const", "name": "current_major", diff --git a/executor/codegen/data/public-abi.json b/executor/codegen/data/public-abi.json index ad7993c6..ee385c92 100644 --- a/executor/codegen/data/public-abi.json +++ b/executor/codegen/data/public-abi.json @@ -88,34 +88,54 @@ }, { "head": "wasm_trap", - "tail": "$str" + "tail": [ + null, + "unreachable", + "stack_overflow", + "memory_out_of_bounds", + "table_out_of_bounds", + "indirect_call_to_null", + "bad_signature", + "integer_overflow", + "integer_divide_by_zero", + "bad_conversion_to_integer", + "heap_misaligned", + "atomic_wait_non_shared_memory", + "out_of_fuel", + "interrupt", + "nondet_instruction", + "fault" + ] }, { - "head": "OOM", + "head": "out_of", "tail": [ { - "head": "RAM", - "tail": [ - [], "table", "memory", "limit" - ] + "head": "memory", + "tail": [null, "wasm_memory", "wasm_table"] }, "storage", { "head": "receipt", - "tail": [ - "nondet_output", - { - "head": "message", - "tail": ["internal", "external"] - } - ] + "tail": ["nondet_output", "message", "event"] }, - { - "head": "fees", - "tail": ["internal", "external"] - } + "message_fee", + "vm_recursion", + "nondet_blocks", + "locked_slots", + "upgraders", + "fds" ] }, + { + "head": "fee", + "tail": ["no_matching_node", "below_minimal"] + }, + "host_forbidden", + { + "head": "evm", + "tail": ["reverted"] + }, { "head": "invalid_contract", "tail": [ @@ -129,10 +149,6 @@ "tail": ["validating", "linking", "entrypoint"] } ] - }, - { - "head": "host", - "tail": "$str" } ] }, diff --git a/executor/crates/common/Cargo.lock b/executor/crates/common/Cargo.lock index 68c55320..e8047774 100644 --- a/executor/crates/common/Cargo.lock +++ b/executor/crates/common/Cargo.lock @@ -537,9 +537,11 @@ dependencies = [ "rpds", "rustix", "serde", + "serde_bytes", "serde_json", "serde_yaml", "symbol_table", + "thiserror", "tokio", ] @@ -1156,6 +1158,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.45.1" diff --git a/executor/crates/common/Cargo.toml b/executor/crates/common/Cargo.toml index 4e8e3051..761f7783 100644 --- a/executor/crates/common/Cargo.toml +++ b/executor/crates/common/Cargo.toml @@ -42,6 +42,7 @@ serde_bytes = "0.11.17" serde_json = "1.0.140" serde_yaml = "0.9.34" symbol_table = { version = "0.4.0", features = ["global"] } +thiserror = "2.0.18" tokio = { version = "1.44.1", features = [ "rt", "rt-multi-thread", diff --git a/executor/crates/common/src/expr/evaluator.rs b/executor/crates/common/src/expr/evaluator.rs index 2909317b..538a3c0d 100644 --- a/executor/crates/common/src/expr/evaluator.rs +++ b/executor/crates/common/src/expr/evaluator.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use num_rational::BigRational; use num_traits::{ToPrimitive, Zero}; +use crate::expr::value::HostFnImpl; + use super::value::{BinOp, EvalError, Expr, StrSeg, Thunk, Value}; // SAFETY: this evaluator has no recursion depth or fuel limits. @@ -135,7 +137,7 @@ fn eval(expr: &Expr, ctx: &EvalContext) -> Result { obj.as_object()? .get(key) .cloned() - .ok_or_else(|| EvalError::Custom(format!("object has no key `{key}`"))) + .ok_or_else(|| EvalError::ObjectKeyNotFound(key.clone())) } } } @@ -166,82 +168,106 @@ fn apply(f: &Value, arg: Thunk, ctx: &EvalContext) -> Result { } } -fn resolve_builtin(name: &str) -> Option { - match name { - "floor" => Some(Value::HostFn(Arc::new(|arg: &Value| { - Ok(Value::Rational(arg.clone().into_rational()?.floor())) - }))), - "toString" => Some(Value::HostFn(Arc::new(|arg: &Value| { - Ok(Value::Str(stringify(arg).into())) - }))), - "hasKey" => Some(Value::HostFn(Arc::new(|obj: &Value| { - let obj = obj.as_object()?.clone(); - Ok(Value::HostFn(Arc::new(move |key: &Value| { - Ok(Value::Bool(obj.contains_key(key.as_str()?))) - }))) - }))), - "arrayLen" => Some(Value::HostFn(Arc::new(|arg: &Value| match arg { - Value::Array(elems) => Ok(Value::Rational(BigRational::from_integer( - elems.len().into(), - ))), - other => Err(EvalError::TypeError { - expected: "array", - got: other.typ(), - }), - }))), - "arrayGetElem" => Some(Value::HostFn(Arc::new(|arr: &Value| match arr { - Value::Array(elems) => { - let elems = elems.clone(); - Ok(Value::HostFn(Arc::new(move |idx: &Value| { - let i = idx - .clone() - .into_rational()? - .to_integer() - .to_usize() - .ok_or_else(|| { - EvalError::Custom(format!("array index out of range: {idx}")) - })?; - elems.get(i).cloned().ok_or_else(|| { - EvalError::Custom(format!( - "array index {i} out of bounds for length {}", - elems.len() - )) - }) +static BUILTINS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| { + let mut m = std::collections::HashMap::<_, Arc>::new(); + m.insert( + "floor", + Arc::new(|arg: &Value| Ok(Value::Rational(arg.clone().into_rational()?.floor()))), + ); + m.insert( + "toString", + Arc::new(|arg: &Value| Ok(Value::Str(stringify(arg).into()))), + ); + m.insert( + "hasKey", + Arc::new(|obj: &Value| { + let obj = obj.as_object()?.clone(); + Ok(Value::HostFn(Arc::new(move |key: &Value| { + Ok(Value::Bool(obj.contains_key(key.as_str()?))) }))) - } - other => Err(EvalError::TypeError { - expected: "array", - got: other.typ(), }), - }))), - "pow" => Some(Value::HostFn(Arc::new(|base: &Value| { - let base = base.clone().into_rational()?; - Ok(Value::HostFn(Arc::new(move |exp: &Value| { - let exp = exp.clone().into_rational()?; - if !exp.is_integer() { - return Err(EvalError::Custom(format!( - "pow exponent must be an integer, got {exp}" - ))); + ); + m.insert( + "arrayLen", + Arc::new(|arg: &Value| match arg { + Value::Array(elems) => Ok(Value::Rational(BigRational::from_integer( + elems.len().into(), + ))), + other => Err(EvalError::TypeError { + expected: "array", + got: other.typ(), + }), + }), + ); + m.insert( + "vmError", + Arc::new(|arg: &Value| { + let msg = arg.as_str()?.to_string(); + Err(EvalError::ScriptVMError(msg)) + }), + ); + m.insert( + "arrayGetElem", + Arc::new(|arr: &Value| match arr { + Value::Array(elems) => { + let elems = elems.clone(); + Ok(Value::HostFn(Arc::new(move |idx: &Value| { + let i = idx.clone().into_rational()?; + if !i.is_integer() { + return Err(EvalError::IsFractional); + } + let i = i.to_integer().to_usize().unwrap_or(usize::MAX); + if i >= elems.len() { + return Err(EvalError::ArrayIndexOutOfBounds { + index: i, + len: elems.len(), + }); + } + Ok(elems[i].clone()) + }))) } - let exp = exp.to_integer(); - let negative = exp < num_bigint::BigInt::ZERO; - let exp: u64 = exp - .magnitude() - .try_into() - .map_err(|_| EvalError::Custom("pow exponent too large".to_owned()))?; - let result = num_traits::pow::pow(base.clone(), exp as usize); - if negative { - if result.is_zero() { - return Err(EvalError::DivisionByZero); + other => Err(EvalError::TypeError { + expected: "array", + got: other.typ(), + }), + }), + ); + m.insert( + "pow", + Arc::new(|base: &Value| { + let base = base.clone().into_rational()?; + Ok(Value::HostFn(Arc::new(move |exp: &Value| { + let exp = exp.clone().into_rational()?; + if !exp.is_integer() { + return Err(EvalError::TypeError { + expected: "integer", + got: "fraction", + }); } - Ok(Value::Rational(result.recip())) - } else { - Ok(Value::Rational(result)) - } - }))) - }))), - _ => None, - } + let exp = exp.to_integer(); + let negative = exp < num_bigint::BigInt::ZERO; + let exp: u64 = exp + .magnitude() + .try_into() + .map_err(|_| EvalError::ExponentTooLarge)?; + let result = num_traits::pow::pow(base.clone(), exp as usize); + if negative { + if result.is_zero() { + return Err(EvalError::DivisionByZero); + } + Ok(Value::Rational(result.recip())) + } else { + Ok(Value::Rational(result)) + } + }))) + }), + ); + m + }); + +fn resolve_builtin(name: &str) -> Option { + BUILTINS.get(name).map(|f| Value::HostFn(f.clone())) } impl Value { diff --git a/executor/crates/common/src/expr/value.rs b/executor/crates/common/src/expr/value.rs index 680f7bc1..b86738d9 100644 --- a/executor/crates/common/src/expr/value.rs +++ b/executor/crates/common/src/expr/value.rs @@ -30,41 +30,34 @@ impl fmt::Display for ParseError { impl std::error::Error for ParseError {} -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum EvalError { + #[error("script VM error: {0}")] + ScriptVMError(String), + + #[error("undefined variable `{0}`")] UndefinedVariable(String), + #[error("division by zero")] DivisionByZero, + #[error("infinite recursion while forcing a lazy value (tying the knot?)")] + InfiniteRecursion, + #[error("exponent too large")] + ExponentTooLarge, + #[error("object key not found: `{0}`")] + ObjectKeyNotFound(String), + #[error("array index out of bounds: index {index}, length {len}")] + ArrayIndexOutOfBounds { index: usize, len: usize }, + #[error("type error: expected integer, got fractional")] + IsFractional, + #[error("type error: expected {expected}, got {got}")] TypeError { expected: &'static str, got: &'static str, }, - Custom(String), + #[error(transparent)] Dyn(Box), } -impl fmt::Display for EvalError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - EvalError::UndefinedVariable(name) => write!(f, "undefined variable `{name}`"), - EvalError::DivisionByZero => write!(f, "division by zero"), - EvalError::TypeError { expected, got } => { - write!(f, "type error: expected {expected}, got {got}") - } - EvalError::Custom(msg) => write!(f, "{msg}"), - EvalError::Dyn(e) => write!(f, "{e}"), - } - } -} - -impl std::error::Error for EvalError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - EvalError::Dyn(e) => Some(&**e), - _ => None, - } - } -} - #[derive(Debug, Clone)] pub enum Expr { Ident(String), @@ -197,9 +190,7 @@ impl Thunk { return Ok(v); } ThunkState::InProgress => { - return Err(EvalError::Custom( - "infinite recursion while forcing a lazy value".to_owned(), - )); + return Err(EvalError::InfiniteRecursion); } ThunkState::Deferred(f) => f, } diff --git a/executor/crates/common/src/host_fns.rs b/executor/crates/common/src/host_fns.rs index 1f08621c..d52f4804 100644 --- a/executor/crates/common/src/host_fns.rs +++ b/executor/crates/common/src/host_fns.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + #[derive( Debug, PartialEq, @@ -84,27 +86,24 @@ impl TryFrom for Methods { #[repr(u8)] pub enum Errors { Ok = 0, - Absent = 1, + EvmReverted = 1, Forbidden = 2, - OutOfStorageGas = 3, } impl Errors { - pub const SIZE: usize = 4; + pub const SIZE: usize = 3; pub fn value(self) -> u8 { match self { Errors::Ok => 0, - Errors::Absent => 1, + Errors::EvmReverted => 1, Errors::Forbidden => 2, - Errors::OutOfStorageGas => 3, } } pub fn str_snake_case(self) -> &'static str { match self { Errors::Ok => "ok", - Errors::Absent => "absent", + Errors::EvmReverted => "evm_reverted", Errors::Forbidden => "forbidden", - Errors::OutOfStorageGas => "out_of_storage_gas", } } } @@ -115,12 +114,25 @@ impl TryFrom for Errors { fn try_from(value: u8) -> Result { match value { 0 => Ok(Errors::Ok), - 1 => Ok(Errors::Absent), + 1 => Ok(Errors::EvmReverted), 2 => Ok(Errors::Forbidden), - 3 => Ok(Errors::OutOfStorageGas), _ => Err(()), } } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VmErrorDetail(pub Cow<'static, str>); + +impl From for String { + fn from(val: VmErrorDetail) -> String { + val.0.into() + } +} +#[rustfmt::skip] +impl VmErrorDetail { + pub const fn internal() -> Self { Self(Cow::Borrowed("internal")) } + pub const fn external() -> Self { Self(Cow::Borrowed("external")) } +} + pub const CURRENT_MAJOR: u8 = 0; pub const CURRENT_MAJOR_STR: &'static str = "v0.0.0"; diff --git a/executor/crates/sdk-rs/src/abi/consts.rs b/executor/crates/sdk-rs/src/abi/consts.rs index 7d84c786..2f4e6e58 100644 --- a/executor/crates/sdk-rs/src/abi/consts.rs +++ b/executor/crates/sdk-rs/src/abi/consts.rs @@ -223,43 +223,68 @@ pub mod __VmError { use std::borrow::Cow; use super::VmError; - pub struct OomRam; + pub struct WasmTrap; - impl OomRam { - pub const fn val(&self) -> VmError { VmError(Cow::Borrowed("OOM RAM")) } - pub const fn table(&self) -> VmError { VmError(Cow::Borrowed("OOM RAM table")) } - pub const fn memory(&self) -> VmError { VmError(Cow::Borrowed("OOM RAM memory")) } - pub const fn limit(&self) -> VmError { VmError(Cow::Borrowed("OOM RAM limit")) } + impl WasmTrap { + pub const fn val(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap")) } + pub const fn unreachable(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap unreachable")) } + pub const fn stack_overflow(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap stack_overflow")) } + pub const fn memory_out_of_bounds(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap memory_out_of_bounds")) } + pub const fn table_out_of_bounds(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap table_out_of_bounds")) } + pub const fn indirect_call_to_null(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap indirect_call_to_null")) } + pub const fn bad_signature(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap bad_signature")) } + pub const fn integer_overflow(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap integer_overflow")) } + pub const fn integer_divide_by_zero(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap integer_divide_by_zero")) } + pub const fn bad_conversion_to_integer(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap bad_conversion_to_integer")) } + pub const fn heap_misaligned(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap heap_misaligned")) } + pub const fn atomic_wait_non_shared_memory(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap atomic_wait_non_shared_memory")) } + pub const fn out_of_fuel(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap out_of_fuel")) } + pub const fn interrupt(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap interrupt")) } + pub const fn nondet_instruction(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap nondet_instruction")) } + pub const fn fault(&self) -> VmError { VmError(Cow::Borrowed("wasm_trap fault")) } } - pub struct OomReceiptMessage; + pub struct OutOfMemory; - impl OomReceiptMessage { - pub const fn internal(&self) -> VmError { VmError(Cow::Borrowed("OOM receipt message internal")) } - pub const fn external(&self) -> VmError { VmError(Cow::Borrowed("OOM receipt message external")) } + impl OutOfMemory { + pub const fn val(&self) -> VmError { VmError(Cow::Borrowed("out_of memory")) } + pub const fn wasm_memory(&self) -> VmError { VmError(Cow::Borrowed("out_of memory wasm_memory")) } + pub const fn wasm_table(&self) -> VmError { VmError(Cow::Borrowed("out_of memory wasm_table")) } } - pub struct OomReceipt; + pub struct OutOfReceipt; + + impl OutOfReceipt { + pub const fn nondet_output(&self) -> VmError { VmError(Cow::Borrowed("out_of receipt nondet_output")) } + pub const fn message(&self) -> VmError { VmError(Cow::Borrowed("out_of receipt message")) } + pub const fn event(&self) -> VmError { VmError(Cow::Borrowed("out_of receipt event")) } + } - impl OomReceipt { - pub const fn nondet_output(&self) -> VmError { VmError(Cow::Borrowed("OOM receipt nondet_output")) } - pub const fn message(&self) -> OomReceiptMessage { OomReceiptMessage } + pub struct OutOf; + + impl OutOf { + pub const fn storage(&self) -> VmError { VmError(Cow::Borrowed("out_of storage")) } + pub const fn message_fee(&self) -> VmError { VmError(Cow::Borrowed("out_of message_fee")) } + pub const fn vm_recursion(&self) -> VmError { VmError(Cow::Borrowed("out_of vm_recursion")) } + pub const fn nondet_blocks(&self) -> VmError { VmError(Cow::Borrowed("out_of nondet_blocks")) } + pub const fn locked_slots(&self) -> VmError { VmError(Cow::Borrowed("out_of locked_slots")) } + pub const fn upgraders(&self) -> VmError { VmError(Cow::Borrowed("out_of upgraders")) } + pub const fn fds(&self) -> VmError { VmError(Cow::Borrowed("out_of fds")) } + pub const fn memory(&self) -> OutOfMemory { OutOfMemory } + pub const fn receipt(&self) -> OutOfReceipt { OutOfReceipt } } - pub struct OomFees; + pub struct Fee; - impl OomFees { - pub const fn internal(&self) -> VmError { VmError(Cow::Borrowed("OOM fees internal")) } - pub const fn external(&self) -> VmError { VmError(Cow::Borrowed("OOM fees external")) } + impl Fee { + pub const fn no_matching_node(&self) -> VmError { VmError(Cow::Borrowed("fee no_matching_node")) } + pub const fn below_minimal(&self) -> VmError { VmError(Cow::Borrowed("fee below_minimal")) } } - pub struct Oom; + pub struct Evm; - impl Oom { - pub const fn storage(&self) -> VmError { VmError(Cow::Borrowed("OOM storage")) } - pub const fn ram(&self) -> OomRam { OomRam } - pub const fn receipt(&self) -> OomReceipt { OomReceipt } - pub const fn fees(&self) -> OomFees { OomFees } + impl Evm { + pub const fn reverted(&self) -> VmError { VmError(Cow::Borrowed("evm reverted")) } } pub struct InvalidContractWasm; @@ -289,22 +314,6 @@ pub mod __VmError { } } - pub struct WasmTrap; - - impl WasmTrap { - pub fn val_str(&self, v: &str) -> VmError { - VmError(Cow::Owned(format!("wasm_trap {v}"))) - } - } - - pub struct Host; - - impl Host { - pub fn val_str(&self, v: &str) -> VmError { - VmError(Cow::Owned(format!("host {v}"))) - } - } - } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -319,11 +328,13 @@ impl From for String { impl VmError { pub const fn timeout() -> Self { Self(Cow::Borrowed("timeout")) } pub const fn absent_leader_nondet_output() -> Self { Self(Cow::Borrowed("absent_leader_nondet_output")) } + pub const fn host_forbidden() -> Self { Self(Cow::Borrowed("host_forbidden")) } pub const fn exit_code() -> __VmError::ExitCode { __VmError::ExitCode } pub const fn wasm_trap() -> __VmError::WasmTrap { __VmError::WasmTrap } - pub const fn oom() -> __VmError::Oom { __VmError::Oom } + pub const fn out_of() -> __VmError::OutOf { __VmError::OutOf } + pub const fn fee() -> __VmError::Fee { __VmError::Fee } + pub const fn evm() -> __VmError::Evm { __VmError::Evm } pub const fn invalid_contract() -> __VmError::InvalidContract { __VmError::InvalidContract } - pub const fn host() -> __VmError::Host { __VmError::Host } } pub const EVENT_MAX_TOPICS: u32 = 4; diff --git a/executor/install/config/genvm.yaml b/executor/install/config/genvm.yaml index cce110fb..9cbd2f2d 100644 --- a/executor/install/config/genvm.yaml +++ b/executor/install/config/genvm.yaml @@ -9,18 +9,31 @@ modules: log_level: info fees: + # Host bucket indices (bucket_totals[] supplied by node/consensus): + # 0 - execution_data_gas budget (shared by storage, message_receipt, nondet_output, event) + # 1 - message_fee budget + # 2 - eq_outputs byte cap (node.maxEqOutputsBytes; enforced via nondet_output) + # 3 - submitted_messages byte cap (node.maxSubmittedMessagesBytes; enforced via message_receipt) + # + # # Required host-provided `node` fields (from gas_data): - # node.storageUnitPrice - price per storage page - # node.receiptGasPerByte - price per receipt/output byte - # node.gasPerChangedSlot - price per changed storage slot - # node.intrinsicGas - per-tx intrinsic gas - # node.bootloaderOverhead - per-tx bootloader overhead - # node.fixedProposeReceiptGas - fixed cost of a propose receipt - # node.fixedMessageRevealGas - fixed cost of a message reveal - # node.genPerTimeUnit - GEN per time unit (0 disables the time term) + # node.storageUnitPrice - price per storage page + # node.receiptGasPerByte - price per receipt/output byte + # node.gasPerChangedSlot - price per changed storage slot + # node.intrinsicGas - per-tx intrinsic gas + # node.bootloaderOverhead - per-tx bootloader overhead + # node.fixedProposeReceiptGas - fixed cost of a propose receipt + # node.fixedMessageRevealGas - fixed cost of a message reveal + # node.genPerTimeUnit - GEN per time unit (0 disables the time term) + # node.maxEqOutputsBytes - hard byte cap for eqBlocksOutputs (bucket 2) + # node.maxSubmittedMessagesBytes - hard byte cap for SubmittedMessage[] (bucket 3) # Optional: - # node.validatorsPerRound[] - validator count per round - # (defaults to defaultValidatorsPerRound below) + # node.validatorsPerRound[] - validator count per round + # (defaults to defaultValidatorsPerRound below) + # + # GenVM consumption points can consume multiple buckets, but as a fee only first is reported to the node + # i.e. message_receipt consumes from execution_data_gas & submitted_messages + # to message only execution_data_gas gets attached expr_prelude: | let Y = \f = (\x = f (\v = x x v)) (\x = f (\v = x x v)) in let fold = \arr acc fn = @@ -89,16 +102,18 @@ fees: delta_expr: | \a = a.pages * node.storageUnitPrice message_receipt: - bucket_no: 0 + # bucket 0: gas cost; bucket 3: canonical ABI byte size of this SubmittedMessage + bucket_no: [0, 3] subtract_on_start_expr: | - node.fixedProposeReceiptGas # for propose + [node.fixedProposeReceiptGas # for propose + node.intrinsicGas + node.bootloaderOverhead + 7 * node.gasPerChangedSlot + node.fixedMessageRevealGas # for reveal + node.intrinsicGas + node.bootloaderOverhead - + 32 * node.gasPerChangedSlot # for 32 bytes of length + + 32 * node.gasPerChangedSlot, # for 32 bytes of length + 0] delta_expr: | \a = # each `bytes` field is indirection + length + data, rounded up to 32 bytes @@ -108,13 +123,20 @@ fees: let subtreeBytes = 32 + 32 + ceilDiv a.subtreeLength 32 * 32 in # deployed contract code carried in the receipt (deploys only) let codeBytes = if a.isDeploy then 32 + 32 + ceilDiv a.codeLength 32 * 32 else 0 in - (calldataBytes + subtreeBytes + codeBytes) * node.receiptGasPerByte + 1 * node.gasPerChangedSlot + let abiBytes = calldataBytes + subtreeBytes + codeBytes in + [(calldataBytes + subtreeBytes + codeBytes) * node.receiptGasPerByte + 1 * node.gasPerChangedSlot, + abiBytes] nondet_output: - bucket_no: 0 + # bucket 0: gas cost; bucket 2: raw output byte count + bucket_no: [0, 2] subtract_on_start_expr: | - 32 * node.receiptGasPerByte # for 32 bytes of length + [32 * node.receiptGasPerByte, # for 32 bytes of length + 0] delta_expr: | - \a = (32 + 32 + ceilDiv a.outputLength 32 * 32) * node.receiptGasPerByte # bytes are indirection + length + data, rounded up to 32 bytes + \a = + # bytes are indirection + length + data, rounded up to 32 bytes + let abiBytes = 32 + 32 + ceilDiv a.outputLength 32 * 32 in + [abiBytes * node.receiptGasPerByte, a.outputLength] message_fee: bucket_no: 1 subtract_on_start_expr: | diff --git a/executor/src/exe/run.rs b/executor/src/exe/run.rs index 2046c730..9ceac5ee 100644 --- a/executor/src/exe/run.rs +++ b/executor/src/exe/run.rs @@ -199,7 +199,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { // OOM code if that ever stops holding rather than panicking during setup. let insufficient_run_ok = insufficient_err.into_run_ok().unwrap_or_else(|e| { genvm::rt::vm::RunOk::VMError( - genvm::public_abi::VmError::oom().ram().val(), + genvm::public_abi::VmError::out_of().memory().val(), Some(anyhow::Error::new(e)), ) }); diff --git a/executor/src/host/mod.rs b/executor/src/host/mod.rs index 01360777..dba91941 100644 --- a/executor/src/host/mod.rs +++ b/executor/src/host/mod.rs @@ -99,10 +99,17 @@ fn read_host_error(sock: &mut dyn Sock, context: &str) -> Result Result<()> { let e = read_host_error(sock, context)?; - if e == host_fns::Errors::Ok { - Ok(()) - } else { - Err(rt::errors::Error::vm(abi::consts::VmError::host().val_str(e.str_snake_case())).into()) + match e { + host_fns::Errors::Ok => Ok(()), + host_fns::Errors::EvmReverted => { + Err(rt::errors::Error::vm(abi::consts::VmError::evm().reverted()).into()) + } + // Reserved for gen_call-class host methods (e.g. eth_call) refused in + // the current execution context; it must not occur during on-chain + // consensus execution. + host_fns::Errors::Forbidden => { + Err(rt::errors::Error::vm(abi::consts::VmError::host_forbidden()).into()) + } } } @@ -301,11 +308,15 @@ impl Host { let len = u32::from_le_bytes(len_buf); if len > abi::consts::top_limits::LOCKED_SLOTS { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().limit()).into()); + return Err( + rt::errors::Error::vm(abi::consts::VmError::out_of().locked_slots()).into(), + ); } if !limiter.consume_mul(len, SlotID::SIZE) { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().val()).into()); + return Err( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ); } let res = Box::new_uninit_slice(len as usize); @@ -349,7 +360,7 @@ impl Host { let len = u32::from_le_bytes(len_buf); if len > abi::consts::top_limits::UPGRADERS { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().limit()).into()); + return Err(rt::errors::Error::vm(abi::consts::VmError::out_of().upgraders()).into()); } for i in 0..len { diff --git a/executor/src/rt/errors.rs b/executor/src/rt/errors.rs index 4804657f..67618cca 100644 --- a/executor/src/rt/errors.rs +++ b/executor/src/rt/errors.rs @@ -20,23 +20,46 @@ fn recover_executor_error(e: UnwrapDynError) -> std::result::Result abi::consts::VmError { + use wasmtime::Trap; + let t = abi::consts::VmError::wasm_trap(); + match trap { + Trap::UnreachableCodeReached => t.unreachable(), + Trap::StackOverflow => t.stack_overflow(), + Trap::MemoryOutOfBounds => t.memory_out_of_bounds(), + Trap::TableOutOfBounds => t.table_out_of_bounds(), + Trap::IndirectCallToNull => t.indirect_call_to_null(), + Trap::BadSignature => t.bad_signature(), + Trap::IntegerOverflow => t.integer_overflow(), + Trap::IntegerDivisionByZero => t.integer_divide_by_zero(), + Trap::BadConversionToInteger => t.bad_conversion_to_integer(), + Trap::HeapMisaligned => t.heap_misaligned(), + Trap::AtomicWaitNonSharedMemory => t.atomic_wait_non_shared_memory(), + Trap::OutOfFuel => t.out_of_fuel(), + Trap::Interrupt => t.interrupt(), + Trap::NondetInstruction => t.nondet_instruction(), + other => { + log_warn!(trap:? = other; "unexpected wasm trap variant"); + t.val() + } + } +} + #[allow(clippy::manual_try_fold)] pub fn unwrap_vm_errors(err: UnwrapDynError) -> anyhow::Result { let res: std::result::Result = [ |e: UnwrapDynError| { - e.downcast::().map(|v| { - rt::vm::RunOk::VMError( - abi::consts::VmError::wasm_trap().val_str(&format!("{v:?}")), - Some(v.into()), - ) - }) + e.downcast::() + .map(|v| rt::vm::RunOk::VMError(trap_to_vm_error(v), Some(v.into()))) }, |e: UnwrapDynError| { e.downcast::().map(|e| { - rt::vm::RunOk::VMError( - abi::consts::VmError::wasm_trap().val_str("fault"), - Some(e.into()), - ) + rt::vm::RunOk::VMError(abi::consts::VmError::wasm_trap().fault(), Some(e.into())) }) }, |e: UnwrapDynError| recover_executor_error(e), @@ -148,14 +171,34 @@ pub fn unwrap_vm_errors_backtrace( #[derive(Debug)] pub enum ErrorKind { - /// VM-level error carrying a stable code (-> ResultCode::VMError). - Vm(abi::consts::VmError), + /// VM-level error carrying a stable code (-> ResultCode::VMError) and an + /// optional internal detail. The detail refines the public code for + /// diagnostics; it is fused into the consensus string as ` # ` + /// only at the terminal boundary (see [`fuse_vm_error`]). + Vm(abi::consts::VmError, Option), /// User/contract error carrying a calldata value (-> ResultCode::UserError). User(calldata::unparsed::Maybe), /// Generic internal executor error (the bare `anyhow` case). Internal, } +/// Fuse a VM error code with its optional detail into the single +/// consensus-visible string carried by [`rt::vm::RunOk::VMError`]: the bare +/// `` when there is no detail, or ` # ` (separator exactly +/// space-'#'-space) when one is present. +pub fn fuse_vm_error( + code: abi::consts::VmError, + detail: Option, +) -> abi::consts::VmError { + match detail { + None => code, + Some(detail) => abi::consts::VmError(std::borrow::Cow::Owned(format!( + "{} # {}", + code.0, detail.0 + ))), + } +} + /// The executor's error type. Fields after `kind` are common to every error. #[derive(Debug)] pub struct Error { @@ -197,12 +240,30 @@ impl Error { pub fn vm(code: abi::consts::VmError) -> Self { Self { - kind: ErrorKind::Vm(code), + kind: ErrorKind::Vm(code, None), + context: Vec::new(), + source: None, + } + } + + /// A VM error carrying an internal [`host_fns::VmErrorDetail`] alongside its + /// public code (see [`fuse_vm_error`]). + pub fn vm_detailed(code: abi::consts::VmError, detail: host_fns::VmErrorDetail) -> Self { + Self { + kind: ErrorKind::Vm(code, Some(detail)), context: Vec::new(), source: None, } } + /// Attach a detail to a VM error (no-op on non-VM kinds). + pub fn with_detail(mut self, detail: host_fns::VmErrorDetail) -> Self { + if let ErrorKind::Vm(_, d) = &mut self.kind { + *d = Some(detail); + } + self + } + /// Wrap a cause as a VM error with `code`, **keeping the inner terminal /// error if it already is one**: if `cause` already carries a `Vm`/`User` /// kind it is returned unchanged (so the innermost code wins, instead of @@ -211,7 +272,7 @@ impl Error { pub fn wrap(code: abi::consts::VmError, cause: impl Into) -> Self { let mut cause = cause.into(); if !cause.is_vm() && !cause.is_user() { - cause.kind = ErrorKind::Vm(code); + cause.kind = ErrorKind::Vm(code, None); } cause } @@ -250,7 +311,7 @@ impl Error { } pub fn is_vm(&self) -> bool { - matches!(self.kind, ErrorKind::Vm(_)) + matches!(self.kind, ErrorKind::Vm(..)) } /// Convert into the terminal VM result this error represents. `Vm`/`User` @@ -265,7 +326,10 @@ impl Error { source, } = self; match kind { - ErrorKind::Vm(code) => Ok(rt::vm::RunOk::VMError(code, fold_cause(context, source))), + ErrorKind::Vm(code, detail) => Ok(rt::vm::RunOk::VMError( + fuse_vm_error(code, detail), + fold_cause(context, source), + )), ErrorKind::User(v) => Ok(rt::vm::RunOk::UserError(v)), ErrorKind::Internal => Err(Error { kind: ErrorKind::Internal, @@ -279,7 +343,8 @@ impl Error { impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.kind { - ErrorKind::Vm(c) => write!(f, "VMError({})", c.0)?, + ErrorKind::Vm(c, None) => write!(f, "VMError({})", c.0)?, + ErrorKind::Vm(c, Some(d)) => write!(f, "VMError({} # {})", c.0, d.0)?, ErrorKind::User(v) => write!(f, "UserError({v:?})")?, ErrorKind::Internal => f.write_str("internal error")?, } @@ -353,6 +418,26 @@ internal_from!( wasmparser::BinaryReaderError, ); +/// Create an [`Error`] with [`ErrorKind::Internal`] from a format string. +#[macro_export] +macro_rules! internal { + ($($arg:tt)*) => { + $crate::rt::errors::Error::internal(::std::format!($($arg)*)) + }; +} +pub use internal; + +/// Return early with an [`ErrorKind::Internal`] error if a condition is false. +#[macro_export] +macro_rules! internal_ensure { + ($cond:expr, $($arg:tt)*) => { + if !($cond) { + return ::std::result::Result::Err($crate::internal!($($arg)*)); + } + }; +} +pub use internal_ensure; + /// Attach context to a `Result>`. Named `ctx`/`with_ctx` /// (not `context`) to avoid colliding with `anyhow::Context` while both are in /// scope during the migration. diff --git a/executor/src/rt/fees.rs b/executor/src/rt/fees.rs index 1eb95ce0..6fb92ea6 100644 --- a/executor/src/rt/fees.rs +++ b/executor/src/rt/fees.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use anyhow::Context; use genvm_common::*; use crate::rt; +pub use crate::rt::errors::ResultExt as _; use genlayer_sdk::abi; /// Builds a numeric expression value. @@ -85,18 +85,20 @@ pub fn fee_params_value_external( Value::Object(Arc::new(m)) } -fn rational_to_u256(rational: num_rational::BigRational) -> anyhow::Result { - anyhow::ensure!( +fn rational_to_u256( + rational: num_rational::BigRational, +) -> rt::errors::Result { + rt::errors::internal_ensure!( rational.is_integer(), "fee expression must yield an integer, got {rational}" ); let int = rational.to_integer(); let (sign, bytes) = int.to_bytes_be(); - anyhow::ensure!( + rt::errors::internal_ensure!( sign != num_bigint::Sign::Minus, "fee cost must be non-negative" ); - anyhow::ensure!(bytes.len() <= 32, "fee cost exceeds U256 range"); + rt::errors::internal_ensure!(bytes.len() <= 32, "fee cost exceeds U256 range"); let mut buf = [0u8; 32]; buf[32 - bytes.len()..].copy_from_slice(&bytes); Ok(primitive_types::U256::from_big_endian(&buf)) @@ -105,29 +107,30 @@ fn rational_to_u256(rational: num_rational::BigRational) -> anyhow::Result anyhow::Result> { +) -> rt::errors::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!( + rt::errors::internal_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}"))?; + let r = v.clone().into_rational().map_err(|e| { + rt::errors::internal!("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:?}",), + other => Err(rt::errors::internal!( + "fee expression must yield a number or array, got {other:?}", + )), } } @@ -139,15 +142,15 @@ fn eval_with_node( label: &str, code: &str, node: &genvm_common::expr::Value, -) -> anyhow::Result { +) -> rt::errors::Result { let src = format!("\\node = {prelude}\n{code}"); let lambda = genvm_common::expr::Expr::parse(&src) - .map_err(|e| anyhow::anyhow!("parsing {label} fee expression `{code}`: {e}"))? + .map_err(|e| rt::errors::internal!("parsing {label} fee expression `{code}`: {e}"))? .evaluate() - .map_err(|e| anyhow::anyhow!("evaluating {label} fee expression: {e}"))?; + .map_err(|e| rt::errors::internal!("evaluating {label} fee expression: {e}"))?; lambda .apply_with(node.clone(), no_free_vars) - .map_err(|e| anyhow::anyhow!("binding `node` in {label} fee expression: {e}")) + .map_err(|e| rt::errors::internal!("binding `node` in {label} fee expression: {e}")) } /// A fee bucket: total = `subtract_on_start` (charged once by @@ -181,9 +184,9 @@ fn build_bucket( cfg: &crate::config::FeesBucketConfig, node: &genvm_common::expr::Value, oom_error: abi::consts::VmError, -) -> anyhow::Result { +) -> rt::errors::Result { let n = cfg.bucket_no.len(); - anyhow::ensure!(n > 0, "bucket_no must have at least one entry"); + rt::errors::internal_ensure!(n > 0, "bucket_no must have at least one entry"); let subtract_on_start = value_to_u256_vec( eval_with_node( @@ -216,6 +219,10 @@ impl CostVec { .copied() .fold(primitive_types::U256::zero(), |a, b| a + b) } + + pub fn reported_fee(&self) -> primitive_types::U256 { + self.0[0] + } } #[derive(Debug, Clone)] @@ -257,7 +264,7 @@ impl DataLimit { bucket_totals: Vec, fees: crate::config::FeesConfig, gas_data: std::collections::BTreeMap, - ) -> anyhow::Result { + ) -> rt::errors::Result { let prelude = &fees.expr_prelude; // Host-provided values are exposed under a single `node` object, e.g. @@ -266,9 +273,9 @@ impl DataLimit { for (name, raw) in gas_data { let src = format!("{prelude}\n{raw}"); let value = genvm_common::expr::Expr::parse(&src) - .map_err(|e| anyhow::anyhow!("parsing gas_data constant `{name}`: {e}"))? + .map_err(|e| rt::errors::internal!("parsing gas_data constant `{name}`: {e}"))? .evaluate() - .map_err(|e| anyhow::anyhow!("evaluating gas_data constant `{name}`: {e}"))?; + .map_err(|e| rt::errors::internal!("evaluating gas_data constant `{name}`: {e}"))?; node.insert(name, value); } let node = genvm_common::expr::Value::Object(Arc::new(node)); @@ -277,31 +284,31 @@ impl DataLimit { prelude, &fees.storage, &node, - abi::consts::VmError::oom().storage(), + abi::consts::VmError::out_of().storage(), )?; let message_receipt = build_bucket( prelude, &fees.message_receipt, &node, - abi::consts::VmError::oom().receipt().message().internal(), + abi::consts::VmError::out_of().receipt().message(), )?; let nondet_output = build_bucket( prelude, &fees.nondet_output, &node, - abi::consts::VmError::oom().receipt().nondet_output(), + abi::consts::VmError::out_of().receipt().nondet_output(), )?; let message_fee = build_bucket( prelude, &fees.message_fee, &node, - abi::consts::VmError::oom().fees().internal(), + abi::consts::VmError::out_of().message_fee(), )?; let event = build_bucket( prelude, &fees.event, &node, - abi::consts::VmError::oom().storage(), + abi::consts::VmError::out_of().receipt().event(), )?; Ok(Self { @@ -318,17 +325,20 @@ impl DataLimit { &self, bucket: &Bucket, vars: &[(&str, genvm_common::expr::Value)], - ) -> anyhow::Result { - match bucket - .delta - .apply_with(attrs_object(vars), no_free_vars) - .map_err(|e| anyhow::anyhow!("{e}")) - .and_then(|v| value_to_u256_vec(v, bucket.bucket_nos.len())) - { + ) -> rt::errors::Result { + let res = bucket.delta.apply_with(attrs_object(vars), no_free_vars); + let res = match res { + Ok(v) => Ok(v), + Err(genvm_common::expr::EvalError::ScriptVMError(e)) => { + Err(rt::errors::Error::vm(abi::consts::VmError(e.into()))) + } + Err(e) => Err(rt::errors::Error::internal(format!("{}", e))), + }; + match res.and_then(|v| value_to_u256_vec(v, bucket.bucket_nos.len())) { 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") + log_error!(error:err = e; "failed to evaluate fee expression"); + Err(e).ctx("failed to evaluate fee expression") } } } @@ -337,7 +347,7 @@ impl DataLimit { &self, bucket: &Bucket, vars: &[(&str, genvm_common::expr::Value)], - ) -> anyhow::Result { + ) -> rt::errors::Result { let costs = self.calculate_bucket(bucket, vars)?; Ok(self.consume_bucket_raw(bucket, &costs.0).await) } @@ -431,7 +441,7 @@ impl DataLimit { { return Some(rt::errors::Error::wrap( bucket.oom_error.clone(), - anyhow::anyhow!( + rt::errors::internal!( "subtract_on_start exceeds bucket {:?} total", bucket.bucket_nos ), @@ -441,7 +451,7 @@ impl DataLimit { None } - pub async fn consume_storage_pages(&self, pages: u64) -> anyhow::Result { + pub async fn consume_storage_pages(&self, pages: u64) -> rt::errors::Result { self.consume_bucket(&self.storage, &[("pages", num(pages))]) .await } @@ -449,7 +459,7 @@ impl DataLimit { pub fn calculate_message_receipt( &self, params: MessageReceiptParams, - ) -> anyhow::Result { + ) -> rt::errors::Result { self.calculate_bucket( &self.message_receipt, &[ @@ -460,23 +470,23 @@ impl DataLimit { ("subtreeLength", num(params.subtree_length)), ], ) - .context("calculating message receipt") + .ctx("calculating message receipt") } - pub async fn consume_nondet_output(&self, output_length: u64) -> anyhow::Result { + pub async fn consume_nondet_output(&self, output_length: u64) -> rt::errors::Result { self.consume_bucket( &self.nondet_output, &[("outputLength", output_length.into())], ) .await - .context("consuming nondet output") + .ctx("consuming nondet output") } pub async fn consume_event( &self, blob_size: u64, topics_count: u64, - ) -> anyhow::Result> { + ) -> rt::errors::Result> { let costs = self.calculate_bucket( &self.event, &[ @@ -495,7 +505,7 @@ impl DataLimit { &self, on: abi::gl_call::On, matched_fee_params: &genvm_common::domain::fees::InternalMessageParams, - ) -> anyhow::Result { + ) -> rt::errors::Result { self.calculate_bucket( &self.message_fee, &[ @@ -507,7 +517,7 @@ impl DataLimit { ), ], ) - .context("calculating message fee internal") + .ctx("calculating message fee internal") } pub async fn consume_message_fee(&self, cost_fee: &CostVec, cost_receipt: &CostVec) -> bool { @@ -567,7 +577,7 @@ impl DataLimit { pub fn calculate_message_fee_external( &self, matched_fee_params: &genvm_common::domain::fees::ExternalMessageParams, - ) -> anyhow::Result { + ) -> rt::errors::Result { self.calculate_bucket( &self.message_fee, &[ @@ -578,6 +588,6 @@ impl DataLimit { ), ], ) - .context("calculating message fee external") + .ctx("calculating message fee external") } } diff --git a/executor/src/rt/memlimiter.rs b/executor/src/rt/memlimiter.rs index cf6e9161..7cf73748 100644 --- a/executor/src/rt/memlimiter.rs +++ b/executor/src/rt/memlimiter.rs @@ -149,7 +149,7 @@ impl wasmtime::ResourceLimiter for Limiter { let success = self.consume(delta); if current == 0 && !success { - Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().memory()).into()) + Err(rt::errors::Error::vm(abi::consts::VmError::out_of().memory().wasm_memory()).into()) } else { Ok(success) } @@ -171,7 +171,7 @@ impl wasmtime::ResourceLimiter for Limiter { let success = self.consume_mul(delta, abi::consts::memory_limiter_consts::TABLE_ENTRY); if current == 0 && !success { - Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().table()).into()) + Err(rt::errors::Error::vm(abi::consts::VmError::out_of().memory().wasm_table()).into()) } else { Ok(success) } diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index 0b0bc6c3..c8c52256 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -381,7 +381,9 @@ pub(crate) fn map_archive_file( if !limiter .consume(public_abi::memory_limiter_consts::FILE_MAPPING + name_in_fs.len() as u32) { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().val()).into()); + return Err( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ); } preview1.map_file(&name_in_fs, file_contents.clone())?; @@ -390,7 +392,9 @@ pub(crate) fn map_archive_file( check_mapping_target(to)?; if !limiter.consume(public_abi::memory_limiter_consts::FILE_MAPPING + to.len() as u32) { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().val()).into()); + return Err( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ); } preview1.map_file(to, arch.get_file(file)?)?; diff --git a/executor/src/rt/supervisor/mod.rs b/executor/src/rt/supervisor/mod.rs index 60ebc632..1107af4b 100644 --- a/executor/src/rt/supervisor/mod.rs +++ b/executor/src/rt/supervisor/mod.rs @@ -354,7 +354,7 @@ pub async fn spawn( ) -> std::result::Result, rt::SpawnError> { if vm.depth >= public_abi::top_limits::VM_RECURSION { return Err(rt::SpawnError { - error: rt::errors::Error::vm(public_abi::VmError::oom().ram().limit()).into(), + error: rt::errors::Error::vm(public_abi::VmError::out_of().vm_recursion()).into(), state: Box::new(rt::SpawnErrorState::Unspawned(vm)), }); } @@ -604,7 +604,7 @@ fn register_custom_runner_into( rt::errors::Error::wrap(public_abi::VmError::invalid_contract().val(), e) })?; if !limiter.consume(archive.total_size) { - return Err(rt::errors::Error::vm(public_abi::VmError::oom().ram().val()).into()); + return Err(rt::errors::Error::vm(public_abi::VmError::out_of().memory().val()).into()); } slot.insert(archive); } diff --git a/executor/src/rt/vm/storage.rs b/executor/src/rt/vm/storage.rs index 8ee8e00c..338f82e2 100644 --- a/executor/src/rt/vm/storage.rs +++ b/executor/src/rt/vm/storage.rs @@ -75,7 +75,7 @@ impl Limiter { pub async fn consume(&self, amount: u64) -> rt::errors::Result<()> { if !self.0.consume_storage_pages(amount).await? { return Err(rt::errors::Error::wrap( - abi::consts::VmError::oom().storage(), + abi::consts::VmError::out_of().storage(), anyhow::anyhow!("consuming {amount} storage pages"), )); } @@ -408,7 +408,9 @@ impl Storage { let code_slot = default_code_slot(); if code.len() > (u32::MAX - 4) as usize { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().storage())); + return Err(rt::errors::Error::vm( + abi::consts::VmError::out_of().storage(), + )); } let code_len = code.len() as u32; @@ -476,7 +478,9 @@ impl Storage { let code_size = u32::from_le_bytes(len_buf); if !limiter.consume(code_size) { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().storage())); + return Err(rt::errors::Error::vm( + abi::consts::VmError::out_of().memory().val(), + )); } let res = Box::new_uninit_slice(code_size as usize); diff --git a/executor/src/runners/cache.rs b/executor/src/runners/cache.rs index d20e5a03..a78920a5 100644 --- a/executor/src/runners/cache.rs +++ b/executor/src/runners/cache.rs @@ -129,7 +129,9 @@ impl Reader { called.store(true, std::sync::atomic::Ordering::SeqCst); let arch = arch_provider().await?; if !limiter.consume(arch.total_size) { - return Err(errors::Error::vm(abi::consts::VmError::oom().ram().val())); + return Err(errors::Error::vm( + abi::consts::VmError::out_of().memory().val(), + )); } Ok(ArchiveCache::new(name, arch)) }) @@ -138,7 +140,9 @@ impl Reader { if !called.load(std::sync::atomic::Ordering::SeqCst) && !limiter.consume(res.files.total_size) { - return Err(errors::Error::vm(abi::consts::VmError::oom().ram().val())); + return Err(errors::Error::vm( + abi::consts::VmError::out_of().memory().val(), + )); } Ok(res) diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs index cb616a2a..e2fd4547 100644 --- a/executor/src/wasi/genlayer_sdk.rs +++ b/executor/src/wasi/genlayer_sdk.rs @@ -11,6 +11,7 @@ use wiggle::GuestError; use crate::host::{self, SlotID}; use crate::{anyhow_to_wasmtime, calldata, public_abi, rt, runners, wasi}; +use crate::rt::errors::ResultExt as _; use genlayer_calldata::codec::Encode; pub use genlayer_sdk::abi::entry::ExtendedMessage; use genlayer_sdk::abi::{self, gl_call}; @@ -21,10 +22,8 @@ fn default_entry_stage_data() -> calldata::Value { calldata::Value::Null } -fn oom_trap(error: abi::consts::VmError) -> generated::types::Error { - generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(error).into(), - )) +fn internal_trap(error: rt::errors::Error) -> generated::types::Error { + generated::types::Error::trap(anyhow_to_wasmtime(error.into())) } /// Named arguments for [`consume_message_fee_internal`]. @@ -45,7 +44,7 @@ async fn consume_message_fee_internal( let fee_cost = shared_data .data_fees_limit .calculate_message_fee_internal(on, &fee_params) - .map_err(|x| generated::types::Error::trap(anyhow_to_wasmtime(x)))?; + .map_err(internal_trap)?; let fee_total = fee_cost.sum(); if fee_total > node.budget { @@ -55,7 +54,10 @@ async fn consume_message_fee_internal( budget: cd = node.budget; "message fee cost exceeds node budget" ); - return Err(oom_trap(abi::consts::VmError::oom().fees().internal())); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().below_minimal(), + host_fns::VmErrorDetail::internal(), + ))); } let receipt_cost = shared_data @@ -67,11 +69,8 @@ async fn consume_message_fee_internal( code_length: args.code_length, subtree_length: args.subtree_length, }) - .map_err(|x| { - generated::types::Error::trap(anyhow_to_wasmtime( - x.context("calculate_message_receipt"), - )) - })?; + .ctx("calculate_message_receipt") + .map_err(internal_trap)?; if !shared_data .data_fees_limit @@ -84,7 +83,10 @@ async fn consume_message_fee_internal( buckets:? = shared_data.data_fees_limit; "not enough remaining fee limit to consume message fee" ); - return Err(oom_trap(abi::consts::VmError::oom().fees().internal())); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee(), + host_fns::VmErrorDetail::internal(), + ))); } node.budget -= fee_total; @@ -113,11 +115,14 @@ async fn consume_message_fee_external( let fee_cost = shared_data .data_fees_limit .calculate_message_fee_external(¶ms) - .map_err(|x| generated::types::Error::trap(anyhow_to_wasmtime(x)))?; + .map_err(internal_trap)?; let fee_total = fee_cost.sum(); if fee_total > node.budget { - return Err(oom_trap(abi::consts::VmError::oom().fees().external())); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().below_minimal(), + host_fns::VmErrorDetail::external(), + ))); } let receipt_cost = shared_data @@ -129,14 +134,17 @@ async fn consume_message_fee_external( code_length: 0, subtree_length: 0, }) - .map_err(|x| generated::types::Error::trap(anyhow_to_wasmtime(x)))?; + .map_err(internal_trap)?; if !shared_data .data_fees_limit .consume_message_fee(&fee_cost, &receipt_cost) .await { - return Err(oom_trap(abi::consts::VmError::oom().fees().external())); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee(), + host_fns::VmErrorDetail::external(), + ))); } node.budget -= fee_total; @@ -155,11 +163,11 @@ async fn consume_nondet_output( .data_fees_limit .consume_nondet_output(output_length) .await - .map_err(|x| generated::types::Error::trap(anyhow_to_wasmtime(x)))? + .map_err(internal_trap)? { - return Err(oom_trap( - abi::consts::VmError::oom().receipt().nondet_output(), - )); + return Err(internal_trap(rt::errors::Error::vm( + abi::consts::VmError::out_of().receipt().nondet_output(), + ))); } Ok(()) } @@ -642,7 +650,10 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { "no matching node for message fee allocation" ); - return Err(oom_trap(abi::consts::VmError::oom().fees().external())); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::external(), + ))); }; let calldata_length = calldata.len() as u64; @@ -667,8 +678,8 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { address, calldata, value, - message_fee: fees.message_fee.sum(), - receipt_fee: fees.receipt_fee.sum(), + message_fee: fees.message_fee.reported_fee(), + receipt_fee: fees.receipt_fee.reported_fee(), fee_params: matched_params, }); @@ -875,8 +886,12 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { .data_fees_limit .consume_event(blob_size, topics_count) .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))? - .ok_or_else(|| oom_trap(abi::consts::VmError::oom().storage()))?; + .map_err(internal_trap)? + .ok_or_else(|| { + internal_trap(rt::errors::Error::vm( + abi::consts::VmError::out_of().receipt().event(), + )) + })?; self.context.data.accumulator.emissions.push( domain::ExecutionEmission::EmitEvent { @@ -956,7 +971,10 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { "no matching node for message fee allocation" ); - return Err(oom_trap(abi::consts::VmError::oom().fees().internal())); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::internal(), + ))); }; log_debug!( @@ -996,8 +1014,8 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { calldata, value, on, - message_fee: fees.message_fee.sum(), - receipt_fee: fees.receipt_fee.sum(), + message_fee: fees.message_fee.reported_fee(), + receipt_fee: fees.receipt_fee.reported_fee(), fee_params, subtree, }, @@ -1067,7 +1085,10 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { "no matching node for message fee allocation" ); - return Err(oom_trap(abi::consts::VmError::oom().fees().internal())); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::internal(), + ))); }; let code_length = code.len() as u64; @@ -1101,8 +1122,8 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { value, on, salt_nonce, - message_fee: fees.message_fee.sum(), - receipt_fee: fees.receipt_fee.sum(), + receipt_fee: fees.receipt_fee.reported_fee(), + message_fee: fees.message_fee.reported_fee(), fee_params, subtree, }, @@ -1134,7 +1155,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { if space_left < abi::consts::top_limits::WEB_RENDER_MIN_SPACE { log_warn!(space_left = space_left; "not enough memory for web render"); return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::oom().ram().val()).into(), + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), ))); } @@ -1172,7 +1193,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { if space_left < abi::consts::top_limits::WEB_REQUEST_MIN_SPACE { log_warn!(space_left = space_left; "not enough memory for web request"); return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::oom().ram().val()).into(), + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), ))); } @@ -1713,7 +1734,7 @@ impl ContextVFS<'_> { if call_no >= public_abi::top_limits::NONDET_BLOCKS { return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::oom().ram().limit()).into(), + rt::errors::Error::vm(abi::consts::VmError::out_of().nondet_blocks()).into(), ))); } diff --git a/executor/src/wasi/vfs.rs b/executor/src/wasi/vfs.rs index efdd40a9..bc908231 100644 --- a/executor/src/wasi/vfs.rs +++ b/executor/src/wasi/vfs.rs @@ -66,7 +66,7 @@ impl VFS { if !limiter.consume(stdin.len() as u32) { return Err(rt::errors::Error::vm( - abi::consts::VmError::oom().ram().val(), + abi::consts::VmError::out_of().memory().val(), )); } @@ -97,7 +97,7 @@ impl VFS { /// gives vacant fd pub fn alloc_fd(&mut self) -> anyhow::Result { if self.fds.len() >= public_abi::top_limits::MAX_FDS as usize { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().limit()).into()); + return Err(rt::errors::Error::vm(abi::consts::VmError::out_of().fds()).into()); } match self.free_descriptors.pop() { Some(v) => Ok(v), @@ -106,9 +106,10 @@ impl VFS { .limiter .consume(public_abi::memory_limiter_consts::FD_ALLOCATION) { - return Err( - rt::errors::Error::vm(abi::consts::VmError::oom().ram().val()).into(), - ); + return Err(rt::errors::Error::vm( + abi::consts::VmError::out_of().memory().val(), + ) + .into()); } self.next_free_descriptor.0 += 1; Ok(self.next_free_descriptor) @@ -140,7 +141,9 @@ impl VFS { pub fn place_content(&mut self, value: FileContents) -> anyhow::Result { if value.release_memory && !self.limiter.consume(value.contents.len() as u32) { - return Err(rt::errors::Error::vm(abi::consts::VmError::oom().ram().val()).into()); + return Err( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ); } let fd = match self.alloc_fd() { diff --git a/runners/genlayer-py-std/src/genlayer/__init__.py b/runners/genlayer-py-std/src/genlayer/__init__.py index 1876af5e..224528fc 100644 --- a/runners/genlayer-py-std/src/genlayer/__init__.py +++ b/runners/genlayer-py-std/src/genlayer/__init__.py @@ -11,7 +11,6 @@ This provides access to: * Type aliases: ``gl.u8``, ``gl.u16``, ..., ``gl.u256``, ``gl.Address``, etc. -* Storage types: ``gl.TreeMap``, ``gl.DynArray``, ``gl.Array`` * Contract declaration via ``gl.contract.Contract`` * Contract interaction via ``gl.contract.interface``, ``gl.contract.deploy``, ``gl.contract.get_at`` * Message context via ``gl.message.contract_address``, ``gl.message.sender_address``, etc. @@ -44,7 +43,6 @@ # Decorators - directly import so gl.public and gl.private work from ._internal.annotations import private, public # noqa: E402 -from .storage import Array, DynArray, TreeMap, allow # noqa: E402 # Re-export types and storage names so they are accessible as gl.X from .types import * # noqa: E402 @@ -65,11 +63,6 @@ # Decorators (accessible via gl.public, gl.private) 'public', 'private', - # Storage types - 'DynArray', - 'Array', - 'TreeMap', - 'allow', # Unsigned integers 'u8', 'u16', diff --git a/runners/genlayer-py-std/src/genlayer/vm/__init__.py b/runners/genlayer-py-std/src/genlayer/vm/__init__.py index cf532cd1..f1eff164 100644 --- a/runners/genlayer-py-std/src/genlayer/vm/__init__.py +++ b/runners/genlayer-py-std/src/genlayer/vm/__init__.py @@ -77,6 +77,25 @@ class VMError: Description of the VM error that occurred. It begins with code, such as ``exit_code`` """ + public_code: str + """ + Extracted public code from the full message, which is the part before the first `` # `` detail suffix. + """ + + detail: str + """ + Additional detail about the error, which is the part after the first `` # `` suffix. + """ + + def __init__(self, message: str, /): + self.message = message + self.public_code, _, self.detail = message.partition(' # ') + + def __str__(self) -> str: + if self.detail: + return f'VMError("{self.public_code} # {self.detail}")' + return f'VMError("{self.public_code}")' + class UserError(Exception): """ @@ -262,7 +281,7 @@ def run_nondet_default[T: calldata.Decoded]( a.data == b.data ), compare_vm_errors: typing.Callable[[VMError, VMError], bool] = lambda a, b: ( - a.message == b.message + a.public_code == b.public_code ), ) -> Lazy[T]: """ @@ -275,7 +294,7 @@ def run_nondet_default[T: calldata.Decoded]( :param leader_fn: Function executed by the leader node :param validator_fn: Function that validates the leader's result, is ran in a sandbox :param compare_user_errors: Function to compare UserError instances for equality - :param compare_vm_errors: Function to compare VMError instances for equality + :param compare_vm_errors: Function to compare VMError instances for equality; the default compares only the public code (the part before the first `` # `` detail suffix), ignoring implementation-specific diagnostics :return: The result from the leader if validation passes Error handling: diff --git a/runners/genlayer-py-std/src/genlayer/vm/public_abi.py b/runners/genlayer-py-std/src/genlayer/vm/public_abi.py index 6c29cc2c..ffb52778 100644 --- a/runners/genlayer-py-std/src/genlayer/vm/public_abi.py +++ b/runners/genlayer-py-std/src/genlayer/vm/public_abi.py @@ -1,9 +1,10 @@ # This file is auto-generated. Do not edit! # fmt: off +# ruff: noqa -from enum import IntEnum, StrEnum import typing +from enum import IntEnum, StrEnum class ResultCode(IntEnum): @@ -60,57 +61,119 @@ class SpecialMethod(StrEnum): GET_SCHEMA = '#get-schema' ERRORED_MESSAGE = '#error' -class _VmErrorOomRam: +class _VmErrorWasmTrap: @staticmethod def val() -> 'VmError': - return VmError('OOM RAM') + return VmError('wasm_trap') @staticmethod - def table() -> 'VmError': - return VmError('OOM RAM table') + def unreachable() -> 'VmError': + return VmError('wasm_trap unreachable') @staticmethod - def memory() -> 'VmError': - return VmError('OOM RAM memory') + def stack_overflow() -> 'VmError': + return VmError('wasm_trap stack_overflow') @staticmethod - def limit() -> 'VmError': - return VmError('OOM RAM limit') - -class _VmErrorOomReceiptMessage: + def memory_out_of_bounds() -> 'VmError': + return VmError('wasm_trap memory_out_of_bounds') + @staticmethod + def table_out_of_bounds() -> 'VmError': + return VmError('wasm_trap table_out_of_bounds') + @staticmethod + def indirect_call_to_null() -> 'VmError': + return VmError('wasm_trap indirect_call_to_null') + @staticmethod + def bad_signature() -> 'VmError': + return VmError('wasm_trap bad_signature') + @staticmethod + def integer_overflow() -> 'VmError': + return VmError('wasm_trap integer_overflow') + @staticmethod + def integer_divide_by_zero() -> 'VmError': + return VmError('wasm_trap integer_divide_by_zero') + @staticmethod + def bad_conversion_to_integer() -> 'VmError': + return VmError('wasm_trap bad_conversion_to_integer') @staticmethod - def internal() -> 'VmError': - return VmError('OOM receipt message internal') + def heap_misaligned() -> 'VmError': + return VmError('wasm_trap heap_misaligned') @staticmethod - def external() -> 'VmError': - return VmError('OOM receipt message external') + def atomic_wait_non_shared_memory() -> 'VmError': + return VmError('wasm_trap atomic_wait_non_shared_memory') + @staticmethod + def out_of_fuel() -> 'VmError': + return VmError('wasm_trap out_of_fuel') + @staticmethod + def interrupt() -> 'VmError': + return VmError('wasm_trap interrupt') + @staticmethod + def nondet_instruction() -> 'VmError': + return VmError('wasm_trap nondet_instruction') + @staticmethod + def fault() -> 'VmError': + return VmError('wasm_trap fault') -class _VmErrorOomReceipt: +class _VmErrorOutOfMemory: @staticmethod - def nondet_output() -> 'VmError': - return VmError('OOM receipt nondet_output') + def val() -> 'VmError': + return VmError('out_of memory') + @staticmethod + def wasm_memory() -> 'VmError': + return VmError('out_of memory wasm_memory') @staticmethod - def message() -> '_VmErrorOomReceiptMessage': - return _VmErrorOomReceiptMessage() + def wasm_table() -> 'VmError': + return VmError('out_of memory wasm_table') -class _VmErrorOomFees: +class _VmErrorOutOfReceipt: + @staticmethod + def nondet_output() -> 'VmError': + return VmError('out_of receipt nondet_output') @staticmethod - def internal() -> 'VmError': - return VmError('OOM fees internal') + def message() -> 'VmError': + return VmError('out_of receipt message') @staticmethod - def external() -> 'VmError': - return VmError('OOM fees external') + def event() -> 'VmError': + return VmError('out_of receipt event') -class _VmErrorOom: +class _VmErrorOutOf: @staticmethod def storage() -> 'VmError': - return VmError('OOM storage') + return VmError('out_of storage') @staticmethod - def ram() -> '_VmErrorOomRam': - return _VmErrorOomRam() + def message_fee() -> 'VmError': + return VmError('out_of message_fee') @staticmethod - def receipt() -> '_VmErrorOomReceipt': - return _VmErrorOomReceipt() + def vm_recursion() -> 'VmError': + return VmError('out_of vm_recursion') @staticmethod - def fees() -> '_VmErrorOomFees': - return _VmErrorOomFees() + def nondet_blocks() -> 'VmError': + return VmError('out_of nondet_blocks') + @staticmethod + def locked_slots() -> 'VmError': + return VmError('out_of locked_slots') + @staticmethod + def upgraders() -> 'VmError': + return VmError('out_of upgraders') + @staticmethod + def fds() -> 'VmError': + return VmError('out_of fds') + @staticmethod + def memory() -> '_VmErrorOutOfMemory': + return _VmErrorOutOfMemory() + @staticmethod + def receipt() -> '_VmErrorOutOfReceipt': + return _VmErrorOutOfReceipt() + +class _VmErrorFee: + @staticmethod + def no_matching_node() -> 'VmError': + return VmError('fee no_matching_node') + @staticmethod + def below_minimal() -> 'VmError': + return VmError('fee below_minimal') + +class _VmErrorEvm: + @staticmethod + def reverted() -> 'VmError': + return VmError('evm reverted') class _VmErrorInvalidContractWasm: @staticmethod @@ -148,16 +211,6 @@ class _VmErrorExitCode: def val_i32(v: int) -> 'VmError': return VmError(f'exit_code {v}') -class _VmErrorWasmTrap: - @staticmethod - def val_str(v: str) -> 'VmError': - return VmError(f'wasm_trap {v}') - -class _VmErrorHost: - @staticmethod - def val_str(v: str) -> 'VmError': - return VmError(f'host {v}') - class VmError: __slots__ = ('value',) def __init__(self, value: str): @@ -171,20 +224,26 @@ def timeout() -> 'VmError': def absent_leader_nondet_output() -> 'VmError': return VmError('absent_leader_nondet_output') @staticmethod + def host_forbidden() -> 'VmError': + return VmError('host_forbidden') + @staticmethod def exit_code() -> '_VmErrorExitCode': return _VmErrorExitCode() @staticmethod def wasm_trap() -> '_VmErrorWasmTrap': return _VmErrorWasmTrap() @staticmethod - def oom() -> '_VmErrorOom': - return _VmErrorOom() + def out_of() -> '_VmErrorOutOf': + return _VmErrorOutOf() + @staticmethod + def fee() -> '_VmErrorFee': + return _VmErrorFee() + @staticmethod + def evm() -> '_VmErrorEvm': + return _VmErrorEvm() @staticmethod def invalid_contract() -> '_VmErrorInvalidContract': return _VmErrorInvalidContract() - @staticmethod - def host() -> '_VmErrorHost': - return _VmErrorHost() diff --git a/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout b/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout index 0ec16297..2b2e5e2d 100644 --- a/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout +++ b/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout @@ -1,5 +1,6 @@ balance before=100 after_first_send=40 -sandbox result=VMError(message='exit_code 1') +sandbox transfer failed: 7: inbalance +sandbox result=Return(calldata=40) balance final=40 executed with `Return(null)` {"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","value":60} diff --git a/tests/integration/balance/sandbox_overspend/sandbox_overspend.py b/tests/integration/balance/sandbox_overspend/sandbox_overspend.py index 89dd460c..47a9d4d3 100644 --- a/tests/integration/balance/sandbox_overspend/sandbox_overspend.py +++ b/tests/integration/balance/sandbox_overspend/sandbox_overspend.py @@ -19,7 +19,10 @@ def sandbox_fn(): # Second transfer: spend another 60 inside sandbox # This should fail (total 120 > 100) but the sandbox # doesn't know about the parent's 60 already spent. - gl.contract.get_at(target).emit_transfer(value=60) + try: + gl.contract.get_at(target).emit_transfer(value=60) + except SystemError as e: + print(f'sandbox transfer failed: {e}') return self.balance result = gl.vm.spawn_sandbox(sandbox_fn, allow_send_messages=True) diff --git a/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.py b/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.py index 620bf90c..b1f18849 100644 --- a/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.py +++ b/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.py @@ -9,7 +9,10 @@ def __init__(self): print(f'balance before={self.balance}') def sandbox_fn(): - gl.contract.get_at(target).emit_transfer(value=60) + try: + gl.contract.get_at(target).emit_transfer(value=60) + except SystemError as e: + print(f'sandbox transfer failed: {e}') return self.balance result = gl.vm.spawn_sandbox(sandbox_fn, allow_send_messages=True) diff --git a/tests/integration/bench/read_tree_map/read_tree_map.py b/tests/integration/bench/read_tree_map/read_tree_map.py index 8e8d82db..4b2d83ac 100644 --- a/tests/integration/bench/read_tree_map/read_tree_map.py +++ b/tests/integration/bench/read_tree_map/read_tree_map.py @@ -9,14 +9,14 @@ @gl.storage.allow class TrieNode[K: gl.storage.Comparable, T]: - children: gl.TreeMap[K, u32] + children: gl.storage.TreeMap[K, u32] value: T has_value: bool @gl.storage.allow class Trie[K: gl.storage.Comparable, T]: - nodes: gl.DynArray[TrieNode[K, T]] + nodes: gl.storage.DynArray[TrieNode[K, T]] def __init__(self): _root = self.nodes.append_new_get() diff --git a/tests/integration/exploit/flt/flt.0.stdout b/tests/integration/exploit/flt/flt.0.stdout index 0a97f4c7..0966371d 100644 --- a/tests/integration/exploit/flt/flt.0.stdout +++ b/tests/integration/exploit/flt/flt.0.stdout @@ -1 +1 @@ -executed with `VMError("wasm_trap DeterministicMode")` +executed with `VMError("wasm_trap nondet_instruction")` diff --git a/tests/integration/exploit/fork_bomb/fork_bomb.0.stdout b/tests/integration/exploit/fork_bomb/fork_bomb.0.stdout index de1be744..a64eedb0 100644 --- a/tests/integration/exploit/fork_bomb/fork_bomb.0.stdout +++ b/tests/integration/exploit/fork_bomb/fork_bomb.0.stdout @@ -1,2 +1,2 @@ -OOM RAM +out_of memory executed with `Return(null)` diff --git a/tests/integration/exploit/rec/rec.0.stdout b/tests/integration/exploit/rec/rec.0.stdout index 7f5c0438..bb057e0d 100644 --- a/tests/integration/exploit/rec/rec.0.stdout +++ b/tests/integration/exploit/rec/rec.0.stdout @@ -1 +1 @@ -executed with `VMError("wasm_trap StackOverflow")` +executed with `VMError("wasm_trap stack_overflow")` diff --git a/tests/integration/exploit/rec_1024/rec_1024.0.stdout b/tests/integration/exploit/rec_1024/rec_1024.0.stdout index 7f5c0438..bb057e0d 100644 --- a/tests/integration/exploit/rec_1024/rec_1024.0.stdout +++ b/tests/integration/exploit/rec_1024/rec_1024.0.stdout @@ -1 +1 @@ -executed with `VMError("wasm_trap StackOverflow")` +executed with `VMError("wasm_trap stack_overflow")` diff --git a/tests/integration/exploit/storage_page_limit/storage_page_limit.0_1.stdout b/tests/integration/exploit/storage_page_limit/storage_page_limit.0_1.stdout index 6a6159e0..823b9336 100644 --- a/tests/integration/exploit/storage_page_limit/storage_page_limit.0_1.stdout +++ b/tests/integration/exploit/storage_page_limit/storage_page_limit.0_1.stdout @@ -1 +1 @@ -executed with `VMError("OOM storage")` +executed with `VMError("out_of storage")` diff --git a/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet b/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet index 0beced89..33e90085 100644 --- a/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet +++ b/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet @@ -31,7 +31,7 @@ local storageOnlyGasData = { {"": "write_2_pages", "args": []} |||, // bucket 0 = storage (limited to 2 pages); bucket 1 (message_fee) unconstrained - "bucket_totals": [2, 1000000], + "bucket_totals": [2, 1000000, 1000000, 1000000], "gas_data": storageOnlyGasData, }, { @@ -42,7 +42,7 @@ local storageOnlyGasData = { {"": "write_3_pages", "args": []} |||, // bucket 0 = storage (limited to 2 pages); bucket 1 (message_fee) unconstrained - "bucket_totals": [2, 1000000], + "bucket_totals": [2, 1000000, 1000000, 1000000], "gas_data": storageOnlyGasData, }, ], diff --git a/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.0.stdout b/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.0.stdout index 710e30b2..a2899dc6 100644 --- a/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.0.stdout +++ b/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.0.stdout @@ -1 +1 @@ -executed with `VMError("OOM receipt message internal")` +executed with `VMError("out_of receipt message")` diff --git a/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet b/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet index c824eb29..3449ec77 100644 --- a/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet +++ b/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet @@ -21,6 +21,6 @@ local util = import 'templates/util.jsonnet'; "code": '${jsonnetDir}/${fileBaseName}.py', "message": msg + {"is_init": true}, "calldata": "{}", - "bucket_totals": [10, 1000000], + "bucket_totals": [10, 1000000, 1000000, 1000000], }, ])} diff --git a/tests/integration/exploit/unreachable/unreachable.0.stdout b/tests/integration/exploit/unreachable/unreachable.0.stdout index 375ef052..6917b0fc 100644 --- a/tests/integration/exploit/unreachable/unreachable.0.stdout +++ b/tests/integration/exploit/unreachable/unreachable.0.stdout @@ -1 +1 @@ -executed with `VMError("wasm_trap UnreachableCodeReached")` +executed with `VMError("wasm_trap unreachable")` diff --git a/tests/integration/message/call_view_committed/call_view_committed.py b/tests/integration/message/call_view_committed/call_view_committed.py index 93ee862b..39b78642 100644 --- a/tests/integration/message/call_view_committed/call_view_committed.py +++ b/tests/integration/message/call_view_committed/call_view_committed.py @@ -5,7 +5,7 @@ class Contract(gl.contract.Contract): x: u32 - write_answer: gl.DynArray[u32] + write_answer: gl.storage.DynArray[u32] def __init__(self): self.x = 1 diff --git a/tests/integration/message/eth_call_view/eth_call_view.0.stdout b/tests/integration/message/eth_call_view/eth_call_view.0.stdout index b9b840c8..b4a706fc 100644 --- a/tests/integration/message/eth_call_view/eth_call_view.0.stdout +++ b/tests/integration/message/eth_call_view/eth_call_view.0.stdout @@ -1 +1 @@ -executed with `VMError("host forbidden")` +executed with `VMError("evm reverted")` diff --git a/tests/integration/misc/formats/formats.py b/tests/integration/misc/formats/formats.py index aca3f1a9..36a42b16 100644 --- a/tests/integration/misc/formats/formats.py +++ b/tests/integration/misc/formats/formats.py @@ -7,8 +7,8 @@ @generate_storage class Test: - arr: gl.DynArray[str] - map: gl.TreeMap[str, None] + arr: gl.storage.DynArray[str] + map: gl.storage.TreeMap[str, None] addr = Address(b'\xa2' * 20) diff --git a/tests/integration/misc/issue_163/issue_163.py b/tests/integration/misc/issue_163/issue_163.py index cd8d4921..cba6116a 100644 --- a/tests/integration/misc/issue_163/issue_163.py +++ b/tests/integration/misc/issue_163/issue_163.py @@ -6,7 +6,7 @@ @generate_storage class Pr: - x: gl.TreeMap[str, str] + x: gl.storage.TreeMap[str, str] a = Pr() diff --git a/tests/integration/runner/code_slot_exec/code_slot_exec.jsonnet b/tests/integration/runner/code_slot_exec/code_slot_exec.jsonnet index 87f66b62..03131749 100644 --- a/tests/integration/runner/code_slot_exec/code_slot_exec.jsonnet +++ b/tests/integration/runner/code_slot_exec/code_slot_exec.jsonnet @@ -12,7 +12,7 @@ local util = import 'templates/util.jsonnet'; vars: {}, code: null, message: msg, - calldata: std.manifestJsonEx({method: 'foo', args: []}, ' '), + calldata: std.manifestJsonEx({'': 'foo', args: []}, ' '), stable_hash: false, }], }]) diff --git a/tests/integration/runner/code_slot_redirect/code_slot_redirect.jsonnet b/tests/integration/runner/code_slot_redirect/code_slot_redirect.jsonnet index 87f66b62..03131749 100644 --- a/tests/integration/runner/code_slot_redirect/code_slot_redirect.jsonnet +++ b/tests/integration/runner/code_slot_redirect/code_slot_redirect.jsonnet @@ -12,7 +12,7 @@ local util = import 'templates/util.jsonnet'; vars: {}, code: null, message: msg, - calldata: std.manifestJsonEx({method: 'foo', args: []}, ' '), + calldata: std.manifestJsonEx({'': 'foo', args: []}, ' '), stable_hash: false, }], }]) diff --git a/tests/integration/runner/major_mismatch/major_mismatch.jsonnet b/tests/integration/runner/major_mismatch/major_mismatch.jsonnet index 45104efb..7f848701 100644 --- a/tests/integration/runner/major_mismatch/major_mismatch.jsonnet +++ b/tests/integration/runner/major_mismatch/major_mismatch.jsonnet @@ -14,7 +14,7 @@ local entries = [ vars: {}, code: null, message: msg, - calldata: std.manifestJsonEx({method: 'foo', args: []}, ' '), + calldata: std.manifestJsonEx({'': 'foo', args: []}, ' '), }], }, // 1: CallContract into a major-mismatched contract diff --git a/tests/integration/sandbox/det/s/exit.0_0.stdout b/tests/integration/sandbox/det/s/exit.0_0.stdout index 5e0ffa5d..961f8cfb 100644 --- a/tests/integration/sandbox/det/s/exit.0_0.stdout +++ b/tests/integration/sandbox/det/s/exit.0_0.stdout @@ -1,3 +1,3 @@ -VMError(message='exit_code 1') +VMError("exit_code 1") loads executed with `Return(null)` diff --git a/tests/integration/sandbox/det/s/sandbox.0_0.stdout b/tests/integration/sandbox/det/s/sandbox.0_0.stdout index 5e0ffa5d..961f8cfb 100644 --- a/tests/integration/sandbox/det/s/sandbox.0_0.stdout +++ b/tests/integration/sandbox/det/s/sandbox.0_0.stdout @@ -1,3 +1,3 @@ -VMError(message='exit_code 1') +VMError("exit_code 1") loads executed with `Return(null)` diff --git a/tests/integration/sandbox/non-det/s/exit.0_0.stdout b/tests/integration/sandbox/non-det/s/exit.0_0.stdout index 5e0ffa5d..961f8cfb 100644 --- a/tests/integration/sandbox/non-det/s/exit.0_0.stdout +++ b/tests/integration/sandbox/non-det/s/exit.0_0.stdout @@ -1,3 +1,3 @@ -VMError(message='exit_code 1') +VMError("exit_code 1") loads executed with `Return(null)` diff --git a/tests/integration/storage/gvm-89/gvm-89.py b/tests/integration/storage/gvm-89/gvm-89.py index 02be67b7..5b87434e 100644 --- a/tests/integration/storage/gvm-89/gvm-89.py +++ b/tests/integration/storage/gvm-89/gvm-89.py @@ -9,11 +9,11 @@ @allow @dataclass class Foo: - x: gl.DynArray[str] + x: gl.storage.DynArray[str] class Main(gl.contract.Contract): - f: gl.DynArray[Foo] + f: gl.storage.DynArray[Foo] @gl.public.write def main(self): diff --git a/tests/integration/storage/persists/persists.py b/tests/integration/storage/persists/persists.py index b7792720..e1bbda27 100644 --- a/tests/integration/storage/persists/persists.py +++ b/tests/integration/storage/persists/persists.py @@ -5,7 +5,7 @@ class Contract(gl.contract.Contract): - m: gl.TreeMap[str, u32] + m: gl.storage.TreeMap[str, u32] def __init__(self): print('first') diff --git a/tests/integration/storage/storage_tree_map/storage_tree_map.py b/tests/integration/storage/storage_tree_map/storage_tree_map.py index 4236fac7..4f7ba93c 100644 --- a/tests/integration/storage/storage_tree_map/storage_tree_map.py +++ b/tests/integration/storage/storage_tree_map/storage_tree_map.py @@ -7,7 +7,7 @@ @generate_storage class UserStorage: - m: gl.TreeMap[str, u32] + m: gl.storage.TreeMap[str, u32] tst = UserStorage() diff --git a/tests/integration/storage/store_proxy/store_proxy.py b/tests/integration/storage/store_proxy/store_proxy.py index d1368404..16f70c69 100644 --- a/tests/integration/storage/store_proxy/store_proxy.py +++ b/tests/integration/storage/store_proxy/store_proxy.py @@ -14,7 +14,7 @@ class User: class Contract(gl.contract.Contract): - users: gl.DynArray[User] + users: gl.storage.DynArray[User] def __init__(self): self.users.append(User('Ada', datetime.datetime.now())) diff --git a/tests/integration/storage/tree_map_nested/tree_map_nested.py b/tests/integration/storage/tree_map_nested/tree_map_nested.py index 80d6cb94..7075c3bc 100644 --- a/tests/integration/storage/tree_map_nested/tree_map_nested.py +++ b/tests/integration/storage/tree_map_nested/tree_map_nested.py @@ -5,7 +5,7 @@ class Contract(gl.contract.Contract): - st: gl.TreeMap[Address, gl.TreeMap[Address, u256]] + st: gl.storage.TreeMap[Address, gl.storage.TreeMap[Address, u256]] def __init__(self): first = self.st.get_or_insert_default(Address(b'\x00' * 20)) From 2d6ceff317e6a0e59e9090560764b11f395a048c Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 2 Jul 2026 21:55:40 +0900 Subject: [PATCH 10/25] =?UTF-8?q?feat(fees):=20contract-funded=20message?= =?UTF-8?q?=20fees,=20emission=20fee=20floors=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sdk): balance-funded message fees via gl_call use_balance ✨ * feat(vm): contract-owned permission bitfield in the root slot ✨ * feat(fees): fail at emission when below minimum fee ✨ * fix(sdk): validate guest fee_params against chain rules and overflow 🔒️ * chore(fees): split out_of message_fee errors into total/node ♻️ * fix(vm): charge oversized buffers instead of truncating byte counts 🔒️ * chore(runners): guard commits against dev-mode and test hashes 🔧 --- .genvm-tool.py | 14 +- executor/codegen/data/public-abi.json | 18 +- executor/crates/common/src/domain.rs | 5 + executor/crates/common/src/domain/fees.rs | 40 +- executor/crates/sdk-rs/src/abi/arb.rs | 7 + executor/crates/sdk-rs/src/abi/consts.rs | 51 +- executor/crates/sdk-rs/src/abi/fees.rs | 54 ++ executor/crates/sdk-rs/src/abi/gl_call.rs | 28 ++ executor/crates/sdk-rs/src/abi/mod.rs | 1 + .../sdk-rs/tests/gl_call_balance_fees.rs | 137 +++++ executor/install/config/genvm.yaml | 64 ++- executor/src/lib.rs | 19 +- executor/src/rt/fees.rs | 25 +- executor/src/rt/memlimiter.rs | 10 + executor/src/rt/vm/storage.rs | 10 + executor/src/wasi/base.rs | 1 + executor/src/wasi/genlayer_sdk.rs | 470 ++++++++++++++++-- executor/src/wasi/vfs.rs | 4 +- runners/genlayer-py-std/src/genlayer/chain.py | 31 +- .../src/genlayer/contract/__init__.py | 121 +++-- .../src/genlayer/storage/root.py | 29 ++ .../src/genlayer/vm/public_abi.py | 26 +- runners/support/versions/check-dev-mode.py | 40 ++ runners/support/versions/current.nix | 6 +- .../balance/balance/balance.0_0.stdout | 2 +- .../sandbox_overspend.0.stdout | 2 +- .../sandbox_overspend_2.0.stdout | 2 +- .../message/deploy/deploy.0.stdout | 2 +- .../message/deploy_salt/deploy_salt.0.stdout | 2 +- .../internal_below_min_timeunits.0.stdout | 1 + .../internal_below_min_timeunits.jsonnet | 48 ++ .../internal_below_min_timeunits.py | 11 + .../send_message/send_message.0.stdout | 2 +- .../send_message_on.0_0.stdout | 2 +- .../use_balance_below_min.0.stdout | 1 + .../use_balance_below_min.0_0.stdout | 1 + .../use_balance_below_min.jsonnet | 28 ++ .../use_balance_below_min.py | 30 ++ .../use_balance_budget_too_low.0.stdout | 1 + .../use_balance_budget_too_low.0_0.stdout | 1 + .../use_balance_budget_too_low.jsonnet | 29 ++ .../use_balance_budget_too_low.py | 30 ++ .../use_balance_empty_rotations.0.stdout | 1 + .../use_balance_empty_rotations.0_0.stdout | 2 + .../use_balance_empty_rotations.jsonnet | 5 + .../use_balance_empty_rotations.py | 33 ++ .../use_balance_forbidden.0.stdout | 2 + .../use_balance_forbidden.jsonnet | 4 + .../use_balance_forbidden.py | 25 + .../use_balance_huge_params.0.stdout | 1 + .../use_balance_huge_params.0_0.stdout | 2 + .../use_balance_huge_params.jsonnet | 5 + .../use_balance_huge_params.py | 35 ++ .../use_balance_insufficient.0.stdout | 1 + .../use_balance_insufficient.0_0.stdout | 2 + .../use_balance_insufficient.jsonnet | 11 + .../use_balance_insufficient.py | 32 ++ .../use_balance_no_alloc.0.stdout | 1 + .../use_balance_no_alloc.0_0.stdout | 1 + .../use_balance_no_alloc.0_0_0.stdout | 2 + .../use_balance_no_alloc.jsonnet | 25 + .../use_balance_no_alloc.py | 37 ++ .../use_balance_no_params.0.stdout | 1 + .../use_balance_no_params.0_0.stdout | 2 + .../use_balance_no_params.jsonnet | 5 + .../use_balance_no_params.py | 21 + .../use_balance_ok/use_balance_ok.0.stdout | 1 + .../use_balance_ok/use_balance_ok.0_0.stdout | 2 + .../use_balance_ok/use_balance_ok.jsonnet | 11 + .../message/use_balance_ok/use_balance_ok.py | 33 ++ .../use_balance_sandbox.0.stdout | 1 + .../use_balance_sandbox.0_0.stdout | 3 + .../use_balance_sandbox.jsonnet | 11 + .../use_balance_sandbox.py | 39 ++ .../use_balance_scaled.0.stdout | 1 + .../use_balance_scaled.0_0.stdout | 2 + .../use_balance_scaled.jsonnet | 29 ++ .../use_balance_scaled/use_balance_scaled.py | 32 ++ .../use_balance_too_many_rounds.0.stdout | 1 + .../use_balance_too_many_rounds.0_0.stdout | 1 + .../use_balance_too_many_rounds.jsonnet | 11 + .../use_balance_too_many_rounds.py | 31 ++ .../use_balance_zero_price.0.stdout | 1 + .../use_balance_zero_price.0_0.stdout | 2 + .../use_balance_zero_price.jsonnet | 5 + .../use_balance_zero_price.py | 34 ++ 86 files changed, 1758 insertions(+), 155 deletions(-) create mode 100644 executor/crates/sdk-rs/src/abi/fees.rs create mode 100644 executor/crates/sdk-rs/tests/gl_call_balance_fees.rs create mode 100644 runners/support/versions/check-dev-mode.py create mode 100644 tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.0.stdout create mode 100644 tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet create mode 100644 tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py create mode 100644 tests/integration/message/use_balance_below_min/use_balance_below_min.0.stdout create mode 100644 tests/integration/message/use_balance_below_min/use_balance_below_min.0_0.stdout create mode 100644 tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet create mode 100644 tests/integration/message/use_balance_below_min/use_balance_below_min.py create mode 100644 tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0.stdout create mode 100644 tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0_0.stdout create mode 100644 tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet create mode 100644 tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.py create mode 100644 tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0.stdout create mode 100644 tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0_0.stdout create mode 100644 tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.jsonnet create mode 100644 tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.py create mode 100644 tests/integration/message/use_balance_forbidden/use_balance_forbidden.0.stdout create mode 100644 tests/integration/message/use_balance_forbidden/use_balance_forbidden.jsonnet create mode 100644 tests/integration/message/use_balance_forbidden/use_balance_forbidden.py create mode 100644 tests/integration/message/use_balance_huge_params/use_balance_huge_params.0.stdout create mode 100644 tests/integration/message/use_balance_huge_params/use_balance_huge_params.0_0.stdout create mode 100644 tests/integration/message/use_balance_huge_params/use_balance_huge_params.jsonnet create mode 100644 tests/integration/message/use_balance_huge_params/use_balance_huge_params.py create mode 100644 tests/integration/message/use_balance_insufficient/use_balance_insufficient.0.stdout create mode 100644 tests/integration/message/use_balance_insufficient/use_balance_insufficient.0_0.stdout create mode 100644 tests/integration/message/use_balance_insufficient/use_balance_insufficient.jsonnet create mode 100644 tests/integration/message/use_balance_insufficient/use_balance_insufficient.py create mode 100644 tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0.stdout create mode 100644 tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0.stdout create mode 100644 tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout create mode 100644 tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.jsonnet create mode 100644 tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.py create mode 100644 tests/integration/message/use_balance_no_params/use_balance_no_params.0.stdout create mode 100644 tests/integration/message/use_balance_no_params/use_balance_no_params.0_0.stdout create mode 100644 tests/integration/message/use_balance_no_params/use_balance_no_params.jsonnet create mode 100644 tests/integration/message/use_balance_no_params/use_balance_no_params.py create mode 100644 tests/integration/message/use_balance_ok/use_balance_ok.0.stdout create mode 100644 tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout create mode 100644 tests/integration/message/use_balance_ok/use_balance_ok.jsonnet create mode 100644 tests/integration/message/use_balance_ok/use_balance_ok.py create mode 100644 tests/integration/message/use_balance_sandbox/use_balance_sandbox.0.stdout create mode 100644 tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout create mode 100644 tests/integration/message/use_balance_sandbox/use_balance_sandbox.jsonnet create mode 100644 tests/integration/message/use_balance_sandbox/use_balance_sandbox.py create mode 100644 tests/integration/message/use_balance_scaled/use_balance_scaled.0.stdout create mode 100644 tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout create mode 100644 tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet create mode 100644 tests/integration/message/use_balance_scaled/use_balance_scaled.py create mode 100644 tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0.stdout create mode 100644 tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0_0.stdout create mode 100644 tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.jsonnet create mode 100644 tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.py create mode 100644 tests/integration/message/use_balance_zero_price/use_balance_zero_price.0.stdout create mode 100644 tests/integration/message/use_balance_zero_price/use_balance_zero_price.0_0.stdout create mode 100644 tests/integration/message/use_balance_zero_price/use_balance_zero_price.jsonnet create mode 100644 tests/integration/message/use_balance_zero_price/use_balance_zero_price.py diff --git a/.genvm-tool.py b/.genvm-tool.py index d885c471..ec985f2a 100644 --- a/.genvm-tool.py +++ b/.genvm-tool.py @@ -119,12 +119,16 @@ def hooks(ctx: 'genvm_tool.common.Context'): }, # --- local guard --------------------------------------------------- { - # Refuse commits that leave the dev-mode runner toggle on (false). - 'id': 'no-commit-test', + # Refuse commits that leave dev-mode on or any hash as "test". + # On failure prints docs/contributing/howto/committing/runners.md. + 'id': 'no-commit-dev-mode', 'local': True, - 'entry': 'grep', - 'args': ['-P', 'false', 'runners/support/versions/dev-mode.nix'], - 'files': r'^runners/support/versions/dev-mode\.nix$', + 'entry': ctx.python_command[0], + 'args': [ + *ctx.python_command[1:], + 'runners/support/versions/check-dev-mode.py', + ], + 'files': r'^runners/support/versions/(dev-mode|current)\.nix$', 'pass_filenames': False, }, # --- commit-msg ---------------------------------------------------- diff --git a/executor/codegen/data/public-abi.json b/executor/codegen/data/public-abi.json index ee385c92..6bdf9c56 100644 --- a/executor/codegen/data/public-abi.json +++ b/executor/codegen/data/public-abi.json @@ -30,6 +30,14 @@ "consensus_stage": 2 } }, + { + "type": "enum", + "name": "permissions", + "repr": "u32", + "values": { + "can_use_balance_for_message_fees": 1 + } + }, { "type": "consts", "name": "memory_limiter_consts", @@ -50,7 +58,8 @@ "code": 2, "locked_slots": 3, "upgraders": 4, - "code_slot": 5 + "code_slot": 5, + "permissions": 37 } }, { @@ -119,7 +128,10 @@ "head": "receipt", "tail": ["nondet_output", "message", "event"] }, - "message_fee", + { + "head": "message_fee", + "tail": ["total", "node"] + }, "vm_recursion", "nondet_blocks", "locked_slots", @@ -129,7 +141,7 @@ }, { "head": "fee", - "tail": ["no_matching_node", "below_minimal"] + "tail": ["no_matching_node", "below_minimum", "too_many_rounds"] }, "host_forbidden", { diff --git a/executor/crates/common/src/domain.rs b/executor/crates/common/src/domain.rs index 48909202..9aa772b0 100644 --- a/executor/crates/common/src/domain.rs +++ b/executor/crates/common/src/domain.rs @@ -29,6 +29,9 @@ pub enum ExecutionEmission { fee_params: fees::InternalMessageParams, subtree: bytes::Bytes, + /// Chain `useBalance`: the fee is drawn from the emitting contract's + /// balance rather than the sender's prefunded message-fee pool. + use_balance: bool, }, DeployContract { calldata: genlayer_calldata::codec::Maybe, @@ -41,6 +44,8 @@ pub enum ExecutionEmission { fee_params: fees::InternalMessageParams, subtree: bytes::Bytes, + /// Chain `useBalance`; see `ExecutionEmission::PostMessage::use_balance`. + use_balance: bool, }, EmitEvent { topics: Vec, diff --git a/executor/crates/common/src/domain/fees.rs b/executor/crates/common/src/domain/fees.rs index 2c54c974..dbda93ce 100644 --- a/executor/crates/common/src/domain/fees.rs +++ b/executor/crates/common/src/domain/fees.rs @@ -3,42 +3,10 @@ use primitive_types::U256; mod abi; -#[derive( - Debug, - Clone, - PartialEq, - Eq, - serde::Serialize, - serde::Deserialize, - genlayer_calldata::Encode, - genlayer_calldata::Decode, -)] -pub struct InternalMessageParams { - pub leader_timeunits_allocation: U256, - pub validator_timeunits_allocation: U256, - pub execution_budget_per_round: U256, - /// Per-round rotation allocations; `rotations[0]` is the initial round, the - /// rest are appeal rounds. Must be non-empty. - /// - /// The chain's `InternalMessageFeeParams` carries an explicit `appealRounds` - /// field, but it is not stored here: the chain enforces - /// `appealRounds == rotations.length - 1` (`FeesVerifier.InvalidAppealRounds`), - /// so we derive `appeal_rounds = rotations.len() - 1` instead. The ABI - /// encoder re-inserts it so the encoded bytes (and their `keccak256` fee-param - /// pin) match the chain's field layout. - pub rotations: Vec, - /// Per-time-unit GEN price cap locked at activation (consensus CON-549, - /// v0.6-dev). The chain charges at this cap as the funding multiplier and - /// cancels the tx if the global price exceeds it; `MessagePayments` requires - /// it to be non-zero for internal messages. - pub max_price_gen_per_time_unit: U256, - /// Max gas price applied to the storage-fee component (v0.6-dev). Revert - /// guard in `_calculateRoundFees`; must be non-zero for internal messages. - pub storage_fee_max_gas_price: U256, - /// Max gas price applied to the receipt-fee component (v0.6-dev). Revert - /// guard in `_calculateRoundFees`; must be non-zero for internal messages. - pub receipt_fee_max_gas_price: U256, -} +/// Balance-funded internal messages carry these params from the guest, so the +/// type is guest-facing and lives in sdk-rs; re-export it here to keep the +/// executor-side `genvm_common::domain::fees::InternalMessageParams` path stable. +pub use genlayer_sdk::abi::fees::InternalMessageParams; #[derive( Debug, diff --git a/executor/crates/sdk-rs/src/abi/arb.rs b/executor/crates/sdk-rs/src/abi/arb.rs index c94748a0..60d986ed 100644 --- a/executor/crates/sdk-rs/src/abi/arb.rs +++ b/executor/crates/sdk-rs/src/abi/arb.rs @@ -19,6 +19,13 @@ pub fn arb_vec_bytes(u: &mut arbitrary::Unstructured) -> arbitrary::Result arbitrary::Result> { + let len = u.int_in_range(0..=4u8)?; + (0..len).map(|_| arb_u256(u)).collect() +} + pub fn arb_btreemap_bytes( u: &mut arbitrary::Unstructured, ) -> arbitrary::Result> { diff --git a/executor/crates/sdk-rs/src/abi/consts.rs b/executor/crates/sdk-rs/src/abi/consts.rs index 2f4e6e58..9f6c38af 100644 --- a/executor/crates/sdk-rs/src/abi/consts.rs +++ b/executor/crates/sdk-rs/src/abi/consts.rs @@ -151,6 +151,44 @@ impl TryFrom for EntryKind { } } } +#[derive( + Debug, + PartialEq, + Clone, + Copy, + Serialize, + Deserialize, + ::genlayer_calldata::Encode, + ::genlayer_calldata::Decode, +)] +#[repr(u32)] +pub enum Permissions { + CanUseBalanceForMessageFees = 1, +} + +impl Permissions { + pub fn value(self) -> u32 { + match self { + Permissions::CanUseBalanceForMessageFees => 1, + } + } + pub fn str_snake_case(self) -> &'static str { + match self { + Permissions::CanUseBalanceForMessageFees => "can_use_balance_for_message_fees", + } + } +} + +impl TryFrom for Permissions { + type Error = (); + + fn try_from(value: u32) -> Result { + match value { + 1 => Ok(Permissions::CanUseBalanceForMessageFees), + _ => Err(()), + } + } +} pub mod memory_limiter_consts { pub const TABLE_ENTRY: u32 = 64; pub const FILE_MAPPING: u32 = 256; @@ -164,6 +202,7 @@ pub mod root_offsets { pub const LOCKED_SLOTS: u32 = 3; pub const UPGRADERS: u32 = 4; pub const CODE_SLOT: u32 = 5; + pub const PERMISSIONS: u32 = 37; } pub mod top_limits { @@ -260,11 +299,17 @@ pub mod __VmError { pub const fn event(&self) -> VmError { VmError(Cow::Borrowed("out_of receipt event")) } } + pub struct OutOfMessageFee; + + impl OutOfMessageFee { + pub const fn total(&self) -> VmError { VmError(Cow::Borrowed("out_of message_fee total")) } + pub const fn node(&self) -> VmError { VmError(Cow::Borrowed("out_of message_fee node")) } + } + pub struct OutOf; impl OutOf { pub const fn storage(&self) -> VmError { VmError(Cow::Borrowed("out_of storage")) } - pub const fn message_fee(&self) -> VmError { VmError(Cow::Borrowed("out_of message_fee")) } pub const fn vm_recursion(&self) -> VmError { VmError(Cow::Borrowed("out_of vm_recursion")) } pub const fn nondet_blocks(&self) -> VmError { VmError(Cow::Borrowed("out_of nondet_blocks")) } pub const fn locked_slots(&self) -> VmError { VmError(Cow::Borrowed("out_of locked_slots")) } @@ -272,13 +317,15 @@ pub mod __VmError { pub const fn fds(&self) -> VmError { VmError(Cow::Borrowed("out_of fds")) } pub const fn memory(&self) -> OutOfMemory { OutOfMemory } pub const fn receipt(&self) -> OutOfReceipt { OutOfReceipt } + pub const fn message_fee(&self) -> OutOfMessageFee { OutOfMessageFee } } pub struct Fee; impl Fee { pub const fn no_matching_node(&self) -> VmError { VmError(Cow::Borrowed("fee no_matching_node")) } - pub const fn below_minimal(&self) -> VmError { VmError(Cow::Borrowed("fee below_minimal")) } + pub const fn below_minimum(&self) -> VmError { VmError(Cow::Borrowed("fee below_minimum")) } + pub const fn too_many_rounds(&self) -> VmError { VmError(Cow::Borrowed("fee too_many_rounds")) } } pub struct Evm; diff --git a/executor/crates/sdk-rs/src/abi/fees.rs b/executor/crates/sdk-rs/src/abi/fees.rs new file mode 100644 index 00000000..e7c91997 --- /dev/null +++ b/executor/crates/sdk-rs/src/abi/fees.rs @@ -0,0 +1,54 @@ +//! Guest-facing message-fee parameter types. +//! +//! [`InternalMessageParams`] lives here (rather than in `genvm_common`) so that +//! [`gl_call`](super::gl_call) can reference it: a guest emitting a +//! balance-funded internal message ships these params, and GenVM meters the fee +//! from them. `genvm_common::domain::fees` re-exports the type for the +//! executor-side call sites, so those paths are unchanged. + +use primitive_types::U256; + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + genlayer_calldata::Encode, + genlayer_calldata::Decode, +)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct InternalMessageParams { + #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] + pub leader_timeunits_allocation: U256, + #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] + pub validator_timeunits_allocation: U256, + #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] + pub execution_budget_per_round: U256, + /// Per-round rotation allocations; `rotations[0]` is the initial round, the + /// rest are appeal rounds. Must be non-empty. + /// + /// The chain's `InternalMessageFeeParams` carries an explicit `appealRounds` + /// field, but it is not stored here: the chain enforces + /// `appealRounds == rotations.length - 1` (`FeesVerifier.InvalidAppealRounds`), + /// so we derive `appeal_rounds = rotations.len() - 1` instead. The ABI + /// encoder re-inserts it so the encoded bytes (and their `keccak256` fee-param + /// pin) match the chain's field layout. + #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_vec_u256))] + pub rotations: Vec, + /// Per-time-unit GEN price cap locked at activation (consensus CON-549, + /// v0.6-dev). The chain charges at this cap as the funding multiplier and + /// cancels the tx if the global price exceeds it; `MessagePayments` requires + /// it to be non-zero for internal messages. + #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] + pub max_price_gen_per_time_unit: U256, + /// Max gas price applied to the storage-fee component (v0.6-dev). Revert + /// guard in `_calculateRoundFees`; must be non-zero for internal messages. + #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] + pub storage_fee_max_gas_price: U256, + /// Max gas price applied to the receipt-fee component (v0.6-dev). Revert + /// guard in `_calculateRoundFees`; must be non-zero for internal messages. + #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] + pub receipt_fee_max_gas_price: U256, +} diff --git a/executor/crates/sdk-rs/src/abi/gl_call.rs b/executor/crates/sdk-rs/src/abi/gl_call.rs index 78d571ca..596f147f 100644 --- a/executor/crates/sdk-rs/src/abi/gl_call.rs +++ b/executor/crates/sdk-rs/src/abi/gl_call.rs @@ -10,6 +10,19 @@ use serde::{Deserialize, Serialize}; use crate::calldata; use super::consts as public_abi; +use super::fees; + +/// Calldata `default` for the backward-compatible `use_balance` flag: an old +/// encoding without the key decodes as `false`. +fn default_false() -> bool { + false +} + +/// Calldata `default` for the backward-compatible optional `fee_params`: an old +/// encoding without the key decodes as `None`. +fn default_none() -> Option { + None +} /// Web module interface types for WebRender and WebRequest operations. pub mod web_iface { @@ -541,6 +554,15 @@ pub enum Message { #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] value: primitive_types::U256, on: On, + /// Chain `useBalance`: fund this message's fee from the emitting + /// contract's balance instead of the sender's prefunded pool. Requires + /// `fee_params` and the `can_use_balance_for_message_fees` permission. + #[calldata(default = default_false)] + use_balance: bool, + /// Guest-supplied fee params GenVM meters the (balance-funded) fee from; + /// only honored when `use_balance` is set. + #[calldata(default = default_none)] + fee_params: Option, }, DeployContract { calldata: calldata::unparsed::Maybe, @@ -551,6 +573,12 @@ pub enum Message { on: On, #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_u256))] salt_nonce: primitive_types::U256, + /// Chain `useBalance` for the deploy message; see `PostMessage::use_balance`. + #[calldata(default = default_false)] + use_balance: bool, + /// Guest fee params for the balance-funded deploy; see `PostMessage::fee_params`. + #[calldata(default = default_none)] + fee_params: Option, }, EmitEvent { #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_vec_bytes))] diff --git a/executor/crates/sdk-rs/src/abi/mod.rs b/executor/crates/sdk-rs/src/abi/mod.rs index a103411b..66676022 100644 --- a/executor/crates/sdk-rs/src/abi/mod.rs +++ b/executor/crates/sdk-rs/src/abi/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod arb; pub mod consts; pub mod entry; +pub mod fees; pub mod gl_call; #[cfg(feature = "wasi")] diff --git a/executor/crates/sdk-rs/tests/gl_call_balance_fees.rs b/executor/crates/sdk-rs/tests/gl_call_balance_fees.rs new file mode 100644 index 00000000..f2df61a9 --- /dev/null +++ b/executor/crates/sdk-rs/tests/gl_call_balance_fees.rs @@ -0,0 +1,137 @@ +//! Backward-compat / round-trip coverage for the balance-funded fee fields +//! (`use_balance`, `fee_params`) added to `PostMessage` / `DeployContract`. + +use genlayer_sdk::abi::fees::InternalMessageParams; +use genlayer_sdk::abi::gl_call::{Message, On}; +use genlayer_sdk::calldata::codec::{BinaryDeserializer, Decode}; +use genlayer_sdk::calldata::unparsed::Maybe; +use genlayer_sdk::calldata::{self, Encoder, Value}; +use primitive_types::U256; + +macro_rules! encode { + ($v:expr) => {{ + let mut buf = Vec::new(); + calldata::codec::Encode::encode(&$v, &mut Encoder::new(&mut buf)) + .unwrap_or_else(|_| unreachable!()); + buf + }}; +} + +fn sample_params() -> InternalMessageParams { + InternalMessageParams { + leader_timeunits_allocation: U256::from(5u64), + validator_timeunits_allocation: U256::from(7u64), + execution_budget_per_round: U256::from(1024u64), + rotations: vec![U256::from(4u64); 5], + max_price_gen_per_time_unit: U256::from(9u64), + storage_fee_max_gas_price: U256::from(20u64), + receipt_fee_max_gas_price: U256::from(20u64), + } +} + +/// A message carrying the two new fields round-trips through the calldata codec. +#[test] +fn post_message_balance_fields_round_trip() { + let params = sample_params(); + let msg = Message::PostMessage { + address: calldata::Address::zero(), + calldata: Maybe::Materialized(Value::Null), + value: U256::from(42u64), + on: On::Finalized, + use_balance: true, + fee_params: Some(params.clone()), + }; + + let decoded: Message = Decode::decode(BinaryDeserializer::new(&encode!(msg))).unwrap(); + + match decoded { + Message::PostMessage { + use_balance, + fee_params, + value, + .. + } => { + assert!(use_balance); + assert_eq!(fee_params, Some(params)); + assert_eq!(value, U256::from(42u64)); + } + other => panic!("expected PostMessage, got {other:?}"), + } +} + +/// A `PostMessage` map produced before the fields existed (no `use_balance` / +/// `fee_params` keys) decodes with the defaults. +#[test] +fn post_message_old_encoding_defaults() { + // Mirrors the pre-feature `PostMessage` variant (same wire name and fields), + // so encoding it yields exactly what an old SDK would emit. + #[derive(calldata::Encode)] + enum OldMessage { + #[allow(dead_code)] + PostMessage { + address: calldata::Address, + calldata: Maybe, + value: U256, + on: On, + }, + } + + let old = OldMessage::PostMessage { + address: calldata::Address::zero(), + calldata: Maybe::Materialized(Value::Null), + value: U256::from(1u64), + on: On::Accepted, + }; + + let decoded: Message = Decode::decode(BinaryDeserializer::new(&encode!(old))).unwrap(); + + match decoded { + Message::PostMessage { + use_balance, + fee_params, + .. + } => { + assert!(!use_balance); + assert!(fee_params.is_none()); + } + other => panic!("expected PostMessage, got {other:?}"), + } +} + +/// Same backward-compat guarantee for `DeployContract`. +#[test] +fn deploy_contract_old_encoding_defaults() { + #[derive(calldata::Encode)] + enum OldMessage { + #[allow(dead_code)] + DeployContract { + calldata: Maybe, + code: Maybe, + value: U256, + on: On, + salt_nonce: U256, + }, + } + + let old = OldMessage::DeployContract { + calldata: Maybe::Materialized(Value::Null), + code: Maybe::Materialized(Value::Bytes(b"code".to_vec())), + value: U256::zero(), + on: On::Finalized, + salt_nonce: U256::from(3u64), + }; + + let decoded: Message = Decode::decode(BinaryDeserializer::new(&encode!(old))).unwrap(); + + match decoded { + Message::DeployContract { + use_balance, + fee_params, + .. + } => { + assert!(!use_balance); + assert!(fee_params.is_none()); + } + other => panic!("expected DeployContract, got {other:?}"), + } +} diff --git a/executor/install/config/genvm.yaml b/executor/install/config/genvm.yaml index 9cbd2f2d..a51f70a5 100644 --- a/executor/install/config/genvm.yaml +++ b/executor/install/config/genvm.yaml @@ -30,6 +30,20 @@ fees: # Optional: # node.validatorsPerRound[] - validator count per round # (defaults to defaultValidatorsPerRound below) + # node.minTimeUnitsPerPhase - minimum per-phase timeunits an emitted + # internal message's allocation must fund + # (leader->propose, validator->commit); a + # matched message below it is rejected at + # emission with `fee below_minimum` + # (defaults to 0 = no floor) + # node.messageBudgetFloor - minimum non-zero per-round execution + # budget a balance-funded internal message + # may declare; the chain reverts + # `BudgetTooLow` below it. A message whose + # executionBudgetPerRound is in (0, floor) + # is rejected at emission with + # `fee below_minimum` + # (defaults to 0 = no floor) # # GenVM consumption points can consume multiple buckets, but as a fee only first is reported to the node # i.e. message_receipt consumes from execution_data_gas & submitted_messages @@ -60,13 +74,23 @@ fees: else defaultValidatorsPerRound in # leaf-node message fee floor = minPrimaryFees * lifecycleMultiplier # feeParams: object { leaderTimeunitsAllocation, - # validatorTimeunitsAllocation, executionBudgetPerRound, rotations[] } + # validatorTimeunitsAllocation, executionBudgetPerRound, rotations[], + # maxPriceGenPerTimeUnit } # appealRounds = len(rotations) - 1 # onAcceptance: per-message lifecycle flag (bool) - # uses validatorsPerRound[] (node or default), node.genPerTimeUnit - let messageFeeFloor = \feeParams onAcceptance = + # balanceFunded: true for use_balance messages (bool) + # uses validatorsPerRound[] (node or default) and, as the consensus-term + # multiplier, either the guest cap (balance-funded) or node.genPerTimeUnit. + let messageFeeFloor = \feeParams onAcceptance balanceFunded = let rotations = feeParams.rotations in let appealRounds = arrayLen rotations - 1 in + # rounds span 0 .. 2*appealRounds and index validatorsPerRound[round]; a + # message with more appeal rounds than the validator table supports would + # overrun the array (internal abort). The bound is node-config-dependent, + # so it is enforced here rather than in the SDK. On-chain such a message + # exceeds MAX_ROUNDS. + if 2 * appealRounds >= arrayLen validatorsPerRound then vmError "fee too_many_rounds" + else let leaderTU = feeParams.leaderTimeunitsAllocation in let validatorTU = feeParams.validatorTimeunitsAllocation in let execBudgetPerRound = feeParams.executionBudgetPerRound in @@ -87,11 +111,18 @@ fees: + appealRounds in # 4. execution-budget term let executionTerm = execBudgetPerRound * leaderRounds in - # 5. minimum primary fees (caps forced to zero by construction) + # 5. consensus-term multiplier. Chain `_calculateRoundFees` charges the + # consensus term at maxPriceGenPerTimeUnit (the funding cap) for + # balance-funded messages; the allocation path keeps the historical + # node.genPerTimeUnit behaviour. Storage/receipt caps stay out of the + # floor: `feeParamsToFeesDistribution` zeroes them so their + # revert-on-exceed guards never fire during this calc. + let multiplier = if balanceFunded then feeParams.maxPriceGenPerTimeUnit else node.genPerTimeUnit in + # 6. minimum primary fees let minPrimaryFees = - (if node.genPerTimeUnit > 0 then node.genPerTimeUnit * consensusTerm else consensusTerm) + (if multiplier > 0 then multiplier * consensusTerm else consensusTerm) + executionTerm in - # 6. lifecycle multiplier + # 7. lifecycle multiplier let lifecycleMultiplier = if onAcceptance then appealRounds + 1 else 1 in minPrimaryFees * lifecycleMultiplier in @@ -143,8 +174,27 @@ fees: 0 delta_expr: | \a = + # per-phase timeunit floor (leader->propose, validator->commit): a matched + # internal message whose child timeunits fall below the node minimum would + # revert PhaseTimeoutOutOfBounds at child creation on-chain, so reject it at + # emission instead of silently dropping it. Absent constant => no floor. + let minTimeUnits = if hasKey node "minTimeUnitsPerPhase" then node.minTimeUnitsPerPhase else 0 in + # a non-zero per-round budget below the node floor reverts `BudgetTooLow` + # at reveal on-chain; reject it at emission instead. Absent constant => no + # floor. + let budgetFloor = if hasKey node "messageBudgetFloor" then node.messageBudgetFloor else 0 in + # reject only a non-zero per-round budget below the floor (matches the + # chain's `execBudget > 0 && execBudget < floor`) + let budgetTooLow = + if a.matchedFeeParams.executionBudgetPerRound > 0 + then a.matchedFeeParams.executionBudgetPerRound < budgetFloor + else false in if a.isInternal - then messageFeeFloor a.matchedFeeParams a.onAcceptance + then + if a.matchedFeeParams.leaderTimeunitsAllocation < minTimeUnits then vmError "fee below_minimum" + else if a.matchedFeeParams.validatorTimeunitsAllocation < minTimeUnits then vmError "fee below_minimum" + else if budgetTooLow then vmError "fee below_minimum" + else messageFeeFloor a.matchedFeeParams a.onAcceptance a.balanceFunded # external messages reserve the worst-case L1 gas cost: gasLimit * maxGasPrice # (matches the chain's `budget == k * (gasLimit * maxGasPrice)` requirement) else a.matchedFeeParams.gasLimit * a.matchedFeeParams.maxGasPrice diff --git a/executor/src/lib.rs b/executor/src/lib.rs index 5f942774..13b59c4a 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -165,8 +165,12 @@ pub async fn run_with_impl( ), ); - let topmost_runner_id = match async { - anyhow::Ok(if let Some(code) = &entry_data.code { + let (topmost_runner_id, root_permissions) = match async { + // Contract-owned permissions live in the root slot, not the node-granted + // `permissions` string; pre-read them before running the wasm. + let root_permissions = topmost_storage.read_permissions().await?; + + let id = if let Some(code) = &entry_data.code { log_debug!("using provided code for execution"); topmost_storage.write_code(code).await?; @@ -201,11 +205,13 @@ pub async fn run_with_impl( on: runners::ChainState::Accepted, slot: code_slot, } - }) + }; + + anyhow::Ok((id, root_permissions)) } .await { - Ok(id) => id, + Ok(v) => v, // 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 @@ -217,6 +223,10 @@ pub async fn run_with_impl( } }; + let can_use_balance_for_message_fees = + primitive_types::U256::from_little_endian(&root_permissions) + .bit(public_abi::Permissions::CanUseBalanceForMessageFees.value() as usize); + let data_fees_limit = supervisor.shared_data.gep(|x| &x.data_fees_limit); let essential_data = Box::new(wasi::genlayer_sdk::SingleVMData { @@ -231,6 +241,7 @@ pub async fn run_with_impl( can_call_others: permissions.contains("c"), can_spawn_nondet: permissions.contains("n"), can_register_runners: permissions.contains("u"), + can_use_balance_for_message_fees, state_mode: crate::public_abi::StorageType::Default, topmost_runner_id, }, diff --git a/executor/src/rt/fees.rs b/executor/src/rt/fees.rs index 6fb92ea6..93cb9f35 100644 --- a/executor/src/rt/fees.rs +++ b/executor/src/rt/fees.rs @@ -57,9 +57,10 @@ pub fn fee_params_value_internal( ); let rotations: Vec = p.rotations.iter().map(|r| num_u256(*r)).collect(); m.insert("rotations".to_owned(), Value::Array(Arc::new(rotations))); - // v0.6-dev (CON-549) price caps, exposed so the host-provided fee expression - // can charge at `maxPriceGenPerTimeUnit` as the funding multiplier and clamp - // the storage/receipt components, matching the chain's `_calculateRoundFees`. + // v0.6-dev (CON-549) price caps. `maxPriceGenPerTimeUnit` is the funding + // multiplier for balance-funded messages (chain `_calculateRoundFees`); the + // storage/receipt caps are exposed for completeness but stay out of the floor + // calc, mirroring `feeParamsToFeesDistribution` which zeroes them there. m.insert( "maxPriceGenPerTimeUnit".to_owned(), num_u256(p.max_price_gen_per_time_unit), @@ -302,7 +303,7 @@ impl DataLimit { prelude, &fees.message_fee, &node, - abi::consts::VmError::out_of().message_fee(), + abi::consts::VmError::out_of().message_fee().total(), )?; let event = build_bucket( prelude, @@ -495,15 +496,20 @@ impl DataLimit { ], )?; if self.consume_bucket_raw(&self.event, &costs.0).await { - Ok(Some(costs.sum())) + Ok(Some(costs.reported_fee())) } else { Ok(None) } } + /// `balance_funded` selects the chain's `minMessagePrimaryFees` multiplier: + /// balance-funded (`useBalance`) messages charge the consensus term at the + /// guest's `maxPriceGenPerTimeUnit` cap (per `_calculateRoundFees`), whereas + /// allocation-matched messages use the node's live `genPerTimeUnit`. pub fn calculate_message_fee_internal( &self, on: abi::gl_call::On, + balance_funded: bool, matched_fee_params: &genvm_common::domain::fees::InternalMessageParams, ) -> rt::errors::Result { self.calculate_bucket( @@ -511,6 +517,7 @@ impl DataLimit { &[ ("isInternal", true.into()), ("onAcceptance", (on == abi::gl_call::On::Accepted).into()), + ("balanceFunded", balance_funded.into()), ( "matchedFeeParams", fee_params_value_internal(matched_fee_params), @@ -574,6 +581,14 @@ impl DataLimit { true } + /// Consumes only the `message_receipt` bucket, leaving the `message_fee` + /// bucket untouched (balance-funded messages are excluded from the sender + /// pool on-chain). Atomic; returns `false` if the bucket cannot cover it. + pub async fn consume_message_receipt_only(&self, cost_receipt: &CostVec) -> bool { + self.consume_bucket_raw(&self.message_receipt, &cost_receipt.0) + .await + } + pub fn calculate_message_fee_external( &self, matched_fee_params: &genvm_common::domain::fees::ExternalMessageParams, diff --git a/executor/src/rt/memlimiter.rs b/executor/src/rt/memlimiter.rs index 7cf73748..08ae0f04 100644 --- a/executor/src/rt/memlimiter.rs +++ b/executor/src/rt/memlimiter.rs @@ -71,6 +71,16 @@ impl Limiter { })) } + /// Charges `delta` bytes, failing (rather than truncating) when `delta` + /// does not fit in the `u32` budget. A body larger than `u32::MAX` can never + /// fit the 4 GiB budget anyway, so this maps cleanly onto the OOM path. + pub fn consume_size(&self, delta: usize) -> bool { + match u32::try_from(delta) { + Ok(delta) => self.consume(delta), + Err(_) => false, + } + } + pub fn consume_mul(&self, delta: u32, multiplier: u32) -> bool { let delta = match delta.checked_mul(multiplier) { Some(delta) => delta, diff --git a/executor/src/rt/vm/storage.rs b/executor/src/rt/vm/storage.rs index 338f82e2..3707f25c 100644 --- a/executor/src/rt/vm/storage.rs +++ b/executor/src/rt/vm/storage.rs @@ -452,6 +452,16 @@ impl Storage { } } + /// Reads the contract's inline permission bitfield from the root slot as a + /// raw 32-byte little-endian `u256`; bit `n` is the `Permissions` member with + /// value `n`. Zero (never-written default) grants nothing. + pub async fn read_permissions(&mut self) -> rt::errors::Result<[u8; 32]> { + let mut raw = [0u8; 32]; + self.read(SlotID::ZERO, root_offsets::PERMISSIONS, &mut raw) + .await?; + Ok(raw) + } + /// Verifies the contract's major version matches this node's, then resolves /// its code slot. The major is checked *first*, before trusting the /// host-read code-slot pointer; on mismatch a `major_mismatch` [`Error`] diff --git a/executor/src/wasi/base.rs b/executor/src/wasi/base.rs index 8bc39177..5d065ea7 100644 --- a/executor/src/wasi/base.rs +++ b/executor/src/wasi/base.rs @@ -12,6 +12,7 @@ pub struct Config { pub can_send_messages: bool, pub can_call_others: bool, pub can_register_runners: bool, + pub can_use_balance_for_message_fees: bool, pub state_mode: public_abi::StorageType, pub topmost_runner_id: runners::Id, } diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs index e2fd4547..58cf21a8 100644 --- a/executor/src/wasi/genlayer_sdk.rs +++ b/executor/src/wasi/genlayer_sdk.rs @@ -34,31 +34,36 @@ struct ConsumeInternalArgs { subtree_length: u64, } +/// How an internal message's fee is funded. +enum FeeFunding<'a> { + /// Sender-pool allocation: fee capped by `node.budget`; consumes the + /// message-fee and receipt buckets. + Allocation(&'a mut domain::fees::MessageAllocationNode), + /// Balance-funded (`useBalance`): the metered fee is the `declaredBudget`, + /// reserved from the contract balance. On-chain such messages are excluded + /// from the sender pool, so the message-fee bucket is skipped and only the + /// receipt is charged. The balance is checked here, before the receipt is + /// consumed, so nothing is charged unless funding is guaranteed. + Balance { + value: primitive_types::U256, + messages_value_decremented: primitive_types::U256, + my_balance: primitive_types::U256, + }, +} + async fn consume_message_fee_internal( shared_data: &rt::SharedData, - node: &mut domain::fees::MessageAllocationNode, + funding: FeeFunding<'_>, fee_params: Arc, on: gl_call::On, args: ConsumeInternalArgs, ) -> Result { + let balance_funded = matches!(funding, FeeFunding::Balance { .. }); let fee_cost = shared_data .data_fees_limit - .calculate_message_fee_internal(on, &fee_params) + .calculate_message_fee_internal(on, balance_funded, &fee_params) .map_err(internal_trap)?; - - let fee_total = fee_cost.sum(); - if fee_total > node.budget { - log_warn!( - node:cd = *node, - fee_cost:cd = fee_total, - budget: cd = node.budget; - "message fee cost exceeds node budget" - ); - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().below_minimal(), - host_fns::VmErrorDetail::internal(), - ))); - } + let fee_total = fee_cost.reported_fee(); let receipt_cost = shared_data .data_fees_limit @@ -72,24 +77,76 @@ async fn consume_message_fee_internal( .ctx("calculate_message_receipt") .map_err(internal_trap)?; - if !shared_data - .data_fees_limit - .consume_message_fee(&fee_cost, &receipt_cost) - .await - { - log_warn!( - node:cd = *node, - fee_cost:cd = fee_total, - buckets:? = shared_data.data_fees_limit; - "not enough remaining fee limit to consume message fee" - ); - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::out_of().message_fee(), - host_fns::VmErrorDetail::internal(), - ))); - } + match funding { + FeeFunding::Allocation(node) => { + if fee_total > node.budget { + log_warn!( + node:cd = *node, + fee_cost:cd = fee_total, + budget: cd = node.budget; + "message fee cost exceeds node budget" + ); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee().node(), + host_fns::VmErrorDetail::internal(), + ))); + } - node.budget -= fee_total; + if !shared_data + .data_fees_limit + .consume_message_fee(&fee_cost, &receipt_cost) + .await + { + log_warn!( + node:cd = *node, + fee_cost:cd = fee_total, + buckets:? = shared_data.data_fees_limit; + "not enough remaining fee limit to consume message fee" + ); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee().total(), + host_fns::VmErrorDetail::internal(), + ))); + } + + node.budget -= fee_total; + } + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + } => { + // `value + fee_total` can itself overflow U256. + let Some(total) = value.checked_add(fee_total) else { + return Err(generated::types::Errno::Inbalance.into()); + }; + if !checked_sum_le(total, messages_value_decremented, my_balance) { + log_warn!( + fee_cost:cd = fee_total, + value:cd = value, + my_balance:cd = my_balance; + "insufficient balance to fund balance-funded message fee" + ); + return Err(generated::types::Errno::Inbalance.into()); + } + + if !shared_data + .data_fees_limit + .consume_message_receipt_only(&receipt_cost) + .await + { + log_warn!( + receipt_cost:cd = receipt_cost.reported_fee(), + buckets:? = shared_data.data_fees_limit; + "not enough remaining receipt limit to consume message receipt" + ); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().receipt().message(), + host_fns::VmErrorDetail::internal(), + ))); + } + } + } Ok(rt::fees::MessageFeeConsumption { message_fee: fee_cost, @@ -117,10 +174,10 @@ async fn consume_message_fee_external( .calculate_message_fee_external(¶ms) .map_err(internal_trap)?; - let fee_total = fee_cost.sum(); + let fee_total = fee_cost.reported_fee(); if fee_total > node.budget { return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().below_minimal(), + abi::consts::VmError::out_of().message_fee().node(), host_fns::VmErrorDetail::external(), ))); } @@ -142,7 +199,7 @@ async fn consume_message_fee_external( .await { return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::out_of().message_fee(), + abi::consts::VmError::out_of().message_fee().total(), host_fns::VmErrorDetail::external(), ))); } @@ -155,6 +212,88 @@ async fn consume_message_fee_external( }) } +/// Magnitude bounds (in significant bits; a larger field is rejected) on +/// guest-supplied fee params. Invariant the code cannot express: the worst-case +/// `messageFeeFloor` product must stay within U256, since the fee evaluator's +/// `rational_to_u256` treats overflow as an internal abort. Three guest fields +/// multiply into one floor term (`maxPrice × rotations entry × validatorTU`), +/// so with the default 18-round validator table (counts ≤ 1537 < 2^11) the +/// accepted-lifecycle worst case is +/// lifecycle(<2^4) × [price(<2^96) × rounds(<2^5) × rot(<2^33) +/// × (leaderTU + vpr × validatorTU)(<2^44) +/// + price(<2^96) × leaderRounds(<2^36)] +/// < 2^183 ≪ 2^256. +/// Economically generous: 2^96 atto-GEN ≈ 8e10 GEN for prices/budgets; 2^32 +/// for counts (time units per phase, rotations per round). +const FEE_PARAM_PRICE_BITS: usize = 96; +const FEE_PARAM_COUNT_BITS: usize = 32; + +/// Validates the balance-funded fee fields (`use_balance` / `fee_params`) shared +/// by `PostMessage` and `DeployContract` (GVM-281 §4.2). Returns the fee params +/// to meter against the contract balance when `use_balance` is set, or `None` +/// for the ordinary allocation-matched path. +/// +/// Guest params are structurally validated so no guest input can drive the fee +/// evaluator into an internal abort or emit a message the chain would reject at +/// reveal: `rotations` must be non-empty (the yaml floor derives +/// `appealRounds = len - 1` and would go negative otherwise), the three +/// price caps must be non-zero (the chain reverts `FeeValueMustBeNonZero(4/5/6)` +/// at reveal), and every numeric field — rotations entries included — is +/// bounded by [`FEE_PARAM_PRICE_BITS`] / [`FEE_PARAM_COUNT_BITS`] so the +/// metered floor fits in U256. The rotations-length upper bound is +/// node-config-dependent (validator table length) and is enforced in the yaml +/// fee expression instead. +fn validate_balance_fee( + can_use_balance: bool, + use_balance: bool, + fee_params: Option, +) -> Result, generated::types::Error> { + match (use_balance, fee_params) { + (true, _) if !can_use_balance => { + log_debug!("balance-funded message rejected: permission absent (Forbidden)"); + Err(generated::types::Errno::Forbidden.into()) + } + (true, None) => { + // this is a feature for future + log_debug!("balance-funded message rejected: fee_params absent (Inval)"); + Err(generated::types::Errno::Inval.into()) + } + (true, Some(params)) => { + if params.rotations.is_empty() { + log_debug!("balance-funded message rejected: rotations empty (Inval)"); + return Err(generated::types::Errno::Inval.into()); + } + if params.max_price_gen_per_time_unit.is_zero() + || params.storage_fee_max_gas_price.is_zero() + || params.receipt_fee_max_gas_price.is_zero() + { + log_debug!("balance-funded message rejected: zero price cap (Inval)"); + return Err(generated::types::Errno::Inval.into()); + } + let too_large = params.max_price_gen_per_time_unit.bits() > FEE_PARAM_PRICE_BITS + || params.storage_fee_max_gas_price.bits() > FEE_PARAM_PRICE_BITS + || params.receipt_fee_max_gas_price.bits() > FEE_PARAM_PRICE_BITS + || params.execution_budget_per_round.bits() > FEE_PARAM_PRICE_BITS + || params.leader_timeunits_allocation.bits() > FEE_PARAM_COUNT_BITS + || params.validator_timeunits_allocation.bits() > FEE_PARAM_COUNT_BITS + || params + .rotations + .iter() + .any(|r| r.bits() > FEE_PARAM_COUNT_BITS); + if too_large { + log_debug!("balance-funded message rejected: fee param too large (Inval)"); + return Err(generated::types::Errno::Inval.into()); + } + Ok(Some(params)) + } + (false, Some(_)) => { + log_debug!("message rejected: fee_params without use_balance (Inval)"); + Err(generated::types::Errno::Inval.into()) + } + (false, None) => Ok(None), + } +} + async fn consume_nondet_output( shared_data: &rt::SharedData, output_length: u64, @@ -770,6 +909,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { can_call_others: my_conf.can_call_others, can_send_messages: false, can_register_runners: my_conf.can_register_runners, + can_use_balance_for_message_fees: false, state_mode: state, topmost_runner_id: runners::Id::Chain { address, @@ -908,10 +1048,13 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { calldata, value, on, + use_balance, + fee_params, } => { log_debug!( recipient = address, - on:? = on; + on:? = on, + use_balance = use_balance; "PostMessage dispatched" ); if !self.context.data.conf.is_deterministic { @@ -923,7 +1066,11 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { return Err(generated::types::Errno::Forbidden.into()); } - let sd = self.context.data.supervisor.shared_data.clone(); + let balance_params = validate_balance_fee( + self.context.data.conf.can_use_balance_for_message_fees, + use_balance, + fee_params, + )?; // Peek the (possibly deferred) calldata to derive the fee call key. let calldata_materialized = calldata.clone().materialize().ok(); @@ -938,6 +1085,63 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { abi::CallKey::UNNAMED }; + if let Some(params) = balance_params { + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc) + .unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; + + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + let messages_value_decremented = + self.context.data.accumulator.messages_value_decremented; + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + }, + Arc::new(params.clone()), + on, + ConsumeInternalArgs { + is_deploy: false, + calldata_length, + code_length: 0, + subtree_length: 0, + }, + ) + .await?; + + let metered_fee = fees.message_fee.reported_fee(); + + // Empty subtree: use_balance nesting is fail-closed on-chain. + self.context.data.accumulator.emissions.push( + domain::ExecutionEmission::PostMessage { + call_key, + address, + calldata, + value, + on, + message_fee: metered_fee, + receipt_fee: fees.receipt_fee.reported_fee(), + fee_params: params, + subtree: bytes::Bytes::new(), + use_balance: true, + }, + ); + + self.context.data.accumulator.messages_value_decremented = + messages_value_decremented + .saturating_add(value) + .saturating_add(metered_fee); + + return Ok(file_fd_none()); + } + if !value.is_zero() { let my_balance = self .context @@ -995,7 +1199,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, - matched_node, + FeeFunding::Allocation(matched_node), matched_params, on, ConsumeInternalArgs { @@ -1018,6 +1222,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { receipt_fee: fees.receipt_fee.reported_fee(), fee_params, subtree, + use_balance: false, }, ); @@ -1042,6 +1247,8 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { value, on, salt_nonce, + use_balance, + fee_params, } => { if !self.context.data.conf.is_deterministic { return Err(generated::types::Errno::Forbidden.into()); @@ -1050,7 +1257,68 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { return Err(generated::types::Errno::Forbidden.into()); } - let sd = self.context.data.supervisor.shared_data.clone(); + let balance_params = validate_balance_fee( + self.context.data.conf.can_use_balance_for_message_fees, + use_balance, + fee_params, + )?; + + if let Some(params) = balance_params { + let code_length = code.len() as u64; + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc) + .unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; + + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + let messages_value_decremented = + self.context.data.accumulator.messages_value_decremented; + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + }, + Arc::new(params.clone()), + on, + ConsumeInternalArgs { + is_deploy: true, + calldata_length, + code_length, + subtree_length: 0, + }, + ) + .await?; + + let metered_fee = fees.message_fee.reported_fee(); + + self.context.data.accumulator.emissions.push( + domain::ExecutionEmission::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + receipt_fee: fees.receipt_fee.reported_fee(), + message_fee: metered_fee, + fee_params: params, + subtree: bytes::Bytes::new(), + use_balance: true, + }, + ); + + self.context.data.accumulator.messages_value_decremented = + messages_value_decremented + .saturating_add(value) + .saturating_add(metered_fee); + + return Ok(file_fd_none()); + } if !value.is_zero() { let my_balance = self @@ -1103,7 +1371,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, - matched_node, + FeeFunding::Allocation(matched_node), matched_params, on, ConsumeInternalArgs { @@ -1126,6 +1394,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { message_fee: fees.message_fee.reported_fee(), fee_params, subtree, + use_balance: false, }, ); @@ -1877,6 +2146,7 @@ impl ContextVFS<'_> { can_call_others: false, can_send_messages: false, can_register_runners: false, + can_use_balance_for_message_fees: false, state_mode: public_abi::StorageType::Default, topmost_runner_id: self.context.data.conf.topmost_runner_id.clone(), }, @@ -1978,6 +2248,7 @@ impl ContextVFS<'_> { can_call_others: false, can_send_messages: zelf_conf.can_send_messages & allow_send_messages, can_register_runners: zelf_conf.can_register_runners & allow_register_runners, + can_use_balance_for_message_fees: zelf_conf.can_use_balance_for_message_fees, state_mode: zelf_conf.state_mode, topmost_runner_id, }, @@ -2109,3 +2380,120 @@ mod gl_call_to_mi { } } } + +#[cfg(test)] +mod tests { + use super::*; + use primitive_types::U256; + + fn valid_params() -> domain::fees::InternalMessageParams { + domain::fees::InternalMessageParams { + leader_timeunits_allocation: U256::from(5), + validator_timeunits_allocation: U256::from(5), + execution_budget_per_round: U256::from(1024), + rotations: vec![U256::from(4); 5], + max_price_gen_per_time_unit: U256::from(2), + storage_fee_max_gas_price: U256::from(20), + receipt_fee_max_gas_price: U256::from(20), + } + } + + fn errno(e: generated::types::Error) -> generated::types::Errno { + e.downcast().expect("expected a plain errno, got a trap") + } + + #[test] + fn balance_no_permission_is_forbidden() { + let err = validate_balance_fee(false, true, Some(valid_params())).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Forbidden); + } + + #[test] + fn balance_without_params_is_inval() { + let err = validate_balance_fee(true, true, None).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); + } + + #[test] + fn params_without_use_balance_is_inval() { + let err = validate_balance_fee(true, false, Some(valid_params())).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); + } + + #[test] + fn empty_rotations_is_inval() { + let mut p = valid_params(); + p.rotations.clear(); + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); + } + + #[test] + fn zero_price_caps_are_inval() { + for mutate in [ + (|p: &mut domain::fees::InternalMessageParams| { + p.max_price_gen_per_time_unit = U256::zero() + }) as fn(&mut domain::fees::InternalMessageParams), + |p| p.storage_fee_max_gas_price = U256::zero(), + |p| p.receipt_fee_max_gas_price = U256::zero(), + ] { + let mut p = valid_params(); + mutate(&mut p); + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); + } + } + + #[test] + fn huge_magnitude_params_are_inval() { + // Security-review N1 repro: passes the emptiness/zero checks, but the + // 2^250 magnitudes would push messageFeeFloor past U256 and trip the + // evaluator's internal `fee cost exceeds U256 range` abort. + let p = domain::fees::InternalMessageParams { + leader_timeunits_allocation: U256::one() << 250, + validator_timeunits_allocation: U256::zero(), + execution_budget_per_round: U256::zero(), + rotations: vec![U256::zero()], + max_price_gen_per_time_unit: U256::one() << 250, + storage_fee_max_gas_price: U256::from(20), + receipt_fee_max_gas_price: U256::from(20), + }; + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); + } + + #[test] + fn huge_rotations_entry_is_inval() { + let mut p = valid_params(); + p.rotations[2] = U256::one() << 250; + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); + } + + #[test] + fn params_at_magnitude_bounds_pass() { + let mut p = valid_params(); + p.max_price_gen_per_time_unit = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.storage_fee_max_gas_price = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.receipt_fee_max_gas_price = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.execution_budget_per_round = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.leader_timeunits_allocation = (U256::one() << FEE_PARAM_COUNT_BITS) - 1; + p.validator_timeunits_allocation = (U256::one() << FEE_PARAM_COUNT_BITS) - 1; + p.rotations = vec![(U256::one() << FEE_PARAM_COUNT_BITS) - 1; 5]; + let got = validate_balance_fee(true, true, Some(p.clone())).unwrap(); + assert_eq!(got, Some(p)); + } + + #[test] + fn valid_balance_params_pass_through() { + let p = valid_params(); + let got = validate_balance_fee(true, true, Some(p.clone())).unwrap(); + assert_eq!(got, Some(p)); + } + + #[test] + fn no_balance_no_params_is_allocation_path() { + let got = validate_balance_fee(true, false, None).unwrap(); + assert_eq!(got, None); + } +} diff --git a/executor/src/wasi/vfs.rs b/executor/src/wasi/vfs.rs index bc908231..cfada934 100644 --- a/executor/src/wasi/vfs.rs +++ b/executor/src/wasi/vfs.rs @@ -64,7 +64,7 @@ impl VFS { pub fn new(stdin: Vec, limiter: rt::memlimiter::Limiter) -> rt::errors::Result { log_trace!(stdin:bytes = stdin; "creating VFS"); - if !limiter.consume(stdin.len() as u32) { + if !limiter.consume_size(stdin.len()) { return Err(rt::errors::Error::vm( abi::consts::VmError::out_of().memory().val(), )); @@ -140,7 +140,7 @@ impl VFS { } pub fn place_content(&mut self, value: FileContents) -> anyhow::Result { - if value.release_memory && !self.limiter.consume(value.contents.len() as u32) { + if value.release_memory && !self.limiter.consume_size(value.contents.len()) { return Err( rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), ); diff --git a/runners/genlayer-py-std/src/genlayer/chain.py b/runners/genlayer-py-std/src/genlayer/chain.py index 60989e89..c6bf80c9 100644 --- a/runners/genlayer-py-std/src/genlayer/chain.py +++ b/runners/genlayer-py-std/src/genlayer/chain.py @@ -1,5 +1,6 @@ -__all__ = ('ON', 'IAccount', 'Account', 'Event', 'id') +__all__ = ('ON', 'IAccount', 'Account', 'Event', 'InternalMessageParams', 'id') +import dataclasses import typing import genlayer._internal.on_chain.gl_call as gl_call @@ -11,6 +12,34 @@ """When the transaction message should be applied: ``'accepted'`` or ``'finalized'``""" +@typing.final +@dataclasses.dataclass(frozen=True) +class InternalMessageParams: + """ + Fee parameters for a balance-funded internal message (``use_balance``). GenVM + meters the fee from these params (against the emitting contract's balance) and + that metered amount becomes the child transaction's ``declaredBudget``. + + Field names and layout mirror the executor's calldata encoding exactly. + + :param leader_timeunits_allocation: time units allocated to the leader per round + :param validator_timeunits_allocation: time units allocated to each validator per round + :param execution_budget_per_round: gas budget granted to the child execution per round + :param rotations: per-round rotation allocations; must be non-empty (``appeal_rounds`` is ``len(rotations) - 1``) + :param max_price_gen_per_time_unit: per-time-unit GEN price cap; must be non-zero + :param storage_fee_max_gas_price: max gas price for the storage-fee component; must be non-zero + :param receipt_fee_max_gas_price: max gas price for the receipt-fee component; must be non-zero + """ + + leader_timeunits_allocation: u256 + validator_timeunits_allocation: u256 + execution_budget_per_round: u256 + rotations: list[u256] + max_price_gen_per_time_unit: u256 + storage_fee_max_gas_price: u256 + receipt_fee_max_gas_price: u256 + + @typing.runtime_checkable class IAccount(typing.Protocol): """ diff --git a/runners/genlayer-py-std/src/genlayer/contract/__init__.py b/runners/genlayer-py-std/src/genlayer/contract/__init__.py index 30569dea..0efc5c41 100644 --- a/runners/genlayer-py-std/src/genlayer/contract/__init__.py +++ b/runners/genlayer-py-std/src/genlayer/contract/__init__.py @@ -25,7 +25,7 @@ import genlayer.calldata as calldata from genlayer import IS_IN_VM -from genlayer.chain import IAccount +from genlayer.chain import IAccount, InternalMessageParams from genlayer.types import Address, Lazy, u256 if typing.TYPE_CHECKING or IS_IN_VM: @@ -78,27 +78,36 @@ def lazy(self, *args, **kwargs) -> Lazy[typing.Any]: class _ContractAtEmitMethod: - __slots__ = ('_addr', '_name', '_value', '_on') + __slots__ = ('_addr', '_name', '_value', '_on', '_use_balance', '_fee_params') - def __init__(self, name: str | None, addr: Address, value: u256, on: str): + def __init__( + self, + name: str | None, + addr: Address, + value: u256, + on: str, + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, + ): self._addr = addr self._name = name self._value = value self._on = on + self._use_balance = use_balance + self._fee_params = fee_params def __call__(self, *args, **kwargs) -> None: - wasi.gl_call( - calldata.encode( - { - 'PostMessage': { - 'address': self._addr, - 'calldata': _make_calldata_obj(self._name, args, kwargs), - 'value': self._value, - 'on': self._on, - } - } - ) - ) + message: dict[str, calldata.Encodable] = { + 'address': self._addr, + 'calldata': _make_calldata_obj(self._name, args, kwargs), + 'value': self._value, + 'on': self._on, + } + if self._use_balance: + message['use_balance'] = True + if self._fee_params is not None: + message['fee_params'] = self._fee_params + wasi.gl_call(calldata.encode({'PostMessage': message})) @typing.runtime_checkable @@ -122,12 +131,24 @@ def view(self, *, state: StorageType = StorageType.LATEST_NON_FINAL) -> TView: """ ... - def emit(self, *, value: u256 = 0, on: ON = 'finalized') -> TSend: + def emit( + self, + *, + value: u256 = 0, + on: ON = 'finalized', + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, + ) -> TSend: """ Get a namespace for emitting write transactions. :param value: Amount of native tokens to transfer with the transaction :param on: When the transaction message should be emitted to consensus + :param use_balance: Fund the message fee from this contract's balance instead + of the sender's prefunded pool. Requires the + ``can_use_balance_for_message_fees`` permission and ``fee_params``. + :param fee_params: Fee parameters GenVM meters the balance-funded fee from; + required when ``use_balance`` is set, ignored otherwise :returns: Object providing access to write methods .. warning:: @@ -136,13 +157,22 @@ def emit(self, *, value: u256 = 0, on: ON = 'finalized') -> TSend: """ ... - def emit_transfer(self, value: u256, *, on: ON = 'finalized') -> None: + def emit_transfer( + self, + value: u256, + *, + on: ON = 'finalized', + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, + ) -> None: """ Emit a simple value transfer without calling any method. Receiver may catch it with py:func:`genlayer.gl.Contract.__receive__` method, so users may need to supply non-zero gas :param value: Amount of native tokens to transfer :param on: When transaction message should be emitted to consensus + :param use_balance: Fund the message fee from this contract's balance; see :py:meth:`emit` + :param fee_params: Fee parameters for the balance-funded fee; required when ``use_balance`` is set :raises ValueError: If value is zero """ @@ -182,13 +212,29 @@ def address(self) -> Address: def view(self, *, state: StorageType = StorageType.LATEST_NON_FINAL) -> ErasedMethods: return _ContractAtGetter(_ContractAtViewMethod, self._address, state) - def emit(self, *, value: u256 = 0, on: ON = 'finalized') -> ErasedMethods: - return _ContractAtGetter(_ContractAtEmitMethod, self._address, value, on) + def emit( + self, + *, + value: u256 = 0, + on: ON = 'finalized', + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, + ) -> ErasedMethods: + return _ContractAtGetter( + _ContractAtEmitMethod, self._address, value, on, use_balance, fee_params + ) - def emit_transfer(self, value: u256, *, on: ON = 'finalized') -> None: + def emit_transfer( + self, + value: u256, + *, + on: ON = 'finalized', + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, + ) -> None: if value <= 0: raise ValueError('value must be greater than 0 for emit_transfer') - _ContractAtEmitMethod(None, self._address, value, on)() + _ContractAtEmitMethod(None, self._address, value, on, use_balance, fee_params)() @property def balance(self) -> u256: @@ -325,6 +371,8 @@ def deploy( salt_nonce: typing.Literal[0] = 0, value: u256 = 0, on: ON = 'finalized', + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, ) -> None: ... @@ -337,6 +385,8 @@ def deploy( salt_nonce: u256, value: u256, on: ON = 'finalized', + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, ) -> Address: ... @@ -348,6 +398,8 @@ def deploy( salt_nonce: u256 | typing.Literal[0] = 0, value: u256 = 0, on: ON = 'finalized', + use_balance: bool = False, + fee_params: InternalMessageParams | None = None, ) -> Address | None: """ Deploy a new GenVM contract to the blockchain. @@ -362,6 +414,8 @@ def deploy( :param salt_nonce: Salt for deterministic deployment. Use 0 for non-deterministic. :param value: Amount of native tokens to send to the contract during deployment :param on: When to execute the deployment ('accepted' or 'finalized') + :param use_balance: Fund the deploy message fee from this contract's balance; see :py:meth:`Proxy.emit` + :param fee_params: Fee parameters for the balance-funded fee; required when ``use_balance`` is set :returns: Contract address if salt_nonce != 0, None otherwise Example: @@ -392,19 +446,18 @@ def deploy( details about transaction ordering """ - wasi.gl_call( - calldata.encode( - { - 'DeployContract': { - 'calldata': _make_calldata_obj(None, args, kwargs), - 'code': code, - 'value': value, - 'on': on, - 'salt_nonce': salt_nonce, - } - } - ) - ) + message: dict[str, calldata.Encodable] = { + 'calldata': _make_calldata_obj(None, args, kwargs), + 'code': code, + 'value': value, + 'on': on, + 'salt_nonce': salt_nonce, + } + if use_balance: + message['use_balance'] = True + if fee_params is not None: + message['fee_params'] = fee_params + wasi.gl_call(calldata.encode({'DeployContract': message})) if salt_nonce == 0: return None diff --git a/runners/genlayer-py-std/src/genlayer/storage/root.py b/runners/genlayer-py-std/src/genlayer/storage/root.py index f5ca058b..0e0e22a8 100644 --- a/runners/genlayer-py-std/src/genlayer/storage/root.py +++ b/runners/genlayer-py-std/src/genlayer/storage/root.py @@ -56,6 +56,13 @@ class Root: from the pointed-to slot (see ``chain:`` runner ids). """ + permissions: u256 + """ + Permission bitfield read by the executor at execution start. Bit ``n`` corresponds + to the :py:class:`~genlayer.vm.public_abi.Permissions` member whose value is ``n``. + Use :py:meth:`get_permission` / :py:meth:`set_permission` to access it. + """ + @staticmethod def get() -> 'Root': """ @@ -112,3 +119,25 @@ def lock_default(self): frozen.append(self.code.slot().as_int()) frozen.append(self.locked_slots.slot().as_int()) frozen.append(self.upgraders.slot().as_int()) + + def get_permission(self, perm: public_abi.Permissions, /) -> bool: + """ + Check whether a permission bit is set in the root ``permissions`` bitfield. + + :param perm: permission to query + :returns: ``True`` iff the corresponding bit is set + """ + return (self.permissions >> perm.value) & 1 != 0 + + def set_permission(self, perm: public_abi.Permissions, value: bool, /) -> None: + """ + Set or clear a permission bit in the root ``permissions`` bitfield. + + :param perm: permission to modify + :param value: whether to grant the permission + """ + bit = 1 << perm.value + if value: + self.permissions |= bit + else: + self.permissions &= ~bit diff --git a/runners/genlayer-py-std/src/genlayer/vm/public_abi.py b/runners/genlayer-py-std/src/genlayer/vm/public_abi.py index ffb52778..a1e172fd 100644 --- a/runners/genlayer-py-std/src/genlayer/vm/public_abi.py +++ b/runners/genlayer-py-std/src/genlayer/vm/public_abi.py @@ -26,6 +26,10 @@ class EntryKind(IntEnum): CONSENSUS_STAGE = 2 +class Permissions(IntEnum): + CAN_USE_BALANCE_FOR_MESSAGE_FEES = 1 + + class _MemoryLimiterConsts(typing.NamedTuple): TABLE_ENTRY: int = 64 FILE_MAPPING: int = 256 @@ -41,6 +45,7 @@ class _RootOffsets(typing.NamedTuple): LOCKED_SLOTS: int = 3 UPGRADERS: int = 4 CODE_SLOT: int = 5 + PERMISSIONS: int = 37 root_offsets: typing.Final = _RootOffsets() @@ -133,14 +138,19 @@ def message() -> 'VmError': def event() -> 'VmError': return VmError('out_of receipt event') +class _VmErrorOutOfMessageFee: + @staticmethod + def total() -> 'VmError': + return VmError('out_of message_fee total') + @staticmethod + def node() -> 'VmError': + return VmError('out_of message_fee node') + class _VmErrorOutOf: @staticmethod def storage() -> 'VmError': return VmError('out_of storage') @staticmethod - def message_fee() -> 'VmError': - return VmError('out_of message_fee') - @staticmethod def vm_recursion() -> 'VmError': return VmError('out_of vm_recursion') @staticmethod @@ -161,14 +171,20 @@ def memory() -> '_VmErrorOutOfMemory': @staticmethod def receipt() -> '_VmErrorOutOfReceipt': return _VmErrorOutOfReceipt() + @staticmethod + def message_fee() -> '_VmErrorOutOfMessageFee': + return _VmErrorOutOfMessageFee() class _VmErrorFee: @staticmethod def no_matching_node() -> 'VmError': return VmError('fee no_matching_node') @staticmethod - def below_minimal() -> 'VmError': - return VmError('fee below_minimal') + def below_minimum() -> 'VmError': + return VmError('fee below_minimum') + @staticmethod + def too_many_rounds() -> 'VmError': + return VmError('fee too_many_rounds') class _VmErrorEvm: @staticmethod diff --git a/runners/support/versions/check-dev-mode.py b/runners/support/versions/check-dev-mode.py new file mode 100644 index 00000000..56b15e26 --- /dev/null +++ b/runners/support/versions/check-dev-mode.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Pre-commit guard: fail if dev-mode.nix is true or any hash is "test".""" + +from __future__ import annotations + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DEV_MODE_NIX = HERE / 'dev-mode.nix' +CURRENT_NIX = HERE / 'current.nix' + + +def _repo_root(start: Path) -> Path: + for d in (start, *start.parents): + if (d / '.genvm-monorepo-root').exists(): + return d + raise RuntimeError(f'no .genvm-monorepo-root above {start}') + + +def _print_guide() -> None: + try: + guide = _repo_root(HERE) / 'docs/contributing/howto/committing/runners.md' + print('=== docs/contributing/howto/committing/runners.md ===', file=sys.stderr) + print(guide.read_text(), file=sys.stderr) + except Exception: + pass + + +errors: list[str] = [] + +if DEV_MODE_NIX.read_text().strip() == 'true': + errors.append('runners/support/versions/dev-mode.nix is true') + +if errors: + for e in errors: + print(f'error: {e}', file=sys.stderr) + print(file=sys.stderr) + _print_guide() + sys.exit(1) diff --git a/runners/support/versions/current.nix b/runners/support/versions/current.nix index 418013d0..49957a54 100644 --- a/runners/support/versions/current.nix +++ b/runners/support/versions/current.nix @@ -49,7 +49,7 @@ let }; genlayer-std = { - hash = "sha256-U56UmY3im2Tlua69T4vdVp5jAr/GkD4edBrEZqCbsDg="; + hash = "sha256-x4U4R5V/KgdDj5GW5l8gZq3id+51GR2O/1PiodyTlj0="; depends = [ top ]; @@ -83,7 +83,7 @@ let wrappers = { __prefix = ""; py-genlayer = { - hash = "sha256-MYYcyvObIeS2Ctc8IJbHoKzdxxdUVM3pGh4TW8O23Jw="; + hash = "sha256-5yB84s5W613I0Us6onRHHU4mLUH45hhJh6Ny75dA0L0="; depends = [ cpython pyLibs.cloudpickle @@ -91,7 +91,7 @@ let ]; }; py-genlayer-multi = { - hash = "sha256-m4Yy55954X9H0XU56J3Cnw8fBZM6XgZ46p2FYsb8Klo="; + hash = "sha256-hcyUots2aiVJcOyanJWpcmG10YTd4AyxW7NwYukKTEo="; depends = [ cpython pyLibs.cloudpickle diff --git a/tests/integration/balance/balance/balance.0_0.stdout b/tests/integration/balance/balance/balance.0_0.stdout index a4813028..fd642160 100644 --- a/tests/integration/balance/balance/balance.0_0.stdout +++ b/tests/integration/balance/balance/balance.0_0.stdout @@ -7,4 +7,4 @@ main At(self) 10 nested self 10 nested At(self) 10 executed with `Return(null)` -{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","value":5} +{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","use_balance":false,"value":5} diff --git a/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout b/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout index 2b2e5e2d..f99d1a72 100644 --- a/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout +++ b/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout @@ -3,4 +3,4 @@ sandbox transfer failed: 7: inbalance sandbox result=Return(calldata=40) balance final=40 executed with `Return(null)` -{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","value":60} +{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","use_balance":false,"value":60} diff --git a/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout b/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout index cc802705..8d0ae61c 100644 --- a/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout +++ b/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout @@ -3,4 +3,4 @@ sandbox result=Return(calldata=40) balance after sandbox=40 transfer failed with error: 7: inbalance balance final=40 executed with `Return(null)` -{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","value":60} +{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","use_balance":false,"value":60} diff --git a/tests/integration/message/deploy/deploy.0.stdout b/tests/integration/message/deploy/deploy.0.stdout index ab257eb5..f0c3e0a0 100644 --- a/tests/integration/message/deploy/deploy.0.stdout +++ b/tests/integration/message/deploy/deploy.0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":321,"salt_nonce":0,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"DeployContract","value":0} +{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":321,"salt_nonce":0,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"DeployContract","use_balance":false,"value":0} diff --git a/tests/integration/message/deploy_salt/deploy_salt.0.stdout b/tests/integration/message/deploy_salt/deploy_salt.0.stdout index 915b006b..15f52c60 100644 --- a/tests/integration/message/deploy_salt/deploy_salt.0.stdout +++ b/tests/integration/message/deploy_salt/deploy_salt.0.stdout @@ -1,3 +1,3 @@ 0xf539Cb83f077Cd01BDd1a4E002866dCC0D15D633 executed with `Return(null)` -{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":321,"salt_nonce":1,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"DeployContract","value":0} +{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":321,"salt_nonce":1,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"DeployContract","use_balance":false,"value":0} diff --git a/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.0.stdout b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.0.stdout new file mode 100644 index 00000000..4919b4a4 --- /dev/null +++ b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.0.stdout @@ -0,0 +1 @@ +executed with `VMError("fee below_minimum")` diff --git a/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet new file mode 100644 index 00000000..ee6bc2b3 --- /dev/null +++ b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet @@ -0,0 +1,48 @@ +local simple_deploy = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; + +// Pin a per-phase timeunit floor above the emitted message's child timeunits. +// gas_data replaces DEFAULT_GAS_DATA wholesale, so all required node fields are +// restated here (kept minimal/deterministic, matching DEFAULT_GAS_DATA). +local gasData = { + storageUnitPrice: '1', + receiptGasPerByte: '1', + gasPerChangedSlot: '1', + intrinsicGas: '0', + bootloaderOverhead: '0', + fixedProposeReceiptGas: '0', + fixedMessageRevealGas: '0', + genPerTimeUnit: '0', + // leader 5 / validator 10 (below) are rejected at emission. + minTimeUnitsPerPhase: '30', +}; + +// A single wildcard internal allocation that matches the emitted message, funded +// with an ample (but never-reached) budget so the rejection isolates the timeunit +// floor from any fee/budget effect. leader 5 and validator 10 are both < 30. +local alloc = { + budget: 9000000000000000, + recipient: null, + call_key: null, + on: 'finalized', + fee_params: { + Internal: { + execution_budget_per_round: 1024, + rotations: [4, 4, 4, 4, 4], + leader_timeunits_allocation: 5, + validator_timeunits_allocation: 10, + max_price_gen_per_time_unit: 9000000000000000, + storage_fee_max_gas_price: 20, + receipt_fee_max_gas_price: 20, + }, + }, + children: [], +}; + +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([ + simple_deploy.run('${jsonnetDir}/${fileBaseName}.py') { + gas_data: gasData, + message_fee_allocation: [alloc], + }, + ])} diff --git a/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py new file mode 100644 index 00000000..b79360bf --- /dev/null +++ b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py @@ -0,0 +1,11 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl + + +class Contract(gl.contract.Contract): + def __init__(self): + # Emits a single internal message. Its matched allocation funds child + # timeunits (leader 5, validator 10) below the node's minTimeUnitsPerPhase + # floor (30), so emission is rejected with `fee below_minimum` and the + # message is never issued. + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit().foo(1, 2) diff --git a/tests/integration/message/send_message/send_message.0.stdout b/tests/integration/message/send_message/send_message.0.stdout index 9001864c..63391dc9 100644 --- a/tests/integration/message/send_message/send_message.0.stdout +++ b/tests/integration/message/send_message/send_message.0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/send_message_on/send_message_on.0_0.stdout b/tests/integration/message/send_message_on/send_message_on.0_0.stdout index 39c01da9..76c1bf75 100644 --- a/tests/integration/message/send_message_on/send_message_on.0_0.stdout +++ b/tests/integration/message/send_message_on/send_message_on.0_0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"validator_timeunits_allocation":5},"message_fee":174180,"on":"accepted","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"validator_timeunits_allocation":5},"message_fee":174180,"on":"accepted","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"PostMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/use_balance_below_min/use_balance_below_min.0.stdout b/tests/integration/message/use_balance_below_min/use_balance_below_min.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_below_min/use_balance_below_min.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_below_min/use_balance_below_min.0_0.stdout b/tests/integration/message/use_balance_below_min/use_balance_below_min.0_0.stdout new file mode 100644 index 00000000..4919b4a4 --- /dev/null +++ b/tests/integration/message/use_balance_below_min/use_balance_below_min.0_0.stdout @@ -0,0 +1 @@ +executed with `VMError("fee below_minimum")` diff --git a/tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet b/tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet new file mode 100644 index 00000000..840a503b --- /dev/null +++ b/tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet @@ -0,0 +1,28 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; + +// Pin a per-phase timeunit floor (30) above the emitted message's timeunits (5). +// gas_data replaces DEFAULT_GAS_DATA wholesale, so all required node fields are +// restated here (kept minimal/deterministic, matching DEFAULT_GAS_DATA). +local gasData = { + storageUnitPrice: '1', + receiptGasPerByte: '1', + gasPerChangedSlot: '1', + intrinsicGas: '0', + bootloaderOverhead: '0', + fixedProposeReceiptGas: '0', + fixedMessageRevealGas: '0', + genPerTimeUnit: '0', + minTimeUnitsPerPhase: '30', +}; + +// Ample balance so the rejection isolates the timeunit floor, not the balance. +local extra = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, + }, + gas_data: gasData, +}; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base + extra + {next: [base.next[0] + extra]}])} diff --git a/tests/integration/message/use_balance_below_min/use_balance_below_min.py b/tests/integration/message/use_balance_below_min/use_balance_below_min.py new file mode 100644 index 00000000..1c326965 --- /dev/null +++ b/tests/integration/message/use_balance_below_min/use_balance_below_min.py @@ -0,0 +1,30 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# leader/validator timeunits (5) are below the node's minTimeUnitsPerPhase floor +# (30, set in the jsonnet), so metering rejects the emission. +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # The min-timeunits floor is enforced on the balance-funded path too, so + # emission aborts with the `fee below_minimum` VMError. + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) diff --git a/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0.stdout b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0_0.stdout b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0_0.stdout new file mode 100644 index 00000000..4919b4a4 --- /dev/null +++ b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.0_0.stdout @@ -0,0 +1 @@ +executed with `VMError("fee below_minimum")` diff --git a/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet new file mode 100644 index 00000000..454425e9 --- /dev/null +++ b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet @@ -0,0 +1,29 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; + +// Pin a per-round execution-budget floor (2000) above the emitted message's +// executionBudgetPerRound (1024). gas_data replaces DEFAULT_GAS_DATA wholesale, +// so all required node fields are restated here. +local gasData = { + storageUnitPrice: '1', + receiptGasPerByte: '1', + gasPerChangedSlot: '1', + intrinsicGas: '0', + bootloaderOverhead: '0', + fixedProposeReceiptGas: '0', + fixedMessageRevealGas: '0', + genPerTimeUnit: '0', + minTimeUnitsPerPhase: '0', + messageBudgetFloor: '2000', +}; + +// Ample balance so the rejection isolates the budget floor, not the balance. +local extra = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, + }, + gas_data: gasData, +}; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base + extra + {next: [base.next[0] + extra]}])} diff --git a/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.py b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.py new file mode 100644 index 00000000..de1407e6 --- /dev/null +++ b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.py @@ -0,0 +1,30 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# execution_budget_per_round (1024) is non-zero but below the node's +# messageBudgetFloor (2000, set in the jsonnet). On-chain this reverts +# `BudgetTooLow` at reveal, so metering rejects the emission at emission time. +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # Metering aborts with the `fee below_minimum` VMError (budget floor). + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) diff --git a/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0.stdout b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0_0.stdout b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0_0.stdout new file mode 100644 index 00000000..10cba4d6 --- /dev/null +++ b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.0_0.stdout @@ -0,0 +1,2 @@ +2: inval +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.jsonnet b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.jsonnet new file mode 100644 index 00000000..dadbef19 --- /dev/null +++ b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.jsonnet @@ -0,0 +1,5 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base])} diff --git a/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.py b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.py new file mode 100644 index 00000000..3a15d203 --- /dev/null +++ b/tests/integration/message/use_balance_empty_rotations/use_balance_empty_rotations.py @@ -0,0 +1,33 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# Empty rotations are rejected structurally before metering: the fee floor +# derives `appealRounds = len(rotations) - 1`, which would underflow, so the SDK +# refuses the params with Inval instead of driving the evaluator negative. +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[], + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # use_balance with empty rotations is rejected with Inval (errno 2). + try: + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) + except SystemError as e: + print(e) diff --git a/tests/integration/message/use_balance_forbidden/use_balance_forbidden.0.stdout b/tests/integration/message/use_balance_forbidden/use_balance_forbidden.0.stdout new file mode 100644 index 00000000..31d88ed5 --- /dev/null +++ b/tests/integration/message/use_balance_forbidden/use_balance_forbidden.0.stdout @@ -0,0 +1,2 @@ +6: forbidden +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_forbidden/use_balance_forbidden.jsonnet b/tests/integration/message/use_balance_forbidden/use_balance_forbidden.jsonnet new file mode 100644 index 00000000..9aec2898 --- /dev/null +++ b/tests/integration/message/use_balance_forbidden/use_balance_forbidden.jsonnet @@ -0,0 +1,4 @@ +local simple_deploy = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([simple_deploy.run('${jsonnetDir}/${fileBaseName}.py')])} diff --git a/tests/integration/message/use_balance_forbidden/use_balance_forbidden.py b/tests/integration/message/use_balance_forbidden/use_balance_forbidden.py new file mode 100644 index 00000000..e29d63c4 --- /dev/null +++ b/tests/integration/message/use_balance_forbidden/use_balance_forbidden.py @@ -0,0 +1,25 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl + +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + max_price_gen_per_time_unit=2**200, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + # The CAN_USE_BALANCE_FOR_MESSAGE_FEES bit is unset (no earlier step + # granted it), so a balance-funded emission is rejected pre-execution + # with Forbidden (errno 6). Caught here so the result stays `Return`. + try: + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) + except SystemError as e: + print(e) diff --git a/tests/integration/message/use_balance_huge_params/use_balance_huge_params.0.stdout b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_huge_params/use_balance_huge_params.0_0.stdout b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.0_0.stdout new file mode 100644 index 00000000..10cba4d6 --- /dev/null +++ b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.0_0.stdout @@ -0,0 +1,2 @@ +2: inval +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_huge_params/use_balance_huge_params.jsonnet b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.jsonnet new file mode 100644 index 00000000..dadbef19 --- /dev/null +++ b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.jsonnet @@ -0,0 +1,5 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base])} diff --git a/tests/integration/message/use_balance_huge_params/use_balance_huge_params.py b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.py new file mode 100644 index 00000000..eb7b2cf4 --- /dev/null +++ b/tests/integration/message/use_balance_huge_params/use_balance_huge_params.py @@ -0,0 +1,35 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# Params of huge magnitude are rejected structurally: unbounded, the metered +# floor (~2^500 here) would exceed U256 inside the fee evaluator and abort the +# executor internally. Prices/budgets are bounded to < 2^96, counts (time +# units, rotations entries) to < 2^32. +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=2**250, + validator_timeunits_allocation=0, + execution_budget_per_round=0, + rotations=[0], + max_price_gen_per_time_unit=2**250, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # use_balance with out-of-bounds magnitudes is rejected with Inval + # (errno 2), not an internal abort. + try: + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) + except SystemError as e: + print(e) diff --git a/tests/integration/message/use_balance_insufficient/use_balance_insufficient.0.stdout b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_insufficient/use_balance_insufficient.0_0.stdout b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.0_0.stdout new file mode 100644 index 00000000..e7d6dcf2 --- /dev/null +++ b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.0_0.stdout @@ -0,0 +1,2 @@ +7: inbalance +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_insufficient/use_balance_insufficient.jsonnet b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.jsonnet new file mode 100644 index 00000000..a52b12dc --- /dev/null +++ b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.jsonnet @@ -0,0 +1,11 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +// Balance far below the metered fee, so `value + metered_fee` overflows it. +local bal = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 100, + }, +}; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['balance'], ['fees', 'balance']], 'stable'), + entry: util.addPaths([base + bal + {next: [base.next[0] + bal]}])} diff --git a/tests/integration/message/use_balance_insufficient/use_balance_insufficient.py b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.py new file mode 100644 index 00000000..3100a0d7 --- /dev/null +++ b/tests/integration/message/use_balance_insufficient/use_balance_insufficient.py @@ -0,0 +1,32 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # The contract balance (100) is below the metered message fee, so the + # balance-funded emission fails with Inbalance (errno 7), catchable by + # the guest just like a value overspend. + try: + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) + except SystemError as e: + print(e) diff --git a/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0.stdout b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0.stdout b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0.stdout new file mode 100644 index 00000000..7048ca87 --- /dev/null +++ b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0.stdout @@ -0,0 +1 @@ +executed with `VMError("fee no_matching_node # internal")` diff --git a/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout new file mode 100644 index 00000000..914a040f --- /dev/null +++ b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout @@ -0,0 +1,2 @@ +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"PostMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.jsonnet b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.jsonnet new file mode 100644 index 00000000..b0388aaf --- /dev/null +++ b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.jsonnet @@ -0,0 +1,25 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local msg = import 'templates/message.json'; +local util = import 'templates/util.jsonnet'; +local s = simple.run('${jsonnetDir}/${fileBaseName}.py'); +// Empty allocation tree (no matching node) + a funded balance for use_balance. +local extra = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, + }, + 'message_fee_allocation': [], +}; +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([util.chain([ + s + extra, + s + extra + { + 'code': null, + 'message': msg, + 'calldata': std.manifestJsonEx({'': 'emit_normal', 'args': []}, ' '), + }, + s + extra + { + 'code': null, + 'message': msg, + 'calldata': std.manifestJsonEx({'': 'emit_balance', 'args': []}, ' '), + }, + ])])} diff --git a/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.py b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.py new file mode 100644 index 00000000..354d8891 --- /dev/null +++ b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.py @@ -0,0 +1,37 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + # Small GEN cap keeps the metered fee tractable (fee = cap * consensusTerm + + # executionTerm). + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def emit_normal(self): + # The tx carries an empty message-fee allocation tree, so the ordinary + # (allocation-matched) path finds no matching node and aborts with the + # `fee no_matching_node` VMError. + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit().foo(1, 2) + + @gl.public.write + def emit_balance(self): + # use_balance bypasses allocation matching entirely, so the same + # emission succeeds despite the empty tree. + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) diff --git a/tests/integration/message/use_balance_no_params/use_balance_no_params.0.stdout b/tests/integration/message/use_balance_no_params/use_balance_no_params.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_no_params/use_balance_no_params.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_no_params/use_balance_no_params.0_0.stdout b/tests/integration/message/use_balance_no_params/use_balance_no_params.0_0.stdout new file mode 100644 index 00000000..10cba4d6 --- /dev/null +++ b/tests/integration/message/use_balance_no_params/use_balance_no_params.0_0.stdout @@ -0,0 +1,2 @@ +2: inval +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_no_params/use_balance_no_params.jsonnet b/tests/integration/message/use_balance_no_params/use_balance_no_params.jsonnet new file mode 100644 index 00000000..dadbef19 --- /dev/null +++ b/tests/integration/message/use_balance_no_params/use_balance_no_params.jsonnet @@ -0,0 +1,5 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base])} diff --git a/tests/integration/message/use_balance_no_params/use_balance_no_params.py b/tests/integration/message/use_balance_no_params/use_balance_no_params.py new file mode 100644 index 00000000..890c290d --- /dev/null +++ b/tests/integration/message/use_balance_no_params/use_balance_no_params.py @@ -0,0 +1,21 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + + +class Contract(gl.contract.Contract): + def __init__(self): + # Grant the permission first, otherwise the emission below would fail + # with Forbidden (checked before the fee_params validation). + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # use_balance without fee_params is rejected with Inval (errno 2): + # GenVM cannot meter the balance-funded fee without explicit params. + try: + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit(use_balance=True).foo(1, 2) + except SystemError as e: + print(e) diff --git a/tests/integration/message/use_balance_ok/use_balance_ok.0.stdout b/tests/integration/message/use_balance_ok/use_balance_ok.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_ok/use_balance_ok.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout b/tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout new file mode 100644 index 00000000..914a040f --- /dev/null +++ b/tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout @@ -0,0 +1,2 @@ +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"PostMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_ok/use_balance_ok.jsonnet b/tests/integration/message/use_balance_ok/use_balance_ok.jsonnet new file mode 100644 index 00000000..064f2037 --- /dev/null +++ b/tests/integration/message/use_balance_ok/use_balance_ok.jsonnet @@ -0,0 +1,11 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +// Fund the emitting contract so `value + metered_fee` clears the balance check. +local bal = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, + }, +}; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base + bal + {next: [base.next[0] + bal]}])} diff --git a/tests/integration/message/use_balance_ok/use_balance_ok.py b/tests/integration/message/use_balance_ok/use_balance_ok.py new file mode 100644 index 00000000..d22d2ab6 --- /dev/null +++ b/tests/integration/message/use_balance_ok/use_balance_ok.py @@ -0,0 +1,33 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + # Small GEN cap keeps the metered fee tractable: it is the multiplier for the + # consensus term (fee = cap * consensusTerm + executionTerm). + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + # Permissions are read pre-execution, so the bit must be granted in this + # (earlier) deploy transaction; the emission happens in the next one. + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # The permission is now set (prior step), so the balance-funded emission + # succeeds. The metered fee is charged against this contract's balance + # and the emitted message carries `use_balance=true` with an empty subtree. + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) diff --git a/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0.stdout b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout new file mode 100644 index 00000000..ca8cb64c --- /dev/null +++ b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout @@ -0,0 +1,3 @@ +sandbox: emitted +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"PostMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_sandbox/use_balance_sandbox.jsonnet b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.jsonnet new file mode 100644 index 00000000..c38a0fa4 --- /dev/null +++ b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.jsonnet @@ -0,0 +1,11 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +// Fund the emitting contract; the sandbox emits on the same contract's behalf. +local bal = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, + }, +}; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees'], ['sandbox']], 'stable'), + entry: util.addPaths([base + bal + {next: [base.next[0] + bal]}])} diff --git a/tests/integration/message/use_balance_sandbox/use_balance_sandbox.py b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.py new file mode 100644 index 00000000..2acef02d --- /dev/null +++ b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.py @@ -0,0 +1,39 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + # Small GEN cap keeps the metered fee tractable (fee = cap * consensusTerm + + # executionTerm). + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + # Grant the permission in this earlier deploy transaction. + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # A Sandbox sub-VM inherits CAN_USE_BALANCE_FOR_MESSAGE_FEES from the + # top-level contract, so a use_balance emission from inside the sandbox + # (spawned with allow_send_messages) succeeds. + def sandbox_fn(): + try: + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) + print('sandbox: emitted') + except SystemError as e: + print(f'sandbox: {e}') + + gl.vm.spawn_sandbox(sandbox_fn, allow_send_messages=True) diff --git a/tests/integration/message/use_balance_scaled/use_balance_scaled.0.stdout b/tests/integration/message/use_balance_scaled/use_balance_scaled.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_scaled/use_balance_scaled.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout b/tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout new file mode 100644 index 00000000..05013a51 --- /dev/null +++ b/tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout @@ -0,0 +1,2 @@ +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":3,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":45116,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"PostMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet b/tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet new file mode 100644 index 00000000..9237993c --- /dev/null +++ b/tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet @@ -0,0 +1,29 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; + +// A non-zero node genPerTimeUnit (7) that the balance-funded path must ignore in +// favour of the guest cap. gas_data replaces DEFAULT_GAS_DATA wholesale, so all +// required node fields are restated here. +local gasData = { + storageUnitPrice: '1', + receiptGasPerByte: '1', + gasPerChangedSlot: '1', + intrinsicGas: '0', + bootloaderOverhead: '0', + fixedProposeReceiptGas: '0', + fixedMessageRevealGas: '0', + genPerTimeUnit: '7', + minTimeUnitsPerPhase: '0', + messageBudgetFloor: '0', +}; + +// Fund the emitting contract so `value + metered_fee` clears the balance check. +local extra = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, + }, + gas_data: gasData, +}; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base + extra + {next: [base.next[0] + extra]}])} diff --git a/tests/integration/message/use_balance_scaled/use_balance_scaled.py b/tests/integration/message/use_balance_scaled/use_balance_scaled.py new file mode 100644 index 00000000..49a434fe --- /dev/null +++ b/tests/integration/message/use_balance_scaled/use_balance_scaled.py @@ -0,0 +1,32 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# Proves the balance-funded floor scales with the GUEST cap, not the node's live +# genPerTimeUnit. With consensusTerm=5140 and executionTerm=1024*29=29696: +# fee = max_price_gen_per_time_unit * consensusTerm + executionTerm +# = 3 * 5140 + 29696 = 45116 +# The jsonnet sets node.genPerTimeUnit=7; had the balance path used it the fee +# would be 7*5140 + 29696 = 65676. The golden's 45116 confirms the cap is used. +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + max_price_gen_per_time_unit=3, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) diff --git a/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0.stdout b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0_0.stdout b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0_0.stdout new file mode 100644 index 00000000..54fb7fe7 --- /dev/null +++ b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.0_0.stdout @@ -0,0 +1 @@ +executed with `VMError("fee too_many_rounds")` diff --git a/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.jsonnet b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.jsonnet new file mode 100644 index 00000000..7f29f9ce --- /dev/null +++ b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.jsonnet @@ -0,0 +1,11 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +// Ample balance so the rejection isolates the rounds guard, not the balance. +local bal = { + 'balances': { + 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, + }, +}; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base + bal + {next: [base.next[0] + bal]}])} diff --git a/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.py b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.py new file mode 100644 index 00000000..3ad85b81 --- /dev/null +++ b/tests/integration/message/use_balance_too_many_rounds/use_balance_too_many_rounds.py @@ -0,0 +1,31 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# 10 rotations => appealRounds = 9 => rounds span 0..18, which overruns the +# 18-entry validator table (indices 0..17). The bound is node-config-dependent, +# so the fee expression (not the SDK) rejects it with a clean `fee +# too_many_rounds` VMError instead of an internal abort. +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4] * 10, + max_price_gen_per_time_unit=2, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # Metering aborts with `fee too_many_rounds`. + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) diff --git a/tests/integration/message/use_balance_zero_price/use_balance_zero_price.0.stdout b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_zero_price/use_balance_zero_price.0_0.stdout b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.0_0.stdout new file mode 100644 index 00000000..10cba4d6 --- /dev/null +++ b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.0_0.stdout @@ -0,0 +1,2 @@ +2: inval +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_zero_price/use_balance_zero_price.jsonnet b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.jsonnet new file mode 100644 index 00000000..dadbef19 --- /dev/null +++ b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.jsonnet @@ -0,0 +1,5 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; +local base = deploy_then.run('${jsonnetDir}/${fileBaseName}.py', 'do_emit'); +{tags: util.features([['message'], ['fees']], 'stable'), + entry: util.addPaths([base])} diff --git a/tests/integration/message/use_balance_zero_price/use_balance_zero_price.py b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.py new file mode 100644 index 00000000..795e36ec --- /dev/null +++ b/tests/integration/message/use_balance_zero_price/use_balance_zero_price.py @@ -0,0 +1,34 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# A zero price cap is rejected structurally: the chain reverts +# `FeeValueMustBeNonZero` at reveal, so the SDK refuses it with Inval up front. +# Here `max_price_gen_per_time_unit` is zero (storage/receipt caps are checked +# the same way). +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=1024, + rotations=[4, 4, 4, 4, 4], + max_price_gen_per_time_unit=0, + storage_fee_max_gas_price=20, + receipt_fee_max_gas_price=20, +) + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.storage.Root.get().set_permission( + Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, True + ) + + @gl.public.write + def do_emit(self): + # use_balance with a zero price cap is rejected with Inval (errno 2). + try: + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) + except SystemError as e: + print(e) From 765dcff33b716c5748ecb29f08bd96b917056ed6 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 2 Jul 2026 22:33:33 +0900 Subject: [PATCH 11/25] =?UTF-8?q?fix(third-party):=20save=20missed=20wasmt?= =?UTF-8?q?ime=20nondet-instruction=20trap=20rename=20=F0=9F=92=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .git-third-party/config.json | 2 +- .../patches/executor/third-party/wasmtime/4 | 92 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 .git-third-party/patches/executor/third-party/wasmtime/4 diff --git a/.git-third-party/config.json b/.git-third-party/config.json index 20bebfcf..90f379a6 100644 --- a/.git-third-party/config.json +++ b/.git-third-party/config.json @@ -3,7 +3,7 @@ "executor/third-party/wasmtime": { "url": "https://github.com/bytecodealliance/wasmtime.git", "commit": "6844a83b530f86ad13a65175282fe2dbcd33cad1", - "patches": 3, + "patches": 4, "submodules": [] }, "executor/third-party/wasm-tools": { diff --git a/.git-third-party/patches/executor/third-party/wasmtime/4 b/.git-third-party/patches/executor/third-party/wasmtime/4 new file mode 100644 index 00000000..3ccf9edb --- /dev/null +++ b/.git-third-party/patches/executor/third-party/wasmtime/4 @@ -0,0 +1,92 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: kp2pml30 +Date: Thu, 2 Jul 2026 22:33:10 +0900 +Subject: [PATCH] =?UTF-8?q?chore(wasm):=20rename=20DeterministicMode=20tra?= + =?UTF-8?q?p=20to=20NondetInstruction=20=E2=99=BB=EF=B8=8F?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +--- + crates/cranelift/src/func_environ.rs | 4 ++-- + crates/cranelift/src/lib.rs | 4 ++-- + crates/cranelift/src/translate/code_translator.rs | 2 +- + crates/environ/src/trap_encoding.rs | 8 ++++---- + 4 files changed, 9 insertions(+), 9 deletions(-) + +diff --git a/crates/cranelift/src/func_environ.rs b/crates/cranelift/src/func_environ.rs +index 8f549ff5..0c5821fa 100644 +--- a/crates/cranelift/src/func_environ.rs ++++ b/crates/cranelift/src/func_environ.rs +@@ -3929,9 +3929,9 @@ impl FuncEnvironment<'_> { + self.fuel_save_from_var(builder); + } + +- pub fn trap_deterministic_mode(&mut self, builder: &mut FunctionBuilder) { ++ pub fn trap_nondet_instruction(&mut self, builder: &mut FunctionBuilder) { + self.fuel_before_unreachable(builder); +- self.trap(builder, crate::TRAP_DETERMINISTIC_MODE); ++ self.trap(builder, crate::TRAP_NONDET_INSTRUCTION); + self.stacks.reachable = false; + } + +diff --git a/crates/cranelift/src/lib.rs b/crates/cranelift/src/lib.rs +index 4db70f6a..8119e7da 100644 +--- a/crates/cranelift/src/lib.rs ++++ b/crates/cranelift/src/lib.rs +@@ -72,8 +72,8 @@ pub const TRAP_CONTINUATION_ALREADY_CONSUMED: TrapCode = + TrapCode::unwrap_user(Trap::ContinuationAlreadyConsumed as u8 + TRAP_OFFSET); + pub const TRAP_CAST_FAILURE: TrapCode = + TrapCode::unwrap_user(Trap::CastFailure as u8 + TRAP_OFFSET); +-pub const TRAP_DETERMINISTIC_MODE: TrapCode = +- TrapCode::unwrap_user(Trap::DeterministicMode as u8 + TRAP_OFFSET); ++pub const TRAP_NONDET_INSTRUCTION: TrapCode = ++ TrapCode::unwrap_user(Trap::NondetInstruction as u8 + TRAP_OFFSET); + + /// Creates a new cranelift `Signature` with no wasm params/results for the + /// given calling convention. +diff --git a/crates/cranelift/src/translate/code_translator.rs b/crates/cranelift/src/translate/code_translator.rs +index 927df0f8..b5cf3d17 100644 +--- a/crates/cranelift/src/translate/code_translator.rs ++++ b/crates/cranelift/src/translate/code_translator.rs +@@ -4574,6 +4574,6 @@ fn float_op_unreachable_check(environ: &mut FuncEnvironment<'_>, builder: &mut F + if environ.are_floats_enabled() { + return false; + } +- environ.trap_deterministic_mode(builder); ++ environ.trap_nondet_instruction(builder); + return true; + } +diff --git a/crates/environ/src/trap_encoding.rs b/crates/environ/src/trap_encoding.rs +index 2d49707b..fe4f0dea 100644 +--- a/crates/environ/src/trap_encoding.rs ++++ b/crates/environ/src/trap_encoding.rs +@@ -142,8 +142,8 @@ pub enum Trap { + /// value. + UnalignedPointer, + +- /// Deterministic mode violation +- DeterministicMode, ++ /// Nondeterministic instruction executed in deterministic mode ++ NondetInstruction, + // if adding a variant here be sure to update the `check!` macro below, and + // remember to update `trap.rs` and `trap.h` as mentioned above + } +@@ -196,7 +196,7 @@ impl Trap { + ListOutOfBounds + InvalidDiscriminant + UnalignedPointer +- DeterministicMode ++ NondetInstruction + } + + None +@@ -242,7 +242,7 @@ impl fmt::Display for Trap { + ListOutOfBounds => "list content out-of-bounds", + InvalidDiscriminant => "invalid variant discriminant", + UnalignedPointer => "unaligned pointer", +- DeterministicMode => "deterministic mode violation (cpu)", ++ NondetInstruction => "nondeterministic instruction (cpu)", + }; + write!(f, "wasm trap: {desc}") + } From 4cf812ad9078a36d2b5f93f5e2ed095b054804a7 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Mon, 6 Jul 2026 21:13:18 +0900 Subject: [PATCH 12/25] =?UTF-8?q?feat(executor):=20account=20runner=20memo?= =?UTF-8?q?ry=20per=20vm=20=E2=9C=A8=F0=9F=94=92=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/Cargo.toml | 8 + executor/codegen/data/host-fns.json | 6 + executor/crates/common/src/host_fns.rs | 1 + executor/crates/sdk-rs/Cargo.toml | 1 + executor/crates/sdk-rs/src/abi/gl_call.rs | 15 + .../sdk-rs/tests/gl_call_custom_runners.rs | 138 ++ executor/src/lib.rs | 18 +- executor/src/rt/memlimiter.rs | 25 +- executor/src/rt/supervisor/actions.rs | 1462 ++++++++++++++--- executor/src/rt/supervisor/mod.rs | 177 +- executor/src/rt/vm/storage.rs | 134 +- executor/src/runners/cache.rs | 364 +++- executor/src/runners/ustar.rs | 49 +- executor/src/wasi/genlayer_sdk.rs | 146 +- executor/src/wasi/mod.rs | 4 +- .../custom_missing/custom_missing.0.stdout | 2 +- .../deep_dependency_sandbox.0.stdout | 3 + .../deep_dependency_sandbox.jsonnet | 6 + .../deep_dependency_sandbox.py | 54 + .../runner/load_arith/load_arith.0.stdout | 1 + .../runner/load_arith/load_arith.jsonnet | 14 + .../runner/load_arith/load_arith.wat | 13 + .../runner/no_flowback/no_flowback.jsonnet | 14 + .../runner/no_flowback/no_flowback.py | 24 + .../runner/no_flowback/no_flowback.skip | 10 + .../nondet_custom_runner.py | 26 +- .../nondet_inherit_custom.jsonnet | 14 + .../nondet_inherit_custom.py | 23 + .../nondet_inherit_custom.skip | 11 + .../register_ladder/register_ladder.0.stdout | 3 + .../register_ladder/register_ladder.jsonnet | 15 + .../runner/register_ladder/register_ladder.py | 15 + .../register_malformed_charge.0.stdout | 1 + .../register_malformed_charge.jsonnet | 15 + .../register_malformed_charge.py | 16 + 35 files changed, 2370 insertions(+), 458 deletions(-) create mode 100644 executor/crates/sdk-rs/tests/gl_call_custom_runners.rs create mode 100644 tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.0.stdout create mode 100644 tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet create mode 100644 tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.py create mode 100644 tests/integration/runner/load_arith/load_arith.0.stdout create mode 100644 tests/integration/runner/load_arith/load_arith.jsonnet create mode 100644 tests/integration/runner/load_arith/load_arith.wat create mode 100644 tests/integration/runner/no_flowback/no_flowback.jsonnet create mode 100644 tests/integration/runner/no_flowback/no_flowback.py create mode 100644 tests/integration/runner/no_flowback/no_flowback.skip create mode 100644 tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet create mode 100644 tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.py create mode 100644 tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip create mode 100644 tests/integration/runner/register_ladder/register_ladder.0.stdout create mode 100644 tests/integration/runner/register_ladder/register_ladder.jsonnet create mode 100644 tests/integration/runner/register_ladder/register_ladder.py create mode 100644 tests/integration/runner/register_malformed_charge/register_malformed_charge.0.stdout create mode 100644 tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet create mode 100644 tests/integration/runner/register_malformed_charge/register_malformed_charge.py diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 1190df7a..92b06c62 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -9,10 +9,18 @@ opt-level = 2 opt-level = 2 [profile.dev] +incremental = false panic = "abort" +[profile.dev.build-override] +debug = false +incremental = false +opt-level = 0 +strip = true + [profile.release] debug = true +incremental = false panic = "abort" [[example]] diff --git a/executor/codegen/data/host-fns.json b/executor/codegen/data/host-fns.json index ca07a54b..4a712d75 100644 --- a/executor/codegen/data/host-fns.json +++ b/executor/codegen/data/host-fns.json @@ -43,5 +43,11 @@ "name": "current_major_str", "repr": "str", "value": "v0.0.0" + }, + { + "type": "const", + "name": "load_const", + "repr": "u32", + "value": 4096 } ] diff --git a/executor/crates/common/src/host_fns.rs b/executor/crates/common/src/host_fns.rs index d52f4804..dce1e8f4 100644 --- a/executor/crates/common/src/host_fns.rs +++ b/executor/crates/common/src/host_fns.rs @@ -136,3 +136,4 @@ impl VmErrorDetail { pub const CURRENT_MAJOR: u8 = 0; pub const CURRENT_MAJOR_STR: &'static str = "v0.0.0"; +pub const LOAD_CONST: u32 = 4096; diff --git a/executor/crates/sdk-rs/Cargo.toml b/executor/crates/sdk-rs/Cargo.toml index bfd19c73..644ed895 100644 --- a/executor/crates/sdk-rs/Cargo.toml +++ b/executor/crates/sdk-rs/Cargo.toml @@ -59,4 +59,5 @@ version = "0.0.2" [dev-dependencies] afl = { version = "0.15.18", features = ["no_cfg_fuzzing"] } +bytes = { version = "1.11.0", features = ["serde"] } primitive-types = { version = "0.13", features = ["impl-serde"] } diff --git a/executor/crates/sdk-rs/src/abi/gl_call.rs b/executor/crates/sdk-rs/src/abi/gl_call.rs index 596f147f..34b2c99a 100644 --- a/executor/crates/sdk-rs/src/abi/gl_call.rs +++ b/executor/crates/sdk-rs/src/abi/gl_call.rs @@ -591,6 +591,16 @@ pub enum Message { data_leader: Bytes, #[cfg_attr(feature = "arbitrary", arbitrary(with = crate::abi::arb::arb_bytes))] data_validator: Bytes, + /// Runner to execute in the nondet block, as a full runner id. `None` + /// (old SDKs) runs the parent's `contract`; a `custom:` must be + /// visible to the parent and is auto-granted to the child (ADR-012 §4). + #[calldata(default = default_none)] + runner: Option, + /// Custom runners visible to the nondet child, each a full `custom:` + /// id. `None` inherits the parent's entire set (a behavior change: nondet + /// blocks used to start empty); `Some(list)` grants exactly that subset. + #[calldata(default = default_none)] + custom_runners: Option>, }, Sandbox { @@ -602,6 +612,11 @@ pub enum Message { allow_write_storage: bool, allow_send_messages: bool, allow_register_runners: bool, + /// Custom runners visible to the sandbox child, each a full `custom:` + /// id. `None` inherits the parent's entire set; `Some(list)` grants exactly + /// that subset (every element must be in the parent's set) (ADR-012 §4). + #[calldata(default = default_none)] + custom_runners: Option>, }, RegisterRunner { diff --git a/executor/crates/sdk-rs/tests/gl_call_custom_runners.rs b/executor/crates/sdk-rs/tests/gl_call_custom_runners.rs new file mode 100644 index 00000000..3c0cbf46 --- /dev/null +++ b/executor/crates/sdk-rs/tests/gl_call_custom_runners.rs @@ -0,0 +1,138 @@ +//! Backward-compat / round-trip coverage for the runner-visibility fields +//! (`custom_runners`, and `RunNondet::runner`) added to `Sandbox` / `RunNondet` +//! (ADR-012). + +use bytes::Bytes; +use genlayer_sdk::abi::gl_call::Message; +use genlayer_sdk::calldata::codec::{BinaryDeserializer, Decode}; +use genlayer_sdk::calldata::{self, Encoder}; + +macro_rules! encode { + ($v:expr) => {{ + let mut buf = Vec::new(); + calldata::codec::Encode::encode(&$v, &mut Encoder::new(&mut buf)) + .unwrap_or_else(|_| unreachable!()); + buf + }}; +} + +/// A `Sandbox` map produced before the field existed (no `custom_runners` key) +/// decodes with `None` — the "inherit the parent's whole set" default. +#[test] +fn sandbox_old_encoding_defaults_to_none() { + // Mirrors the pre-feature `Sandbox` variant (same wire name and fields), so + // encoding it yields exactly what an old SDK would emit. + #[derive(calldata::Encode)] + enum OldMessage { + #[allow(dead_code)] + Sandbox { + data: Bytes, + runner: String, + allow_write_storage: bool, + allow_send_messages: bool, + allow_register_runners: bool, + }, + } + + let old = OldMessage::Sandbox { + data: Bytes::from_static(b"payload"), + runner: "contract".to_owned(), + allow_write_storage: true, + allow_send_messages: false, + allow_register_runners: true, + }; + + let decoded: Message = Decode::decode(BinaryDeserializer::new(&encode!(old))).unwrap(); + + match decoded { + Message::Sandbox { + custom_runners, + allow_write_storage, + .. + } => { + assert!(custom_runners.is_none(), "absent field must decode as None"); + assert!(allow_write_storage); + } + other => panic!("expected Sandbox, got {other:?}"), + } +} + +/// A `Sandbox` carrying an explicit visibility list round-trips. +#[test] +fn sandbox_custom_runners_round_trip() { + let list = vec!["custom:aaaa".to_owned(), "custom:bbbb".to_owned()]; + let msg = Message::Sandbox { + data: Bytes::from_static(b"x"), + runner: "contract".to_owned(), + allow_write_storage: false, + allow_send_messages: false, + allow_register_runners: false, + custom_runners: Some(list.clone()), + }; + + let decoded: Message = Decode::decode(BinaryDeserializer::new(&encode!(msg))).unwrap(); + + match decoded { + Message::Sandbox { custom_runners, .. } => assert_eq!(custom_runners, Some(list)), + other => panic!("expected Sandbox, got {other:?}"), + } +} + +/// A `RunNondet` map produced before the fields existed decodes with both new +/// optional fields defaulting to `None`. +#[test] +fn run_nondet_old_encoding_defaults_to_none() { + #[derive(calldata::Encode)] + enum OldMessage { + #[allow(dead_code)] + RunNondet { + data_leader: Bytes, + data_validator: Bytes, + }, + } + + let old = OldMessage::RunNondet { + data_leader: Bytes::from_static(b"l"), + data_validator: Bytes::from_static(b"v"), + }; + + let decoded: Message = Decode::decode(BinaryDeserializer::new(&encode!(old))).unwrap(); + + match decoded { + Message::RunNondet { + runner, + custom_runners, + .. + } => { + assert!(runner.is_none(), "absent runner must decode as None"); + assert!(custom_runners.is_none(), "absent list must decode as None"); + } + other => panic!("expected RunNondet, got {other:?}"), + } +} + +/// A `RunNondet` carrying both new fields round-trips. +#[test] +fn run_nondet_new_fields_round_trip() { + let list = vec!["custom:cccc".to_owned()]; + let msg = Message::RunNondet { + data_leader: Bytes::from_static(b"l"), + data_validator: Bytes::from_static(b"v"), + runner: Some("custom:cccc".to_owned()), + custom_runners: Some(list.clone()), + }; + + let decoded: Message = Decode::decode(BinaryDeserializer::new(&encode!(msg))).unwrap(); + + match decoded { + Message::RunNondet { + runner, + custom_runners, + .. + } => { + assert_eq!(runner, Some("custom:cccc".to_owned())); + assert_eq!(custom_runners, Some(list)); + } + other => panic!("expected RunNondet, got {other:?}"), + } +} diff --git a/executor/src/lib.rs b/executor/src/lib.rs index 13b59c4a..5766b12a 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -150,6 +150,7 @@ pub async fn run_with_impl( entry_data: domain::ExecutionData, supervisor: Arc, permissions: &str, + deploy_pin: &mut Option, ) -> anyhow::Result { let storage_pages_limit = supervisor.get_storage_limiter(); @@ -184,11 +185,11 @@ pub async fn run_with_impl( let archive = runners::parse(code.clone()).map_err(|e| { rt::errors::Error::wrap(public_abi::VmError::invalid_contract().val(), e) })?; - supervisor.prepopulate_deploy_runner( + *deploy_pin = Some(supervisor.prepopulate_deploy_runner( entry_data.message.contract_address, code_slot, archive, - ); + )); runners::Id::Chain { address: entry_data.message.contract_address, @@ -259,9 +260,10 @@ 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(), + // The root VM inherits no custom runners; it loads its own tree at init. + granted_custom: Vec::new(), }); let run_result = rt::spawn_apply_run(&supervisor, essential_data).await?; @@ -292,13 +294,21 @@ pub async fn run_with( supervisor: Arc, permissions: &str, ) -> anyhow::Result { - let res = run_with_impl(entry_data, supervisor.clone(), permissions).await; + // Deploy-time weak-cache bridge (ADR-012): the prepopulated deploy-runner + // entry must outlive not just the deterministic run but also the secondary + // nondet validator queue — a queued nondet VM whose runner is `contract` + // during deploy attaches to this very entry at spawn. Dropping the pin + // earlier would make that load's outcome depend on which queue processed it. + let mut deploy_pin: Option = None; + + let res = run_with_impl(entry_data, supervisor.clone(), permissions, &mut deploy_pin).await; log_debug!("deterministic execution done"); let nondet_disagree_res = rt::supervisor::await_nondet_vms(&supervisor).await; log_debug!("non-deterministic execution done"); + drop(deploy_pin); let merged_result = match (res, nondet_disagree_res) { (Err(e_res), Err(e_nondet)) => { diff --git a/executor/src/rt/memlimiter.rs b/executor/src/rt/memlimiter.rs index 08ae0f04..3f6af0c3 100644 --- a/executor/src/rt/memlimiter.rs +++ b/executor/src/rt/memlimiter.rs @@ -99,9 +99,19 @@ impl Limiter { pub fn release(&self, delta: u32) { self.0.release_no_consumed(delta); - self.0 + let prev_consumed = self + .0 .consumed_memory .fetch_sub(delta, std::sync::atomic::Ordering::SeqCst); + // A limiter only ever releases something it consumed, so `consumed_memory` + // never underflows. Asserting on the atomic's return value (the value + // right before the subtraction) is race-free under concurrent + // consume/release on the same limiter. + debug_assert!( + prev_consumed >= delta, + "limiter {} release underflow: released {delta} with only {prev_consumed} consumed", + self.0.id + ); } pub fn consume(&self, delta: u32) -> bool { @@ -130,9 +140,20 @@ impl Limiter { .data .least_remaining_memory .fetch_min(least_for_test, std::sync::atomic::Ordering::SeqCst); - self.0 + let prev_consumed = self + .0 .consumed_memory .fetch_add(delta, std::sync::atomic::Ordering::SeqCst); + // A single limiter's outstanding charge never exceeds the + // shared budget: `remaining_memory` stays >= 0 (the CAS above + // rejects delta > remaining), so the sum of every limiter's + // `consumed_memory` is <= u32::MAX and no single limiter can + // overflow. Return-value form keeps this race-free. + debug_assert!( + prev_consumed.checked_add(delta).is_some(), + "limiter {} consumed_memory overflow: {prev_consumed} + {delta}", + self.0.id + ); break; } Err(new_remaining) => remaining = new_remaining, diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index c8c52256..c1089813 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashSet, VecDeque}; use crate::{caching, public_abi, rt, runners}; @@ -24,6 +24,38 @@ fn make_malformed_runner_error(extra_msg: &str) -> anyhow::Error { .into() } +fn next_action_context( + capture: genvm_common::debug_mode::Capture, + contexts: &VecDeque, + context: String, +) -> VecDeque { + match capture { + genvm_common::debug_mode::Capture::Disabled => VecDeque::new(), + genvm_common::debug_mode::Capture::Unbounded => { + let mut contexts = contexts.clone(); + contexts.push_back(context); + contexts + } + genvm_common::debug_mode::Capture::Bounded => { + const CONTEXT_LIMIT: usize = 16; + + let mut contexts = contexts.clone(); + contexts.push_back(context); + if contexts.len() >= CONTEXT_LIMIT { + if contexts.get(1).map(String::as_str) == Some("...") { + contexts.remove(2); + } else { + contexts[1] = "...".to_owned(); + while contexts.len() > CONTEXT_LIMIT { + contexts.remove(2); + } + } + } + contexts + } + } +} + fn maps_into_vm(to: &str) -> bool { to.split('/').find(|component| !component.is_empty()) == Some("vm") } @@ -47,7 +79,7 @@ fn check_mapping_target(to: &str) -> anyhow::Result<()> { } /// How a resolved runner id should be loaded into the archive cache. -enum ResolvedKind { +pub(crate) enum ResolvedKind { /// A packaged `name:hash` runner read from the runners directory. Disk { name: symbol_table::GlobalSymbol, @@ -61,13 +93,15 @@ enum ResolvedKind { on: runners::ChainState, slot: crate::SlotID, }, - /// `custom:` — a runner registered at runtime. - Custom { hash: Bytes32Hash }, + /// `custom:` — a runner registered at runtime. Carries no data: a + /// `custom:` runner resolves against the VM's loaded set only, never a + /// registry lookup, so the load action needs nothing but the canonical id. + Custom, } /// A runner id resolved to its canonical cache key together with the way it /// should be loaded. -struct Resolved { +pub(crate) struct Resolved { id: symbol_table::GlobalSymbol, kind: ResolvedKind, } @@ -84,7 +118,7 @@ impl From for Resolved { on: *on, slot: *slot, }, - runners::Id::Custom { hash } => ResolvedKind::Custom { hash: *hash }, + runners::Id::Custom { .. } => ResolvedKind::Custom, }; Self { id: id.canonical(), @@ -96,7 +130,7 @@ impl From for Resolved { /// Resolves a runner id to its canonical cache key and load strategy. /// /// Free-standing so it can be shared between contract initialization -/// ([`Ctx`]) and runtime `gl_call`s ([`load_runner`]). +/// ([`Ctx`]) and runtime `gl_call`s ([`load_action`]). pub(crate) async fn resolve_runner_id( supervisor: &rt::supervisor::Supervisor, topmost_runner_id: &runners::Id, @@ -202,135 +236,388 @@ async fn resolve_runner( .into()) } -/// Loads (and caches) the archive for an already-resolved runner. -async fn get_arch( +/// Validates a `Sandbox`/`RunNondet` `custom_runners` grant list against the +/// parent's loaded set and returns the pins to grant the child (ADR-012 §4). +/// +/// - `list == None` → grant every `custom:` runner in the parent's loaded set. +/// - `list == Some(list)` → grant exactly `list`: every element must be a +/// `custom:` id, without duplicates, and present in the parent's loaded +/// set (i.e. the parent registered or was itself granted it). +/// +/// `target` is the runner the child will execute; when it is a `custom:` id it +/// must be loaded in the parent and is auto-included (union) in the grant. Every +/// violation is the deterministic malformed-runner VM error. The returned pins +/// (Arc clones) keep the granted content alive across the parent's death, so a +/// queued nondet child still finds it; the child's spawn performs a load action +/// for each, charging its own limiter. +pub(crate) fn resolve_child_custom_runners( + parent_loaded: &runners::cache::LoadedSet, + list: Option>, + target: &runners::Id, +) -> anyhow::Result> { + let mut granted_ids: HashSet = HashSet::new(); + let mut grants: Vec = match list { + None => { + let pins = parent_loaded.custom_pins(); + granted_ids.extend(pins.iter().map(|p| p.runner_id())); + pins + } + Some(list) => { + let mut grants = Vec::with_capacity(list.len()); + for elem in list { + let hash = match runners::parse_runner_id(&elem) { + Some(runners::IdUnresolved::Custom { hash }) => hash, + _ => { + return Err(make_malformed_runner_error(&format!( + "custom_runners entry `{elem}` is not a `custom:` runner id" + ))); + } + }; + let hash = Bytes32Hash::from_gvm32(&hash).map_err(|e| { + make_malformed_runner_error(&format!( + "custom_runners entry `{elem}` hash is not valid gvm32: {e}" + )) + })?; + let id = runners::Id::Custom { hash }.canonical(); + let Some(pin) = parent_loaded.get(id) else { + return Err(make_malformed_runner_error(&format!( + "custom_runners entry `{elem}` is not loaded in this scope" + ))); + }; + if !granted_ids.insert(id) { + return Err(make_malformed_runner_error(&format!( + "custom_runners entry `{elem}` is duplicated" + ))); + } + grants.push(pin.clone()); + } + grants + } + }; + + if let runners::Id::Custom { .. } = target { + let id = target.canonical(); + let Some(pin) = parent_loaded.get(id) else { + return Err(make_malformed_runner_error( + "runner to execute is a custom runner not loaded in this scope", + )); + }; + if granted_ids.insert(id) { + grants.push(pin.clone()); + } + } + + // Every granted pin is drawn from the parent's loaded set (ADR-012 §4). + #[cfg(debug_assertions)] + for pin in &grants { + debug_assert!( + parent_loaded.contains(pin.runner_id()), + "granted custom runner {} absent from parent loaded set", + pin.runner_id().as_str(), + ); + } + + Ok(grants) +} + +/// A charged det-mode load folds its canonical runner id into the execution +/// fingerprint; when `None` (nondet mode, or a free/cached load) nothing is +/// folded (ADR-012 §5). The id is hashed to a fixed 32 bytes first, so +/// variable-length ids cannot alias each other (or a 32-byte sub-VM small-hash) +/// in the shared fingerprint stream. +fn fold_det_fingerprint( + det_fingerprint: Option<&mut sha3::Sha3_256>, + id: symbol_table::GlobalSymbol, +) { + if let Some(fp) = det_fingerprint { + use sha3::Digest as _; + let id_hash: [u8; 32] = sha3::Sha3_256::digest(id.as_str().as_bytes()).into(); + fp.update(id_hash); + } +} + +/// The stable per-load-action observability line (ADR-012): operators and the +/// integration harness grep for the `runner load` message. `status` is +/// `charged` or `cached`. (`const` is a Rust keyword, hence `load_const`.) +fn log_runner_load(id: symbol_table::GlobalSymbol, size: u32, status: &'static str) { + log_info!( + runner = id.as_str(), + load_const = host_fns::LOAD_CONST, + size = size, + status = status; + "runner load" + ); +} + +fn out_of_memory() -> anyhow::Error { + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into() +} + +/// Charges `LOAD_CONST + size` to the VM's limiter, OOM on failure (ADR-012 §1). +/// A `size` past `u32::MAX`, or a sum that overflows, cannot fit any budget and +/// maps to the same OOM. +fn charge_load(limiter: &rt::memlimiter::Limiter, size: usize) -> anyhow::Result<()> { + let ok = u32::try_from(size) + .ok() + .and_then(|size| host_fns::LOAD_CONST.checked_add(size)) + .is_some_and(|amount| limiter.consume(amount)); + if !ok { + return Err(out_of_memory()); + } + Ok(()) +} + +/// Records an already-charged load: folds the det fingerprint, inserts the pin +/// into the loaded set, logs the line. Split from [`charge_load`] because a +/// cache miss charges *before* materializing (so the charge precedes the peak), +/// then records once the pin exists. +fn record_charged_load( + loaded: &mut runners::cache::LoadedSet, + det_fingerprint: Option<&mut sha3::Sha3_256>, + pin: runners::cache::ArchivePin, +) { + let id = pin.runner_id(); + let size = pin.total_size(); + fold_det_fingerprint(det_fingerprint, id); + loaded.insert(pin); + log_runner_load(id, size, "charged"); +} + +/// Attaches to an already-materialized shared cell: charges `LOAD_CONST + size` +/// then records the load. Its charge equals a miss's by content-determinism, so +/// a VM cannot observe whether it materialized or attached (ADR-012 §1). +fn attach_load( + limiter: &rt::memlimiter::Limiter, + loaded: &mut runners::cache::LoadedSet, + det_fingerprint: Option<&mut sha3::Sha3_256>, + pin: runners::cache::ArchivePin, +) -> anyhow::Result { + charge_load(limiter, pin.total_size() as usize)?; + let out = pin.clone(); + record_charged_load(loaded, det_fingerprint, pin); + Ok(out) +} + +/// The **load action**: the single way any runner enters a VM (ADR-012 §1). +/// +/// 1. already in the VM's loaded set → free, done; +/// 2. else charge `LOAD_CONST + size` to `limiter` (OOM on failure), +/// materialize-or-attach the content, insert the pin, fold the det +/// fingerprint. +/// +/// `custom:` runners resolve against the loaded set *only*: they enter it via +/// `RegisterRunner` or an inherited grant (both insert directly), so a `custom:` +/// id that is not already loaded here is a malformed-runner error — never a +/// registry lookup. +pub(crate) async fn load_action( supervisor: &rt::supervisor::Supervisor, limiter: &rt::memlimiter::Limiter, + loaded: &mut runners::cache::LoadedSet, + det_fingerprint: Option<&mut sha3::Sha3_256>, resolved: Resolved, -) -> anyhow::Result<( - symbol_table::GlobalSymbol, - sync::DArc, -)> { - let Resolved { id, kind } = resolved; - - let cache = &supervisor.runner_cache; +) -> anyhow::Result { + if let Some(pin) = loaded.get(resolved.id) { + log_runner_load(resolved.id, pin.total_size(), "cached"); + return Ok(pin.clone()); + } - let new_arch = match kind { + let Resolved { id, kind } = resolved; + match kind { + ResolvedKind::Custom => Err(make_malformed_runner_error(&format!( + "custom runner {id} is not registered in this execution scope" + ))), ResolvedKind::Disk { name, hash } => { - cache - .get_or_create( - id, - || async { - let mut path = cache.runners_path().to_owned(); - runners::append_runner_subpath(name.as_str(), &hash.to_gvm32(), &mut path); - path.set_extension("tar"); - if !path.exists() { - return Err(rt::errors::Error::internal(format!( - "runner {id} not found" - ))); - } - - let data = util::mmap_file(&path) - .with_ctx(|| format!("memory mapping runner archive for {id}"))?; - let data = bytes::Bytes::copy_from_slice(data.as_ref()); - runners::Archive::from_ustar(data) - .with_ctx(|| format!("parsing ustar archive for {id}")) - }, + let cell = supervisor.runner_cache.cell(id); + if cell.initialized() { + return attach_load( limiter, - ) - .await? + loaded, + det_fingerprint, + runners::cache::pin_of(cell), + ); + } + // Miss: learn the size from the file, charge, then materialize (so the + // charge precedes the resident copy). + let mut path = supervisor.runner_cache.runners_path().to_owned(); + runners::append_runner_subpath(name.as_str(), &hash.to_gvm32(), &mut path); + path.set_extension("tar"); + if !path.exists() { + return Err(rt::errors::Error::internal(format!("runner {id} not found")).into()); + } + let data = util::mmap_file(&path) + .with_ctx(|| format!("memory mapping runner archive for {id}"))?; + let charged_size = data.as_ref().len(); + charge_load(limiter, charged_size)?; + cell.get_or_try_init(|| async { + let data = bytes::Bytes::copy_from_slice(data.as_ref()); + let arch = runners::Archive::from_ustar(data) + .with_ctx(|| format!("parsing ustar archive for {id}"))?; + Ok::<_, rt::errors::Error>(runners::ArchiveCache::new(id, arch)) + }) + .await?; + let pin = runners::cache::pin_of(cell); + // Content-determinism: attach and miss charge the same size. + debug_assert_eq!( + pin.total_size() as usize, + charged_size, + "materialized disk archive size differs from the charged size" + ); + record_charged_load(loaded, det_fingerprint, pin.clone()); + Ok(pin) } ResolvedKind::Chain { address, on, slot } => { - cache - .get_or_create( - id, - || async { - let mode = on.host_storage_type().ok_or_else(|| { - make_malformed_runner_error( - "deploy-state chain runner is not available on chain", - ) - })?; - let mut storage = rt::vm::storage::Storage::new( - address, - supervisor.get_storage_limiter(), - crate::wasi::genlayer_sdk::StorageHostHolder( - supervisor.host.clone(), - crate::wasi::genlayer_sdk::ReadToken { - account: address, - mode, - }, - ), - ); - - let dep_major = storage - .read_major() - .await - .with_ctx(|| format!("reading major for chain runner {id}"))?; - let node_major = genvm_common::version::CURRENT.major; - if dep_major as u16 != node_major { - return Err(rt::errors::Error::wrap( - public_abi::VmError::invalid_contract().major_mismatch(), - anyhow::anyhow!( - "chain runner {id} major {dep_major} != node major {node_major}" - ), - )); - } - - let code = storage - .read_code_at(slot, limiter) - .await - .with_ctx(|| format!("reading chain runner code for {id}"))?; - runners::parse(bytes::Bytes::from(code)) - .with_ctx(|| format!("parsing chain runner for {id}")) - }, + let cell = supervisor.runner_cache.cell(id); + if cell.initialized() { + return attach_load( limiter, - ) - .await? - } - ResolvedKind::Custom { hash } => { - cache - .get_or_create( - id, - || async { - supervisor.get_custom_runner(hash).ok_or_else(|| { - rt::errors::Error::internal(format!("custom runner {id} not found")) - }) + loaded, + det_fingerprint, + runners::cache::pin_of(cell), + ); + } + let mode = on.host_storage_type().ok_or_else(|| { + make_malformed_runner_error("deploy-state chain runner is not available on chain") + })?; + let mut storage = rt::vm::storage::Storage::new( + address, + supervisor.get_storage_limiter(), + crate::wasi::genlayer_sdk::StorageHostHolder( + supervisor.host.clone(), + crate::wasi::genlayer_sdk::ReadToken { + account: address, + mode, }, - limiter, + ), + ); + let dep_major = storage + .read_major() + .await + .with_ctx(|| format!("reading major for chain runner {id}"))?; + let node_major = genvm_common::version::CURRENT.major; + if dep_major as u16 != node_major { + return Err(rt::errors::Error::wrap( + public_abi::VmError::invalid_contract().major_mismatch(), + anyhow::anyhow!( + "chain runner {id} major {dep_major} != node major {node_major}" + ), ) - .await? + .into()); + } + // Read the 4-byte length prefix, charge, then fetch the blob inside + // the creator: a single `LOAD_CONST + code_size` charge covers the peak + // (ADR-012 §1) — the old chain double-charge is gone. + let code_size = storage + .read_code_len(slot) + .await + .with_ctx(|| format!("reading chain runner code length for {id}"))?; + charge_load(limiter, code_size as usize)?; + cell.get_or_try_init(|| async move { + let code = storage + .read_code_blob(slot, code_size) + .await + .with_ctx(|| format!("reading chain runner code for {id}"))?; + let arch = runners::parse(bytes::Bytes::from(code)) + .with_ctx(|| format!("parsing chain runner for {id}"))?; + Ok::<_, rt::errors::Error>(runners::ArchiveCache::new(id, arch)) + }) + .await?; + let pin = runners::cache::pin_of(cell); + // Content-determinism: the charged prefix length must equal the + // parsed archive's `total_size` (what a later attach will charge). + debug_assert_eq!( + pin.total_size(), + code_size, + "chain archive total_size differs from the charged code size" + ); + record_charged_load(loaded, det_fingerprint, pin.clone()); + Ok(pin) } - }; + } +} - Ok((id, new_arch)) +/// Inherit-at-spawn load action for a granted custom runner: the child already +/// holds the pin (carried in `SingleVMData`), so this attaches to it — charging +/// the child's limiter and inserting into the child's loaded set (ADR-012 §1/§4). +/// Idempotent: a grant equal to the child's `custom:` entry point is loaded once. +pub(crate) fn inherit_load( + limiter: &rt::memlimiter::Limiter, + loaded: &mut runners::cache::LoadedSet, + det_fingerprint: Option<&mut sha3::Sha3_256>, + pin: runners::cache::ArchivePin, +) -> anyhow::Result<()> { + if loaded.contains(pin.runner_id()) { + log_runner_load(pin.runner_id(), pin.total_size(), "cached"); + return Ok(()); + } + attach_load(limiter, loaded, det_fingerprint, pin)?; + Ok(()) } -/// Resolves and loads a runner archive by id. Shared entry point used by both -/// contract initialization and runtime `gl_call`s. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn load_runner( +/// Registers `code` as a `custom:` runner via the load action against the +/// current VM (ADR-012 §3). The error ladder (spec: `RegisterRunner` error +/// guarantees), in check order: +/// +/// 1. already in this VM's loaded set → free no-op, same id; +/// 2. charge `LOAD_CONST + code.len()` → OOM error, nothing charged or +/// registered; +/// 3. parse (attach to a live registry entry, or parse and insert) → a parse +/// failure is a deterministic invalid-contract error; the charge is retained +/// (released with the VM like any charge) and the runner is not in the loaded +/// set, hence not resolvable; +/// 4. success → pin inserted into the loaded set, canonical id returned. +/// +/// The registered code length equals the archive `total_size`, so the charge +/// equals a later re-register's or an inherit's. A malformed `code` never enters +/// the weak registry (parse runs before insert), so the attach shortcut cannot +/// mask a parse error — outcomes depend only on the bytes, never cache state. +pub(crate) async fn register_runner_load( supervisor: &rt::supervisor::Supervisor, limiter: &rt::memlimiter::Limiter, - id: runners::Id, - available_custom: &rpds::HashTrieSet, -) -> anyhow::Result<( - symbol_table::GlobalSymbol, - sync::DArc, -)> { - let resolved: Resolved = id.into(); - - // Custom runners are execution-scoped: a `custom:` is only resolvable - // by a scope that registered it (or inherited it from a det parent), so the - // shared registry cannot leak a runner across unrelated scopes (e.g. nondet). - if let ResolvedKind::Custom { hash } = &resolved.kind { - if !available_custom.contains(hash) { - return Err(make_malformed_runner_error(&format!( - "custom runner {} is not registered in this execution scope", - resolved.id - ))); - } + loaded: &mut runners::cache::LoadedSet, + det_fingerprint: Option<&mut sha3::Sha3_256>, + code: bytes::Bytes, +) -> anyhow::Result { + register_runner_load_into( + &supervisor.custom_runners, + limiter, + loaded, + det_fingerprint, + code, + ) + .await +} + +/// Core of [`register_runner_load`], parameterized by the weak registry for +/// testability. +async fn register_runner_load_into( + registry: &runners::cache::WeakCache, + limiter: &rt::memlimiter::Limiter, + loaded: &mut runners::cache::LoadedSet, + det_fingerprint: Option<&mut sha3::Sha3_256>, + code: bytes::Bytes, +) -> anyhow::Result { + let hash = runners::custom_runner_hash(&code); + let id = runners::Id::Custom { hash }.canonical(); + + if let Some(pin) = loaded.get(id) { + log_runner_load(id, pin.total_size(), "cached"); + return Ok(id); } - get_arch(supervisor, limiter, resolved).await + + // Charge before parsing (closes the previously-uncharged parse window). + charge_load(limiter, code.len())?; + + let cell = registry.cell(id); + cell.get_or_try_init(|| async { + let archive = runners::parse(code).map_err(|e| { + rt::errors::Error::wrap(public_abi::VmError::invalid_contract().val(), e) + })?; + Ok::<_, rt::errors::Error>(runners::ArchiveCache::new(id, archive)) + }) + .await?; + + record_charged_load(loaded, det_fingerprint, runners::cache::pin_of(cell)); + Ok(id) } /// Maps a file (or, when `file` ends with `/`, a directory subtree) from a @@ -412,13 +699,17 @@ pub(crate) async fn map_runner_file( supervisor: &rt::supervisor::Supervisor, preview1: &mut crate::wasi::preview1::Context, limiter: &rt::memlimiter::Limiter, + loaded: &mut runners::cache::LoadedSet, + det_fingerprint: Option<&mut sha3::Sha3_256>, runner: runners::Id, path_in_runner: &str, path_in_vfs: &str, - available_custom: &rpds::HashTrieSet, ) -> anyhow::Result<()> { - let (_id, arch) = load_runner(supervisor, limiter, runner, available_custom).await?; - map_archive_file(preview1, limiter, &arch, path_in_runner, path_in_vfs) + // The load action pins the archive into the VM's loaded set, keeping its + // backing bytes (which the mapped files share) resident for the VM's life. + let arch = load_action(supervisor, limiter, loaded, det_fingerprint, runner.into()).await?; + map_archive_file(preview1, limiter, &arch, path_in_runner, path_in_vfs)?; + Ok(()) } impl Ctx<'_, '_> { @@ -429,12 +720,19 @@ impl Ctx<'_, '_> { async fn get_arch( &mut self, resolved: Resolved, - ) -> anyhow::Result<( - symbol_table::GlobalSymbol, - sync::DArc, - )> { - let limiter = self.vm.store.data_mut().limits.clone(); - get_arch(self.supervisor, &limiter, resolved).await + ) -> anyhow::Result<(symbol_table::GlobalSymbol, runners::cache::ArchivePin)> { + // A `Depends`/`With` in a runner.json goes through the load action, so a + // `custom:` dependency resolves against this VM's loaded set only — + // a granted `custom:` runner cannot pull in a non-granted one (ADR-012 §4). + let id = resolved.id; + let is_det = self.vm.config_copy.is_deterministic; + let data = self.vm.store.data_mut(); + let limiter = data.limits.clone(); + let crate::wasi::genlayer_sdk::Context { loaded, data, .. } = + &mut data.genlayer_ctx.genlayer_sdk; + let det_fingerprint = is_det.then_some(&mut data.det_subvm_hashes); + let pin = load_action(self.supervisor, &limiter, loaded, det_fingerprint, resolved).await?; + Ok((id, pin)) } fn load_modules( @@ -531,155 +829,803 @@ impl Ctx<'_, '_> { ) -> anyhow::Result> { use runners::InitAction; - match action { - InitAction::MapFile { to, file } => { - let limiter = self.vm.store.data_mut().limits.clone(); - let preview1 = &mut self.vm.store.data_mut().genlayer_ctx_mut().preview1; - map_archive_file(preview1, &limiter, current_runner_arch, file, to)?; - Ok(None) - } - InitAction::AddEnv { name, val } => { - let new_val = genvm_common::templater::patch_str( - &self.env, - val, - &genvm_common::templater::DOLLAR_UNFOLDER_RE, - )?; - self.env.insert(name.clone(), new_val); - Ok(None) + #[derive(Clone)] + enum RunnerArchive<'a> { + Borrowed(&'a runners::ArchiveCache), + Owned(runners::cache::ArchivePin), + } + + impl<'a> std::ops::Deref for RunnerArchive<'a> { + type Target = runners::ArchiveCache; + + fn deref(&self) -> &Self::Target { + match self { + Self::Borrowed(arch) => arch, + Self::Owned(arch) => arch, + } } - InitAction::SetArgs(args) => { - self.vm - .store - .data_mut() - .genlayer_ctx_mut() - .preview1 - .set_args(&args[..])?; - Ok(None) + } + + enum Work<'a> { + SetState { + current: symbol_table::GlobalSymbol, + current_runner_arch: RunnerArchive<'a>, + contexts: VecDeque, + }, + Action(InitAction), + } + + let context_capture = self.supervisor.shared_data.debug_mode.capture(); + let next_context = |contexts: &VecDeque, context: String| { + next_action_context(context_capture, contexts, context) + }; + + let apply_contexts = |mut err: anyhow::Error, contexts: &VecDeque| { + for context in contexts.iter().rev() { + err = err.context(context.clone()); } - InitAction::LinkWasm(path) => { - let contents = current_runner_arch - .get_file(path) - .with_ctx(|| format!("getting file {path:?}"))?; + err + }; + macro_rules! try_context { + ($expr:expr, $contexts:expr) => { + match $expr { + Ok(v) => v, + Err(e) => return Err(apply_contexts(e.into(), $contexts)), + } + }; + } - let module = self - .link_wasm(contents, current, path) - .await - .with_context(|| format!("linking wasm {path:?}"))?; - - let module = module.into_gep(|x| x.get(self.vm.config_copy.is_deterministic)); - - let instance = { - let instance = self - .vm - .linker - .instantiate_async(&mut self.vm.store, &module) - .await - .with_context(|| format!("instantiating {path:?}"))?; - let name = module - .name() - .ok_or_else(|| anyhow::anyhow!("can't link unnamed module {:?}", current)) - .with_context(|| format!("getting module name for {path:?} of {current}")) - .map_err(|e| { - rt::errors::Error::wrap( - public_abi::VmError::invalid_contract().wasm().linking(), - e, - ) - })?; - self.vm - .linker - .instance(&mut self.vm.store, name, instance) - .with_context(|| format!("linking instance {name} for {path:?}"))?; - instance - }; - match instance.get_typed_func::<(), ()>(&mut self.vm.store, "_initialize") { - Err(_) => {} - Ok(func) => { - log_info!(runner = current_runner_arch.runner_id().as_str(), path = path; "calling _initialize"); - func.call_async(&mut self.vm.store, ()).await?; + let mut current = current; + let mut current_runner_arch = RunnerArchive::Borrowed(current_runner_arch); + let mut contexts = VecDeque::new(); + let mut stack = vec![Work::Action(action.clone())]; + + while let Some(work) = stack.pop() { + let action = match work { + Work::SetState { + current: next_current, + current_runner_arch: next_arch, + contexts: next_contexts, + } => { + current = next_current; + current_runner_arch = next_arch; + contexts = next_contexts; + continue; + } + Work::Action(action) => action, + }; + + match action { + InitAction::MapFile { to, file } => { + let limiter = self.vm.store.data_mut().limits.clone(); + let preview1 = &mut self.vm.store.data_mut().genlayer_ctx_mut().preview1; + try_context!( + map_archive_file(preview1, &limiter, ¤t_runner_arch, &file, &to), + &contexts + ); + } + InitAction::AddEnv { name, val } => { + let new_val = try_context!( + genvm_common::templater::patch_str( + &self.env, + &val, + &genvm_common::templater::DOLLAR_UNFOLDER_RE, + ), + &contexts + ); + self.env.insert(name.clone(), new_val); + } + InitAction::SetArgs(args) => { + try_context!( + self.vm + .store + .data_mut() + .genlayer_ctx_mut() + .preview1 + .set_args(&args[..]), + &contexts + ); + } + InitAction::LinkWasm(path) => { + let contents = try_context!( + current_runner_arch + .get_file(&path) + .with_ctx(|| format!("getting file {path:?}")), + &contexts + ); + + let module = try_context!( + self.link_wasm(contents, current, &path) + .await + .with_context(|| format!("linking wasm {path:?}")), + &contexts + ); + let module = module.into_gep(|x| x.get(self.vm.config_copy.is_deterministic)); + + let instance = { + let instance = try_context!( + self.vm + .linker + .instantiate_async(&mut self.vm.store, &module) + .await + .with_context(|| format!("instantiating {path:?}")), + &contexts + ); + let name = try_context!( + module + .name() + .ok_or_else(|| { + anyhow::anyhow!("can't link unnamed module {:?}", current) + }) + .with_context(|| { + format!("getting module name for {path:?} of {current}") + }) + .map_err(|e| { + rt::errors::Error::wrap( + public_abi::VmError::invalid_contract().wasm().linking(), + e, + ) + }), + &contexts + ); + try_context!( + self.vm + .linker + .instance(&mut self.vm.store, name, instance) + .with_context(|| { + format!("linking instance {name} for {path:?}") + }), + &contexts + ); + instance + }; + match instance.get_typed_func::<(), ()>(&mut self.vm.store, "_initialize") { + Err(_) => {} + Ok(func) => { + log_info!(runner = current_runner_arch.runner_id().as_str(), path = path; "calling _initialize"); + try_context!(func.call_async(&mut self.vm.store, ()).await, &contexts); + } } } - Ok(None) - } - InitAction::StartWasm(path) => { - let env: Vec<(String, String)> = self - .env - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - self.vm - .store - .data_mut() - .genlayer_ctx_mut() - .preview1 - .set_env(&env)?; - let contents = current_runner_arch - .get_file(path) - .with_ctx(|| format!("getting file {path:?}"))?; - let module = self - .link_wasm(contents, current, path) - .await - .with_context(|| format!("linking wasm {path:?}"))?; + InitAction::StartWasm(path) => { + let env: Vec<(String, String)> = self + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + try_context!( + self.vm + .store + .data_mut() + .genlayer_ctx_mut() + .preview1 + .set_env(&env), + &contexts + ); + let contents = try_context!( + current_runner_arch + .get_file(&path) + .with_ctx(|| format!("getting file {path:?}")), + &contexts + ); + let module = try_context!( + self.link_wasm(contents, current, &path) + .await + .with_context(|| format!("linking wasm {path:?}")), + &contexts + ); - let module = module.into_gep(|x| x.get(self.vm.config_copy.is_deterministic)); + let module = module.into_gep(|x| x.get(self.vm.config_copy.is_deterministic)); - Ok(Some( - self.vm - .linker - .instantiate_async(&mut self.vm.store, &module) - .await - .with_context(|| format!("instantiating {path:?}"))?, - )) - } - InitAction::When { cond, action } => { - if (*cond == runners::WasmMode::Det) != self.vm.config_copy.is_deterministic { - return Ok(None); + return Ok(Some(try_context!( + self.vm + .linker + .instantiate_async(&mut self.vm.store, &module) + .await + .with_context(|| format!("instantiating {path:?}")), + &contexts + ))); } - Box::pin(self.apply(action, current, current_runner_arch)).await - } - InitAction::Seq(vec) => { - for act in vec { - if let Some(x) = Box::pin(self.apply(act, current, current_runner_arch)).await? - { - return Ok(Some(x)); + InitAction::When { cond, action: next } => { + if (cond == runners::WasmMode::Det) == self.vm.config_copy.is_deterministic { + stack.push(Work::Action(*next)); + } + } + InitAction::Seq(vec) => { + for act in vec.into_iter().rev() { + stack.push(Work::Action(act)); + } + } + InitAction::With { + runner: uid, + action: next, + } => { + let resolved = try_context!(self.resolve_runner(&uid).await, &contexts); + let (uid, new_arch) = try_context!(self.get_arch(resolved).await, &contexts); + + let old_current = current; + let old_arch = current_runner_arch.clone(); + let old_contexts = contexts.clone(); + + let next_contexts = next_context(&contexts, format!("With {uid}")); + + stack.push(Work::SetState { + current: old_current, + current_runner_arch: old_arch, + contexts: old_contexts, + }); + stack.push(Work::Action(*next)); + stack.push(Work::SetState { + current: uid, + current_runner_arch: RunnerArchive::Owned(new_arch), + contexts: next_contexts, + }); + } + InitAction::Depends(uid) => { + let resolved = try_context!(self.resolve_runner(&uid).await, &contexts); + + if self.visited.insert(resolved.id) { + let uid = resolved.id.clone(); + log_trace!(uid = uid; "adding dependency"); + + let (uid, new_arch) = try_context!( + self.get_arch(resolved) + .await + .with_context(|| format!("getting archive for {uid}")), + &contexts + ); + + let new_action = try_context!( + new_arch + .get_actions() + .await + .with_ctx(|| format!("loading {uid} runner.json")), + &contexts + ); + + let old_current = current; + let old_arch = current_runner_arch.clone(); + let old_contexts = contexts.clone(); + + let next_contexts = + next_context(&contexts, format!("applying Depends {uid}")); + + stack.push(Work::SetState { + current: old_current, + current_runner_arch: old_arch, + contexts: old_contexts, + }); + stack.push(Work::Action((*new_action).clone())); + stack.push(Work::SetState { + current: uid, + current_runner_arch: RunnerArchive::Owned(new_arch), + contexts: next_contexts, + }); } } - Ok(None) } - InitAction::With { - runner: uid, - action, - } => { - let resolved = self.resolve_runner(uid).await?; - let (uid, new_arch) = self.get_arch(resolved).await?; - - Box::pin(self.apply(action, uid, &new_arch)) - .await - .with_context(|| format!("With {uid}")) + } + + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn hash(n: u8) -> Bytes32Hash { + Bytes32Hash::from_bytes([n; 32]) + } + + fn custom_id(n: u8) -> symbol_table::GlobalSymbol { + runners::Id::Custom { hash: hash(n) }.canonical() + } + + fn custom_id_str(n: u8) -> String { + custom_id(n).as_str().to_owned() + } + + fn pin(id: symbol_table::GlobalSymbol) -> runners::cache::ArchivePin { + let arch = runners::Archive { + data: BTreeMap::new(), + total_size: 1, + }; + let cell = std::sync::Arc::new(tokio::sync::OnceCell::new_with(Some( + runners::ArchiveCache::new(id, arch), + ))); + runners::cache::pin_of(cell) + } + + /// A loaded set holding `custom:` entries for each of `hashes`. + fn parent_of(hashes: &[u8]) -> runners::cache::LoadedSet { + let mut set = runners::cache::LoadedSet::default(); + for &n in hashes { + set.insert(pin(custom_id(n))); + } + set + } + + fn granted_ids(grants: &[runners::cache::ArchivePin]) -> Vec { + let mut ids: Vec = grants + .iter() + .map(|p| p.runner_id().as_str().to_owned()) + .collect(); + ids.sort(); + ids + } + + fn builtin_target() -> runners::Id { + runners::Id::Builtin { + name: symbol_table::GlobalSymbol::from("py"), + hash: hash(200), + } + } + + fn contexts(items: &[&str]) -> VecDeque { + items.iter().map(|item| (*item).to_owned()).collect() + } + + #[test] + fn bounded_contexts_collapse_middle_at_limit() { + let mut got = VecDeque::new(); + for idx in 0..16 { + got = next_action_context( + genvm_common::debug_mode::Capture::Bounded, + &got, + format!("ctx-{idx}"), + ); + } + + assert_eq!(got.len(), 16); + assert_eq!(got[0], "ctx-0"); + assert_eq!(got[1], "..."); + assert_eq!(got[2], "ctx-2"); + assert_eq!(got[15], "ctx-15"); + } + + #[test] + fn bounded_contexts_drop_after_existing_ellipsis() { + let got = next_action_context( + genvm_common::debug_mode::Capture::Bounded, + &contexts(&[ + "ctx-0", "...", "ctx-2", "ctx-3", "ctx-4", "ctx-5", "ctx-6", "ctx-7", "ctx-8", + "ctx-9", "ctx-10", "ctx-11", "ctx-12", "ctx-13", "ctx-14", "ctx-15", + ]), + "ctx-16".to_owned(), + ); + + assert_eq!(got.len(), 16); + assert_eq!(got[0], "ctx-0"); + assert_eq!(got[1], "..."); + assert_eq!(got[2], "ctx-3"); + assert_eq!(got[15], "ctx-16"); + } + + #[test] + fn unbounded_contexts_do_not_collapse() { + let mut got = VecDeque::new(); + for idx in 0..17 { + got = next_action_context( + genvm_common::debug_mode::Capture::Unbounded, + &got, + format!("ctx-{idx}"), + ); + } + + assert_eq!(got.len(), 17); + assert_eq!(got[1], "ctx-1"); + assert_eq!(got[16], "ctx-16"); + } + + #[test] + fn none_grants_the_whole_parent_custom_set() { + let parent = parent_of(&[1, 2]); + let got = resolve_child_custom_runners(&parent, None, &builtin_target()).unwrap(); + assert_eq!( + granted_ids(&got), + vec![custom_id_str(1), custom_id_str(2)], + "should grant both parent custom entries" + ); + } + + #[test] + fn some_list_grants_exactly_that_subset() { + let parent = parent_of(&[1, 2]); + let got = + resolve_child_custom_runners(&parent, Some(vec![custom_id_str(1)]), &builtin_target()) + .unwrap(); + assert_eq!(granted_ids(&got), vec![custom_id_str(1)]); + } + + #[test] + fn duplicate_element_is_rejected() { + let parent = parent_of(&[1, 2]); + let err = resolve_child_custom_runners( + &parent, + Some(vec![custom_id_str(1), custom_id_str(1)]), + &builtin_target(), + ) + .unwrap_err(); + assert!( + err.to_string().contains("duplicated"), + "unexpected error: {err}" + ); + } + + #[test] + fn non_custom_element_is_rejected() { + let parent = parent_of(&[1]); + let err = resolve_child_custom_runners( + &parent, + Some(vec!["py:abcdef".to_owned()]), + &builtin_target(), + ) + .unwrap_err(); + assert!( + err.to_string().contains("not a `custom:`"), + "unexpected error: {err}" + ); + } + + #[test] + fn element_outside_parent_set_is_rejected() { + let parent = parent_of(&[1]); + let err = + resolve_child_custom_runners(&parent, Some(vec![custom_id_str(9)]), &builtin_target()) + .unwrap_err(); + assert!( + err.to_string().contains("not loaded"), + "unexpected error: {err}" + ); + } + + #[test] + fn custom_target_loaded_in_parent_is_auto_included() { + let parent = parent_of(&[1, 2]); + // Empty explicit list, but the runner to execute is custom:1 (loaded). + let target = runners::Id::Custom { hash: hash(1) }; + let got = resolve_child_custom_runners(&parent, Some(vec![]), &target).unwrap(); + assert_eq!( + granted_ids(&got), + vec![custom_id_str(1)], + "target must be auto-granted" + ); + } + + #[test] + fn custom_target_not_loaded_in_parent_is_rejected() { + let parent = parent_of(&[1]); + let target = runners::Id::Custom { hash: hash(9) }; + let err = resolve_child_custom_runners(&parent, None, &target).unwrap_err(); + assert!( + err.to_string().contains("not loaded"), + "unexpected error: {err}" + ); + } + + #[test] + fn custom_target_already_granted_is_not_duplicated() { + let parent = parent_of(&[1, 2]); + let target = runners::Id::Custom { hash: hash(1) }; + // custom:1 appears both in the explicit grant list and as the target. + let got = + resolve_child_custom_runners(&parent, Some(vec![custom_id_str(1)]), &target).unwrap(); + assert_eq!( + granted_ids(&got), + vec![custom_id_str(1)], + "no dup for target" + ); + } + + // ── load-action charging ──────────────────────────────────────────── + + fn limiter_with_budget(budget: u32) -> rt::memlimiter::Limiter { + let limiter = rt::memlimiter::Limiter::new("actions-test"); + assert!(limiter.consume(u32::MAX - budget)); + limiter + } + + fn fingerprint_of(fp: &sha3::Sha3_256) -> [u8; 32] { + use sha3::Digest as _; + fp.clone().finalize().into() + } + + #[test] + fn charge_load_consumes_load_const_plus_size() { + let limiter = limiter_with_budget(host_fns::LOAD_CONST + 100); + charge_load(&limiter, 100).unwrap(); + assert_eq!( + limiter.get_remaining_memory(), + 0, + "charge must be exactly LOAD_CONST + size" + ); + } + + #[test] + fn charge_load_oom_charges_nothing() { + let budget = host_fns::LOAD_CONST + 99; + let limiter = limiter_with_budget(budget); + let err = charge_load(&limiter, 100).unwrap_err(); + assert!( + err.to_string().contains("out_of memory"), + "unexpected error: {err}" + ); + assert_eq!( + limiter.get_remaining_memory(), + budget, + "a failed charge must leave the budget untouched" + ); + } + + #[test] + fn charge_load_size_overflow_is_oom() { + // LOAD_CONST + u32::MAX overflows; must map to OOM, not wrap. + let limiter = rt::memlimiter::Limiter::new("actions-test"); + let err = charge_load(&limiter, u32::MAX as usize).unwrap_err(); + assert!( + err.to_string().contains("out_of memory"), + "unexpected error: {err}" + ); + assert_eq!(limiter.get_remaining_memory(), u32::MAX); + } + + // ── inherit load (grant transport) ────────────────────────────────── + + #[test] + fn inherit_load_charges_once_then_is_free() { + // Grant pins have total_size 1 (see `pin`). + let budget = 2 * (host_fns::LOAD_CONST + 1); + let limiter = limiter_with_budget(budget); + let mut loaded = runners::cache::LoadedSet::default(); + let granted = pin(custom_id(1)); + + inherit_load(&limiter, &mut loaded, None, granted.clone()).unwrap(); + let after_first = limiter.get_remaining_memory(); + assert_eq!(budget - after_first, host_fns::LOAD_CONST + 1); + assert!(loaded.contains(custom_id(1)), "grant must be pinned"); + + // Same id again (e.g. also the child's custom entry point): free. + inherit_load(&limiter, &mut loaded, None, granted).unwrap(); + assert_eq!( + limiter.get_remaining_memory(), + after_first, + "an already-loaded runner must not be charged again" + ); + } + + #[test] + fn inherit_load_oom_leaves_loaded_set_unchanged() { + // One short of LOAD_CONST + total_size(=1). + let budget = host_fns::LOAD_CONST; + let limiter = limiter_with_budget(budget); + let mut loaded = runners::cache::LoadedSet::default(); + + let err = inherit_load(&limiter, &mut loaded, None, pin(custom_id(1))).unwrap_err(); + assert!( + err.to_string().contains("out_of memory"), + "unexpected error: {err}" + ); + assert!(!loaded.contains(custom_id(1))); + assert_eq!(limiter.get_remaining_memory(), budget); + } + + // ── det fingerprint ───────────────────────────────────────────────── + + #[test] + fn det_fingerprint_folds_charged_loads_in_execution_order() { + let load = |ids: &[u8]| { + let limiter = rt::memlimiter::Limiter::new("actions-test"); + let mut loaded = runners::cache::LoadedSet::default(); + let mut fp = sha3::Sha3_256::default(); + for &n in ids { + inherit_load(&limiter, &mut loaded, Some(&mut fp), pin(custom_id(n))).unwrap(); } - InitAction::Depends(uid) => { - let resolved = self.resolve_runner(uid).await?; + fingerprint_of(&fp) + }; - if !self.visited.insert(resolved.id) { - return Ok(None); - } + assert_ne!(load(&[1]), load(&[2]), "different runner sets must diverge"); + assert_ne!(load(&[1, 2]), load(&[2, 1]), "order is part of the stream"); + assert_eq!( + load(&[1, 2]), + load(&[1, 2]), + "same history, same fingerprint" + ); + } - let uid = resolved.id.clone(); - log_trace!(uid = uid; "adding dependency"); + #[test] + fn det_fingerprint_ignores_cached_loads() { + let limiter = rt::memlimiter::Limiter::new("actions-test"); + let mut loaded = runners::cache::LoadedSet::default(); + let mut fp = sha3::Sha3_256::default(); - let (uid, new_arch) = self - .get_arch(resolved) - .await - .with_context(|| format!("getting archive for {uid}"))?; + inherit_load(&limiter, &mut loaded, Some(&mut fp), pin(custom_id(1))).unwrap(); + let after_charged = fingerprint_of(&fp); - let new_action = new_arch - .get_actions() - .await - .with_ctx(|| format!("loading {uid} runner.json"))?; + // A free (already-loaded) load must not alter the fingerprint. + inherit_load(&limiter, &mut loaded, Some(&mut fp), pin(custom_id(1))).unwrap(); + assert_eq!(fingerprint_of(&fp), after_charged); + } + + // ── RegisterRunner error ladder ───────────────────────────────────── + + fn valid_code() -> bytes::Bytes { + bytes::Bytes::from_static(b"# { \"Depends\": \"py-genlayer:test\" }\n") + } - Box::pin(self.apply(&new_action, uid, &new_arch)) + fn custom_id_of(code: &bytes::Bytes) -> symbol_table::GlobalSymbol { + runners::Id::Custom { + hash: runners::custom_runner_hash(code), + } + .canonical() + } + + #[tokio::test] + async fn register_charges_load_const_plus_code_len_and_pins() { + let registry = runners::cache::WeakCache::new(); + let code = valid_code(); + let budget = 2 * (host_fns::LOAD_CONST + code.len() as u32); + let limiter = limiter_with_budget(budget); + let mut loaded = runners::cache::LoadedSet::default(); + + let id = register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()) + .await + .unwrap(); + + assert_eq!(id, custom_id_of(&code)); + assert_eq!( + budget - limiter.get_remaining_memory(), + host_fns::LOAD_CONST + code.len() as u32 + ); + assert!(loaded.contains(id), "registered runner must be resolvable"); + } + + #[tokio::test] + async fn register_same_code_in_same_vm_is_free() { + let registry = runners::cache::WeakCache::new(); + let code = valid_code(); + let limiter = rt::memlimiter::Limiter::new("actions-test"); + let mut loaded = runners::cache::LoadedSet::default(); + + let id = register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()) + .await + .unwrap(); + let after_first = limiter.get_remaining_memory(); + + for _ in 0..3 { + let again = + register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()) .await - .with_context(|| format!("applying Depends {uid}")) - } + .unwrap(); + assert_eq!(again, id, "re-register must return the same id"); } + assert_eq!( + limiter.get_remaining_memory(), + after_first, + "same-VM re-register must be free" + ); + } + + #[tokio::test] + async fn register_oom_charges_and_registers_nothing() { + let registry = runners::cache::WeakCache::new(); + let code = valid_code(); + let budget = host_fns::LOAD_CONST + code.len() as u32 - 1; + let limiter = limiter_with_budget(budget); + let mut loaded = runners::cache::LoadedSet::default(); + + let err = register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()) + .await + .unwrap_err(); + assert!( + err.to_string().contains("out_of memory"), + "unexpected error: {err}" + ); + assert_eq!(limiter.get_remaining_memory(), budget, "nothing charged"); + assert!(!loaded.contains(custom_id_of(&code)), "nothing pinned"); + assert!( + !registry.cell(custom_id_of(&code)).initialized(), + "nothing registered" + ); + } + + #[tokio::test] + async fn register_parse_failure_retains_charge_and_is_not_resolvable() { + let registry = runners::cache::WeakCache::new(); + // Not a zip, not wasm, not UTF-8 text: parse fails on the bytes alone. + let code = bytes::Bytes::from_static(b"\xff\xfe\xfd"); + let budget = host_fns::LOAD_CONST + code.len() as u32; + let limiter = limiter_with_budget(budget); + let mut loaded = runners::cache::LoadedSet::default(); + + let err = register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()) + .await + .unwrap_err(); + assert!( + err.to_string().contains("invalid_contract"), + "unexpected error: {err}" + ); + assert_eq!( + limiter.get_remaining_memory(), + 0, + "the pre-parse charge is retained on parse failure" + ); + assert!( + !loaded.contains(custom_id_of(&code)), + "a failed registration must not be resolvable" + ); + assert!( + !registry.cell(custom_id_of(&code)).initialized(), + "malformed code must not enter the registry" + ); + } + + /// Grant transport (ADR-012 §4): the pins handed to a child at `RunNondet`/ + /// `Sandbox` call time keep the content alive even after the granting parent + /// dies — a queued nondet validator task must still find it and load it into + /// its own set, charged to its own limiter. + #[tokio::test] + async fn granted_pins_keep_content_alive_after_parent_death() { + let registry = runners::cache::WeakCache::new(); + let code = valid_code(); + let parent_limiter = rt::memlimiter::Limiter::new("actions-test-parent"); + let mut parent = runners::cache::LoadedSet::default(); + let id = + register_runner_load_into(®istry, &parent_limiter, &mut parent, None, code.clone()) + .await + .unwrap(); + + // gl_call time: the grant is computed and pinned while the parent lives. + let grants = resolve_child_custom_runners(&parent, None, &builtin_target()).unwrap(); + + // The parent VM dies before the queued child runs. + drop(parent); + assert!( + registry.cell(id).initialized(), + "granted pin must keep the content resident past the parent's death" + ); + + // Child spawn: inherit load actions charge the child's own limiter. + let cost = host_fns::LOAD_CONST + code.len() as u32; + let child_limiter = limiter_with_budget(cost); + let mut child = runners::cache::LoadedSet::default(); + for grant in grants { + inherit_load(&child_limiter, &mut child, None, grant).unwrap(); + } + assert_eq!( + child_limiter.get_remaining_memory(), + 0, + "child pays for the grant" + ); + assert!(child.contains(id)); + } + + #[tokio::test] + async fn register_dead_content_reparses_and_recharges_identically() { + let registry = runners::cache::WeakCache::new(); + let code = valid_code(); + let cost = host_fns::LOAD_CONST + code.len() as u32; + let limiter = limiter_with_budget(2 * cost); + + let mut loaded = runners::cache::LoadedSet::default(); + let id = register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()) + .await + .unwrap(); + assert_eq!(limiter.get_remaining_memory(), cost); + + // The registering scope dies: its loaded set (the only pin) drops and the + // weak registry entry becomes dead. + drop(loaded); + assert!(!registry.cell(id).initialized(), "content freed with scope"); + + // Re-register in a fresh scope: re-parses and charges the same amount. + let mut fresh = runners::cache::LoadedSet::default(); + let again = register_runner_load_into(®istry, &limiter, &mut fresh, None, code) + .await + .unwrap(); + assert_eq!(again, id); + assert_eq!(limiter.get_remaining_memory(), 0, "identical re-charge"); + assert!(fresh.contains(id)); } } diff --git a/executor/src/rt/supervisor/mod.rs b/executor/src/rt/supervisor/mod.rs index 1107af4b..06793fdc 100644 --- a/executor/src/rt/supervisor/mod.rs +++ b/executor/src/rt/supervisor/mod.rs @@ -5,7 +5,6 @@ use std::{ use anyhow::Context as _; use genvm_common::*; -use symbol_table::GlobalSymbol; use crate::{ config, host, public_abi, @@ -76,14 +75,26 @@ pub struct Supervisor { runner_cache: runners::cache::Reader, wasm_mod_cache: WasmModuleCache, - /// Runners registered at runtime via `gl_call`, looked up by the - /// `custom:` runner id. Empty until a contract registers one. - custom_runners: dashmap::DashMap, + /// Weak registry of runtime-registered `custom:` runners, keyed by + /// canonical id. Holds only weak references — a registration lives while some + /// VM's loaded set pins it (ADR-012 §3). Pure cross-scope dedup: resolution + /// never reads it, so a scope can only use what it loaded. + custom_runners: runners::cache::WeakCache, pub(crate) engines: rt::DetNondet, pub(crate) host: Arc, } +#[cfg(debug_assertions)] +impl Drop for Supervisor { + fn drop(&mut self) { + // Every VM (and thus every loaded-set pin) is gone by supervisor teardown, + // so no registered custom runner may still be resident (ADR-012 §3). + self.custom_runners + .assert_empty_on_teardown("custom runner"); + } +} + pub fn create_engines( config_base: impl FnOnce(&mut wasmtime::Config) -> anyhow::Result<()>, ) -> anyhow::Result> { @@ -93,7 +104,9 @@ pub fn create_engines( .debug_info(true) .wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Disable) .consume_fuel(false) - .cranelift_opt_level(wasmtime::OptLevel::None); + .cranelift_opt_level(wasmtime::OptLevel::None) + .async_stack_size(8 << 20) + .max_wasm_stack(6 << 20); base_conf .wasm_tail_call(true) @@ -150,9 +163,14 @@ pub async fn await_nondet_vms(zelf: &Arc) -> anyhow::Result Option { - self.custom_runners.get(&hash).map(|r| r.clone()) - } - + /// Inserts the just-deployed contract's runner into the cache and returns a + /// pin. The cache is weakly held, so the caller (the top-level execution + /// scope in `lib.rs`) must keep the pin for the whole run — otherwise the + /// entry would be evicted before the contract VM loads it. + #[must_use] pub fn prepopulate_deploy_runner( &self, address: calldata::Address, code_slot: crate::SlotID, archive: runners::Archive, - ) { + ) -> runners::cache::ArchivePin { let id = runners::Id::Chain { address, on: runners::ChainState::Deploy, slot: code_slot, } .canonical(); - self.runner_cache.put(id, archive); - } - - /// Registers a runner from its `code`, returning the `custom:` id it can - /// be referenced by. - /// - /// The archive is parsed and charged against `limiter` only the first time a - /// given hash is seen — re-registering the same code is a cheap no-op, so a - /// contract cannot exhaust the memory limit by registering in a loop. - pub fn register_custom_runner( - &self, - code: bytes::Bytes, - limiter: &rt::memlimiter::Limiter, - ) -> anyhow::Result { - register_custom_runner_into(&self.custom_runners, code, limiter) + self.runner_cache.put(id, archive) } pub fn start(config: &config::Config, ctor: Ctor) -> anyhow::Result> { @@ -325,7 +330,7 @@ impl Supervisor { cache_dir: my_cache_dir, wasm_modules_cache: sync::CacheMap::new(), }, - custom_runners: dashmap::DashMap::new(), + custom_runners: runners::cache::WeakCache::new(), host: Arc::new(ctor.multi_host), engines, }); @@ -401,6 +406,34 @@ pub async fn spawn( }); } + // Inherit-at-spawn (ADR-012 §1/§4): perform a load action for each custom + // runner granted to this child, charging its own limiter, *before* its main + // runner loads (so a custom entry point granted here is not charged twice). + // The pins were carried in `SingleVMData`, so the content survives even if the + // granting parent has died (queued nondet VMs). + { + let is_det = vm_base.config_copy.is_deterministic; + let child_limiter = vm_base.store.data().limits.clone(); + let store_data = vm_base.store.data_mut(); + let wasi::genlayer_sdk::Context { loaded, data, .. } = + &mut store_data.genlayer_ctx.genlayer_sdk; + let grants = std::mem::take(&mut data.granted_custom); + let mut det_fingerprint = is_det.then_some(&mut data.det_subvm_hashes); + for grant in grants { + if let Err(e) = actions::inherit_load( + &child_limiter, + loaded, + det_fingerprint.as_deref_mut(), + grant, + ) { + return Err(rt::SpawnError { + error: e, + state: Box::new(rt::SpawnErrorState::Spawned(vm_base)), + }); + } + } + } + Ok(rt::vm::VM { vm_base, data: () }) } @@ -448,15 +481,25 @@ async fn apply_contract_actions_inner( let topmost_runner_id = data.conf.topmost_runner_id.clone(); - let arch = actions::load_runner( - zelf, - &limiter, - topmost_runner_id.clone(), - &data.accumulator.custom_runners, - ) - .await - .with_context(|| format!("getting runner for {topmost_runner_id}"))? - .1; + // Main-runner load action (ADR-012 §1). Runs *after* the spawn-time inherit + // loads, so a custom entry point granted to this VM is already loaded (free + // here, not double charged). Charges this VM's own limiter. + let arch = { + let is_det = vm.vm_base.config_copy.is_deterministic; + let store_data = vm.vm_base.store.data_mut(); + let wasi::genlayer_sdk::Context { loaded, data, .. } = + &mut store_data.genlayer_ctx.genlayer_sdk; + let det_fingerprint = is_det.then_some(&mut data.det_subvm_hashes); + actions::load_action( + zelf, + &limiter, + loaded, + det_fingerprint, + topmost_runner_id.clone().into(), + ) + .await + .with_context(|| format!("getting runner for {topmost_runner_id}"))? + }; let actions = arch .get_actions() @@ -497,7 +540,13 @@ async fn run_single_nondet( task: NonDetVMTask, limiter: memlimiter::Limiter, ) -> anyhow::Result { - match run_single_nondet_inner(zelf, task, limiter).await { + let zelf = zelf.clone(); + let res = + tokio::task::spawn(async move { run_single_nondet_inner(&zelf, task, limiter).await }) + .await + .map_err(|e| anyhow::anyhow!("nondet VM task failed to join: {e}"))?; + + match res { Ok(v) => Ok(v.run_ok), Err(e) => rt::errors::unwrap_vm_errors(rt::errors::UnwrapDynError::from(e)), } @@ -589,53 +638,3 @@ async fn nondet_vm_processor( std::mem::drop(read_permit); log_debug!(count = count; "nondet worker done"); } - -/// Core of [`Supervisor::register_custom_runner`]; charges the parsed archive -/// against `limiter` only on the first registration of a given hash. -fn register_custom_runner_into( - custom_runners: &dashmap::DashMap, - code: bytes::Bytes, - limiter: &rt::memlimiter::Limiter, -) -> anyhow::Result { - let hash = runners::custom_runner_hash(&code); - - if let dashmap::mapref::entry::Entry::Vacant(slot) = custom_runners.entry(hash) { - let archive = runners::parse(code).map_err(|e| { - rt::errors::Error::wrap(public_abi::VmError::invalid_contract().val(), e) - })?; - if !limiter.consume(archive.total_size) { - return Err(rt::errors::Error::vm(public_abi::VmError::out_of().memory().val()).into()); - } - slot.insert(archive); - } - - Ok(runners::Id::Custom { hash }.canonical()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn register_custom_runner_consumes_once() { - let map = dashmap::DashMap::new(); - let limiter = rt::memlimiter::Limiter::new("test-register"); - let code = bytes::Bytes::from_static(b"# { \"Depends\": \"py-genlayer:test\" }\n"); - - let id = register_custom_runner_into(&map, code.clone(), &limiter).unwrap(); - let remaining_after_first = limiter.get_remaining_memory(); - - // the first registration must have charged the archive - assert!(remaining_after_first < u32::MAX); - - // re-registering the same code must be a cheap no-op: same id, no extra - // memory charged and no duplicate map entries - for _ in 0..1000 { - let again = register_custom_runner_into(&map, code.clone(), &limiter).unwrap(); - assert_eq!(again, id); - } - - assert_eq!(limiter.get_remaining_memory(), remaining_after_first); - assert_eq!(map.len(), 1); - } -} diff --git a/executor/src/rt/vm/storage.rs b/executor/src/rt/vm/storage.rs index 3707f25c..1b1f1c2d 100644 --- a/executor/src/rt/vm/storage.rs +++ b/executor/src/rt/vm/storage.rs @@ -5,7 +5,7 @@ use const_lru::ConstLru; use genlayer_sdk::abi; use genvm_common::{calldata, sync}; -use crate::{public_abi::root_offsets, rt, runners, SlotID}; +use crate::{public_abi::root_offsets, rt, SlotID}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] #[repr(C)] @@ -65,15 +65,19 @@ pub trait HostStorageLocking { } #[derive(Clone, Debug)] -pub struct Limiter(sync::DArc); +pub struct Limiter { + limit: sync::DArc, +} impl Limiter { pub fn new(data_fees_limit: sync::DArc) -> Self { - Self(data_fees_limit) + Self { + limit: data_fees_limit, + } } pub async fn consume(&self, amount: u64) -> rt::errors::Result<()> { - if !self.0.consume_storage_pages(amount).await? { + if !self.limit.consume_storage_pages(amount).await? { return Err(rt::errors::Error::wrap( abi::consts::VmError::out_of().storage(), anyhow::anyhow!("consuming {amount} storage pages"), @@ -478,21 +482,25 @@ impl Storage { self.resolve_code_slot().await } - pub async fn read_code_at( - &mut self, - code_slot: SlotID, - limiter: &rt::memlimiter::Limiter, - ) -> rt::errors::Result> { + /// Reads the 4-byte little-endian length prefix of the code blob at + /// `code_slot`. Cheap (one slot read, no fee metering) — the runner load + /// action reads this first to learn the size to charge *before* fetching the + /// blob, so a single `LOAD_CONST + code_size` charge covers the peak + /// (ADR-012 §1). + pub async fn read_code_len(&mut self, code_slot: SlotID) -> rt::errors::Result { let mut len_buf = [0; 4]; self.read(code_slot, 0, &mut len_buf).await?; - let code_size = u32::from_le_bytes(len_buf); - - if !limiter.consume(code_size) { - return Err(rt::errors::Error::vm( - abi::consts::VmError::out_of().memory().val(), - )); - } + Ok(u32::from_le_bytes(len_buf)) + } + /// Reads the code blob of `code_size` bytes at `code_slot` (after its 4-byte + /// length prefix). Charges nothing: the load action already charged + /// `code_size` via [`read_code_len`](Self::read_code_len). + pub async fn read_code_blob( + &mut self, + code_slot: SlotID, + code_size: u32, + ) -> rt::errors::Result> { let res = Box::new_uninit_slice(code_size as usize); let mut res = unsafe { res.assume_init() }; @@ -506,6 +514,100 @@ impl Storage { mod tests { use super::*; + // ── code slot reads (runner load action support) ──────────────────── + + /// In-memory host: one slot whose content is `code_len_le ++ code`; reads + /// beyond the content are zero-filled (like unwritten storage). + #[derive(Clone)] + struct FakeHost(std::sync::Arc>); + + struct FakeLock(std::sync::Arc>); + + impl HostStorage for FakeLock { + fn storage_read( + &mut self, + _slot_id: SlotID, + index: u32, + buf: &mut [u8], + ) -> anyhow::Result<()> { + let data = &self.0; + for (i, out) in buf.iter_mut().enumerate() { + *out = data.get(index as usize + i).copied().unwrap_or(0); + } + Ok(()) + } + } + + impl HostStorageLocking for FakeHost { + type ReturnType<'a> = FakeLock; + + async fn lock(&self) -> FakeLock { + FakeLock(self.0.clone()) + } + } + + /// A minimal `DataLimit` whose storage bucket charges one unit per page. + fn data_fees(total_pages: u64) -> Limiter { + use crate::config::{FeesBucketConfig, FeesConfig}; + let bucket = |delta: &str| FeesBucketConfig { + bucket_no: vec![0], + subtract_on_start_expr: "0".to_owned(), + delta_expr: delta.to_owned(), + }; + let fees = FeesConfig { + expr_prelude: String::new(), + storage: bucket("\\attrs = attrs.pages"), + message_receipt: bucket("\\attrs = 0"), + nondet_output: bucket("\\attrs = 0"), + message_fee: bucket("\\attrs = 0"), + event: bucket("\\attrs = 0"), + }; + let dl = rt::fees::DataLimit::new( + vec![primitive_types::U256::from(total_pages)], + fees, + Default::default(), + ) + .unwrap(); + Limiter::new(sync::DArc::new(dl)) + } + + fn code_storage(code: &[u8]) -> Storage { + let mut slot = (code.len() as u32).to_le_bytes().to_vec(); + slot.extend_from_slice(code); + Storage::new( + calldata::Address::zero(), + data_fees(u64::MAX), + FakeHost(std::sync::Arc::new(slot)), + ) + } + + /// The runner load action reads the 4-byte length prefix first (to know + /// what to charge), then the blob at offset 4 — the two reads must + /// reassemble exactly the stored code. + #[tokio::test] + async fn code_len_then_blob_reassembles_the_code() { + // A length that is not 32-aligned exercises the page-offset math. + let code: Vec = (0u16..77).map(|i| i as u8).collect(); + let mut storage = code_storage(&code); + let slot = default_code_slot(); + + let len = storage.read_code_len(slot).await.unwrap(); + assert_eq!(len, 77); + + let blob = storage.read_code_blob(slot, len).await.unwrap(); + assert_eq!(&blob[..], &code[..], "blob must skip the 4-byte prefix"); + } + + #[tokio::test] + async fn code_len_of_empty_code_is_zero() { + let mut storage = code_storage(&[]); + let slot = default_code_slot(); + assert_eq!(storage.read_code_len(slot).await.unwrap(), 0); + assert!(storage.read_code_blob(slot, 0).await.unwrap().is_empty()); + } + + // ── page id ordering ──────────────────────────────────────────────── + #[test] fn pages_sorted_correctly_1_byte() { let left = PageID(SlotID::from_bytes([1u8; 32]), 5); diff --git a/executor/src/runners/cache.rs b/executor/src/runners/cache.rs index a78920a5..892a31ff 100644 --- a/executor/src/runners/cache.rs +++ b/executor/src/runners/cache.rs @@ -1,9 +1,206 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; use super::*; use crate::rt::errors::{self, ResultExt}; -use genlayer_sdk::abi; -use genvm_common::*; +use symbol_table::GlobalSymbol; + +/// A shared, initialize-once cell holding an archive. A live [`ArchivePin`] keeps +/// it resident; the weak caches ([`WeakCache`]) that index it hold only weak +/// references to it. +pub type Cell = Arc>; + +/// A strong reference (pin) to loaded runner content. While at least one pin is +/// held the content stays resident; when the last pin drops the weak map's entry +/// becomes dead and is purged on next access, freeing the bytes. Pins live in a +/// VM's [`LoadedSet`] (its store data) and thus die with the VM, so the limiter +/// refund at VM death coincides with the memory actually becoming freeable +/// (ADR-012). +#[derive(Clone)] +pub struct ArchivePin(Cell); + +impl ArchivePin { + fn entry(&self) -> &ArchiveCache { + self.0 + .get() + .expect("an ArchivePin always wraps an initialized cell") + } + + /// The charged size of this runner's content — the archive `total_size`, + /// which for every source equals the raw content length (ADR-012 §1). + pub fn total_size(&self) -> u32 { + self.entry().files.total_size + } +} + +impl std::ops::Deref for ArchivePin { + type Target = ArchiveCache; + + fn deref(&self) -> &ArchiveCache { + self.entry() + } +} + +impl std::fmt::Debug for ArchivePin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("ArchivePin") + .field(&self.runner_id().as_str()) + .finish() + } +} + +/// Per-VM **loaded set**: the single charge-dedup set and pin holder. Maps a +/// canonical runner id to the pin keeping its content resident. Stored next to +/// the VM's limiter (in its store data), **not** in the accumulator: the +/// accumulator round-trips through sandbox children, whereas the loaded set must +/// die with its VM. It is both the "already loaded → free" set and the pin +/// holder; those must never be split (ADR-012 §1). +#[derive(Default)] +pub struct LoadedSet(HashMap); + +impl LoadedSet { + /// Whether `id` is already loaded in this VM (→ a load action is free). + pub fn contains(&self, id: GlobalSymbol) -> bool { + self.0.contains_key(&id) + } + + pub fn get(&self, id: GlobalSymbol) -> Option<&ArchivePin> { + self.0.get(&id) + } + + /// Records a freshly charged pin. The load action guarantees the id was not + /// already present (it returns early on a hit), so this always inserts. + pub fn insert(&mut self, pin: ArchivePin) { + let id = pin.runner_id(); + let prev = self.0.insert(id, pin); + debug_assert!( + prev.is_none(), + "loaded set re-inserted an already-loaded runner {}", + id.as_str(), + ); + #[cfg(debug_assertions)] + self.assert_invariants(); + } + + /// Pins of the `custom:` runners loaded in this VM, in id order — the set a + /// child may be granted (ADR-012 §4). The string-prefix filter is sound + /// because `parse_runner_id` reserves `custom:` (a builtin name can never be + /// `custom`), so no other id kind can produce the prefix canonically. + pub fn custom_pins(&self) -> Vec { + let mut out: Vec = self + .0 + .iter() + .filter(|(id, _)| id.as_str().starts_with("custom:")) + .map(|(_, pin)| pin.clone()) + .collect(); + out.sort_by_key(|pin| pin.runner_id().as_str()); + out + } + + /// Every entry is keyed by its pin's canonical runner id. + #[cfg(debug_assertions)] + fn assert_invariants(&self) { + for (key, pin) in &self.0 { + debug_assert!( + pin.runner_id() == *key, + "loaded set keyed {} holds a pin for {}", + key.as_str(), + pin.runner_id().as_str(), + ); + } + } +} + +/// Weakly-held content index: the map owns only [`Weak`](std::sync::Weak) +/// references, so an entry lives exactly as long as some [`ArchivePin`] to it +/// does. Dead weaks are purged lazily when their key is next touched. Backs both +/// the disk/chain archive cache and the custom-runner registry — a pure dedup +/// mechanism whose state a VM cannot observe (ADR-012 §1). +pub struct WeakCache( + dashmap::DashMap>>, +); + +impl Default for WeakCache { + fn default() -> Self { + Self::new() + } +} + +impl WeakCache { + pub fn new() -> Self { + Self(dashmap::DashMap::new()) + } + + /// Returns a strong cell for `key`, deduplicating concurrent creation: two + /// VMs racing on the same id observe the same cell (so a single creator + /// runs), while a dead weak is replaced with a fresh cell (purge). + pub fn cell(&self, key: GlobalSymbol) -> Cell { + // The entry guard must drop before `assert_invariants` iterates the map: + // `iter()` locks every shard, `entry()` holds one, so overlapping them on + // the same thread would deadlock. + let strong = match self.0.entry(key) { + dashmap::Entry::Occupied(mut occ) => match occ.get().upgrade() { + Some(strong) => strong, + None => { + let strong = Arc::new(tokio::sync::OnceCell::new()); + occ.insert(Arc::downgrade(&strong)); + strong + } + }, + dashmap::Entry::Vacant(vac) => { + let strong = Arc::new(tokio::sync::OnceCell::new()); + vac.insert(Arc::downgrade(&strong)); + strong + } + }; + #[cfg(debug_assertions)] + self.assert_invariants(); + strong + } + + fn insert_strong(&self, key: GlobalSymbol, cell: &Cell) { + self.0.insert(key, Arc::downgrade(cell)); + #[cfg(debug_assertions)] + self.assert_invariants(); + } + + /// Every live weak upgrades to a cell that, once its `OnceCell` is + /// initialized, holds an [`ArchiveCache`] whose id equals the map key. A + /// dead weak or a not-yet-initialized cell is vacuously fine. O(entries), + /// debug-only. + #[cfg(debug_assertions)] + fn assert_invariants(&self) { + for entry in self.0.iter() { + let Some(strong) = entry.value().upgrade() else { + continue; + }; + let Some(cache) = strong.get() else { + continue; + }; + debug_assert!( + cache.runner_id() == *entry.key(), + "cache entry keyed {} holds an archive with id {}", + entry.key().as_str(), + cache.runner_id().as_str(), + ); + } + } + + /// Teardown check: no live entry may remain. Called after every VM (and thus + /// every pin) is dropped, asserting the ADR-012 invariant that resident + /// content is always covered by a live pin — with no live VMs, none is + /// pinned. Debug-only. + #[cfg(debug_assertions)] + pub fn assert_empty_on_teardown(&self, what: &str) { + for entry in self.0.iter() { + debug_assert!( + entry.value().upgrade().is_none(), + "{what} {} still pinned at teardown", + entry.key().as_str() + ); + } + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] #[repr(transparent)] @@ -47,13 +244,22 @@ impl<'de> serde::Deserialize<'de> for StrSymbol { } pub struct Reader { - cache: sync::CacheMap, + cache: WeakCache, runners_data_path: std::path::PathBuf, all: BTreeMap>, latest: BTreeMap, } +impl Drop for Reader { + fn drop(&mut self) { + // Supervisor teardown happens after every VM is dropped, so every pin is + // gone and the weak map must hold no live entry (ADR-012 §1). + #[cfg(debug_assertions)] + self.cache.assert_empty_on_teardown("archive"); + } +} + impl Reader { pub fn new( path: &std::path::Path, @@ -86,7 +292,7 @@ impl Reader { }; Ok(Self { - cache: sync::CacheMap::new(), + cache: WeakCache::new(), runners_data_path: runners_path.clone(), all, latest, @@ -108,47 +314,46 @@ impl Reader { &self.runners_data_path } - pub fn put(&self, id: symbol_table::GlobalSymbol, archive: Archive) { - self.cache.insert(id, ArchiveCache::new(id, archive)); - } - - pub async fn get_or_create( - &self, - name: symbol_table::GlobalSymbol, - arch_provider: impl FnOnce() -> F, - limiter: &rt::memlimiter::Limiter, - ) -> errors::Result> - where - F: std::future::Future>, - { - let called = std::sync::atomic::AtomicBool::new(false); - - let res = self - .cache - .get_or_create(name, || async { - called.store(true, std::sync::atomic::Ordering::SeqCst); - let arch = arch_provider().await?; - if !limiter.consume(arch.total_size) { - return Err(errors::Error::vm( - abi::consts::VmError::out_of().memory().val(), - )); - } - Ok(ArchiveCache::new(name, arch)) - }) - .await?; - - if !called.load(std::sync::atomic::Ordering::SeqCst) - && !limiter.consume(res.files.total_size) - { - return Err(errors::Error::vm( - abi::consts::VmError::out_of().memory().val(), - )); - } + /// The shared, weakly-held content cell for a disk/`chain:` runner id. The + /// load action attaches to it (if a live pin already initialized it) or + /// initializes it via its own creator (ADR-012 §1). + pub fn cell(&self, id: symbol_table::GlobalSymbol) -> Cell { + self.cache.cell(id) + } - Ok(res) + /// Inserts `archive` under `id` and returns a pin the caller must hold for + /// as long as the entry should stay resident. Used by the deploy + /// prepopulation path, whose pin is held by the top-level execution scope + /// until the root VM's main load action attaches to (and charges for) it. + pub fn put(&self, id: symbol_table::GlobalSymbol, archive: Archive) -> ArchivePin { + let cell = Arc::new(tokio::sync::OnceCell::new_with(Some(ArchiveCache::new( + id, archive, + )))); + self.cache.insert_strong(id, &cell); + let pin = ArchivePin(cell); + // The cell is initialized (deref would panic otherwise) and keyed by `id`. + #[cfg(debug_assertions)] + debug_assert!( + pin.runner_id() == id, + "put({}) produced a pin for {}", + id.as_str(), + pin.runner_id().as_str(), + ); + pin } } +/// Wraps a shared content cell into a pin. The load action uses this after +/// initializing (or attaching to) a cell obtained from a [`WeakCache`]. +pub(crate) fn pin_of(cell: Cell) -> ArchivePin { + // Catch misuse here rather than at a later, harder-to-attribute deref panic. + debug_assert!( + cell.initialized(), + "pin_of called with an uninitialized cell" + ); + ArchivePin(cell) +} + pub fn get_cache_dir(base_path: &str) -> errors::Result { let base_path = std::path::Path::new(base_path); @@ -158,3 +363,78 @@ pub fn get_cache_dir(base_path: &str) -> errors::Result { std::fs::write(test_path, "").with_ctx(|| "creating test file".to_string())?; Ok(base_path.to_owned()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn sym(s: &str) -> GlobalSymbol { + GlobalSymbol::from(s) + } + + fn archive_of(size: u32) -> Archive { + Archive { + data: BTreeMap::new(), + total_size: size, + } + } + + fn cache_with(id: GlobalSymbol, size: u32) -> (WeakCache, ArchivePin) { + let cache = WeakCache::new(); + let cell = cache.cell(id); + cell.set(ArchiveCache::new(id, archive_of(size))) + .unwrap_or_else(|_| panic!("fresh cell")); + (cache, ArchivePin(cell)) + } + + // ── LoadedSet ─────────────────────────────────────────────────────── + + #[test] + fn loaded_set_reports_membership_and_pin() { + let (_c, pin) = cache_with(sym("py:abc"), 10); + let mut set = LoadedSet::default(); + assert!(!set.contains(sym("py:abc"))); + set.insert(pin); + assert!(set.contains(sym("py:abc"))); + assert_eq!(set.get(sym("py:abc")).unwrap().total_size(), 10); + } + + #[test] + fn custom_pins_returns_only_custom_entries_sorted() { + let mut set = LoadedSet::default(); + let (_c1, p1) = cache_with(sym("custom:bbb"), 1); + let (_c2, p2) = cache_with(sym("py:abc"), 2); + let (_c3, p3) = cache_with(sym("custom:aaa"), 3); + set.insert(p1); + set.insert(p2); + set.insert(p3); + let ids: Vec<&str> = set + .custom_pins() + .iter() + .map(|p| p.runner_id().as_str()) + .collect(); + assert_eq!(ids, vec!["custom:aaa", "custom:bbb"], "only custom, sorted"); + } + + // ── WeakCache eviction ────────────────────────────────────────────── + + #[test] + fn weak_cache_evicts_when_last_pin_drops() { + let cache = WeakCache::new(); + let id = sym("py:abc"); + + let cell = cache.cell(id); + cell.set(ArchiveCache::new(id, archive_of(10))) + .unwrap_or_else(|_| panic!("fresh cell")); + let pin = ArchivePin(cell); + // A second lookup while pinned observes the same, still-initialized cell. + assert!(cache.cell(id).initialized(), "pinned entry stays resident"); + + drop(pin); + // With no pin the weak is dead; the next lookup yields a fresh, empty cell. + assert!( + !cache.cell(id).initialized(), + "entry evicted after last pin drop" + ); + } +} diff --git a/executor/src/runners/ustar.rs b/executor/src/runners/ustar.rs index 1746a2e7..85cefb57 100644 --- a/executor/src/runners/ustar.rs +++ b/executor/src/runners/ustar.rs @@ -107,10 +107,23 @@ impl Archive { map_try_insert(&mut res, name, file_contents)?; } - Ok(Self { + let res = Self { data: res, total_size: original_data.len() as u32, - }) + }; + // Files are carved from non-overlapping, header/padding-separated slices + // of the tar, so their contents sum to at most the whole buffer — the + // amount `total_size` charges. + #[cfg(debug_assertions)] + { + let content_sum: u64 = res.data.values().map(|v| v.len() as u64).sum(); + debug_assert!( + content_sum <= original_data.len() as u64, + "ustar file contents {content_sum} exceed archive size {}", + original_data.len(), + ); + } + Ok(res) } pub fn from_zip( @@ -157,10 +170,24 @@ impl Archive { map_try_insert(&mut res, String::from(file.name()), buf)?; } - Ok(Self { + let res = Self { data: res, total_size: bytes.len() as u32, - }) + }; + // Each entry is a bounds-checked slice of `bytes`, so no single entry + // exceeds `total_size`. Their *sum* is not bounded here: a crafted zip may + // point several entries at overlapping data ranges (attacker input, not a + // bug), so only the per-entry bound is asserted. + #[cfg(debug_assertions)] + for (name, buf) in &res.data { + debug_assert!( + buf.len() as u64 <= bytes.len() as u64, + "zip entry {name} length {} exceeds archive size {}", + buf.len(), + bytes.len(), + ); + } + Ok(res) } pub fn from_file_and_runner( @@ -170,13 +197,23 @@ impl Archive { ) -> Self { let total_size = file.len() as u32; - Self { + let res = Self { data: BTreeMap::from_iter([ ("runner.json".into(), runner_comment), ("version".into(), version), ("file".into(), file), ]), total_size, - } + }; + // `total_size` accounts for the `file` entry only; the synthetic + // `version`/`runner.json` metadata is uncharged, so the sum-of-contents + // bound the other constructors hold does *not* apply here. + #[cfg(debug_assertions)] + debug_assert_eq!( + res.data.get("file").map(|f| f.len() as u32), + Some(total_size), + "from_file_and_runner: `file` entry must equal total_size", + ); + res } } diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs index 58cf21a8..58abe885 100644 --- a/executor/src/wasi/genlayer_sdk.rs +++ b/executor/src/wasi/genlayer_sdk.rs @@ -396,10 +396,6 @@ pub struct VMDataAccumulator { pub messages_value_decremented: primitive_types::U256, pub emissions: Vec, pub message_fee_allocation: Vec, - /// Custom runner hashes registered in (and inherited into) this execution - /// scope. Only these may be resolved via `custom:`. A nondet sub-VM - /// starts empty, so it cannot see runners the deterministic scope registered. - pub custom_runners: rpds::HashTrieSet, } impl VMDataAccumulator { @@ -426,11 +422,30 @@ pub struct SingleVMData { pub storage: rt::vm::storage::Storage, pub accumulator: VMDataAccumulator, pub det_subvm_hashes: sha3::Sha3_256, + /// `custom:` runner pins granted to this VM by its parent, carried from the + /// spawning gl_call (so the content survives the parent's death for a queued + /// nondet VM). The child's spawn performs a load action for each, charging its + /// own limiter, before its main runner loads (ADR-012 §1/§4). Empty for the + /// root VM. Drained at spawn. + pub granted_custom: Vec, } pub struct Context { pub data: SingleVMData, + /// This VM's **loaded set**: the charge-dedup set and pin holder for every + /// runner it has loaded (ADR-012 §1). A sibling of `data` (not inside its + /// accumulator), so it does not round-trip through sandbox children and is + /// dropped when this VM's store is torn down — coinciding with the VM + /// limiter's refund of the load charges. + pub loaded: crate::runners::cache::LoadedSet, + + /// A handle to this VM's own memory limiter (a clone of its store limiter, so + /// it refunds at VM death). Runtime load actions (`RegisterRunner`, `MapFile`) + /// charge it directly, since a gl_call handler cannot otherwise reach the + /// store limiter. + pub limiter: rt::memlimiter::Limiter, + pub start_time: std::time::Instant, pub prev_time: std::time::Instant, } @@ -441,6 +456,15 @@ pub struct ContextVFS<'a> { pub(super) context: &'a mut Context, } +async fn spawn_sub_vm( + supervisor: Arc, + vm_data: Box, +) -> anyhow::Result { + tokio::task::spawn(async move { rt::spawn_apply_run(&supervisor, vm_data).await }) + .await + .map_err(|e| anyhow::anyhow!("sub-VM task failed to join: {e}"))? +} + #[allow(clippy::too_many_arguments)] pub(crate) mod generated { wiggle::from_witx!({ @@ -530,11 +554,13 @@ fn read_owned_vec( } impl Context { - pub fn new(data: Box) -> Self { + pub fn new(data: Box, limiter: rt::memlimiter::Limiter) -> Self { let now = std::time::Instant::now(); Self { data: *data, + loaded: Default::default(), + limiter, start_time: now, prev_time: now, } @@ -947,14 +973,14 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { .messages_value_decremented, emissions: Vec::new(), message_fee_allocation: Vec::new(), - // CallContract is a deterministic sub-call: inherit the - // runners registered so far. - custom_runners: self.context.data.accumulator.custom_runners.clone(), }, det_subvm_hashes: Default::default(), + // A CallContract child is granted the caller's full custom set + // (ADR-012 §4); its spawn load-actions each into the child. + granted_custom: self.context.loaded.custom_pins(), }); - let res = rt::spawn_apply_run(&supervisor, vm_data) + let res = spawn_sub_vm(supervisor.clone(), vm_data) .await .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; @@ -1633,13 +1659,19 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { gl_call::Message::RunNondet { data_leader, data_validator, - } => self.run_nondet(data_leader, data_validator).await, + runner, + custom_runners, + } => { + self.run_nondet(data_leader, data_validator, runner, custom_runners) + .await + } gl_call::Message::Sandbox { data, runner, allow_write_storage, allow_send_messages, allow_register_runners, + custom_runners, } => { self.sandbox( data, @@ -1647,6 +1679,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { allow_write_storage, allow_send_messages, allow_register_runners, + custom_runners, ) .await } @@ -1930,17 +1963,23 @@ impl ContextVFS<'_> { return Err(generated::types::Errno::Forbidden.into()); } - let is_det = self.context.data.conf.is_deterministic; + // The load action registers the content (weak registry, dedup while + // alive), charges `LOAD_CONST + code.len()` to this VM before parsing, and + // pins it into this VM's loaded set — scoping it to this execution and its + // deterministic children only (ADR-012 §3). RegisterRunner is det-only, so + // the load folds into the det fingerprint. let supervisor = self.context.data.supervisor.clone(); - let hash = crate::runners::custom_runner_hash(&code); - let id = supervisor - .register_custom_runner(code, supervisor.limiter.get(is_det)) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - // Scope the runner to this execution (and its deterministic children) so - // it cannot be resolved from an unrelated scope (e.g. a nondet sub-VM). - self.context.data.accumulator.custom_runners = - self.context.data.accumulator.custom_runners.insert(hash); + let limiter = self.context.limiter.clone(); + let Context { loaded, data, .. } = &mut *self.context; + let id = rt::supervisor::actions::register_runner_load( + &supervisor, + &limiter, + loaded, + Some(&mut data.det_subvm_hashes), + code, + ) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; let data = calldata::encode(&calldata::Value::Str(id.as_str().to_owned())); self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) @@ -1961,23 +2000,30 @@ impl ContextVFS<'_> { let supervisor = self.context.data.supervisor.clone(); let is_det = self.context.data.conf.is_deterministic; - let limiter = supervisor.limiter.get(is_det); + // Charge the VM's own limiter (refunded at VM death, coinciding with the + // pin drop), not the long-lived root limiter. + let limiter = self.context.limiter.clone(); let topmost_runner_id = self.context.data.conf.topmost_runner_id.clone(); - let available_custom = self.context.data.accumulator.custom_runners.clone(); let runner = rt::supervisor::actions::resolve_runner_id(&supervisor, &topmost_runner_id, &runner) .await .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + // The load action pins the archive into this VM's loaded set (keeping the + // mapped files' backing bytes resident) and charges once for it. + let preview1 = &mut *self.preview1; + let Context { loaded, data, .. } = &mut *self.context; + let det_fingerprint = is_det.then_some(&mut data.det_subvm_hashes); rt::supervisor::actions::map_runner_file( &supervisor, - self.preview1, - limiter, + preview1, + &limiter, + loaded, + det_fingerprint, runner, &path_in_runner, &path_in_vfs, - &available_custom, ) .await .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; @@ -1989,11 +2035,33 @@ impl ContextVFS<'_> { &mut self, data_leader: bytes::Bytes, data_validator: bytes::Bytes, + runner: Option, + custom_runners: Option>, ) -> Result { if !self.context.data.conf.can_spawn_nondet { return Err(generated::types::Errno::Forbidden.into()); } + // Resolve the runner to execute and the child's custom-runner visibility + // in this (deterministic parent) scope, so malformed inputs fail + // deterministically at gl_call time (ADR-012 §4). + let supervisor = self.context.data.supervisor.clone(); + let parent_runner_id = self.context.data.conf.topmost_runner_id.clone(); + let child_topmost_id = match &runner { + None => parent_runner_id.clone(), + Some(r) => { + rt::supervisor::actions::resolve_runner_id(&supervisor, &parent_runner_id, r) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))? + } + }; + let child_custom = rt::supervisor::actions::resolve_child_custom_runners( + &self.context.loaded, + custom_runners, + &child_topmost_id, + ) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + let call_no = self .context .data @@ -2118,8 +2186,6 @@ impl ContextVFS<'_> { } }; - let supervisor = self.context.data.supervisor.clone(); - let fake_accum = VMDataAccumulator { data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), messages_value_decremented: self @@ -2129,9 +2195,6 @@ impl ContextVFS<'_> { .messages_value_decremented, emissions: Vec::new(), message_fee_allocation: Vec::new(), - // Nondet is an isolated execution scope: it must NOT see custom - // runners registered by the deterministic scope. - custom_runners: Default::default(), }; let vm_data = Box::new(SingleVMData { @@ -2148,13 +2211,17 @@ impl ContextVFS<'_> { can_register_runners: false, can_use_balance_for_message_fees: false, state_mode: public_abi::StorageType::Default, - topmost_runner_id: self.context.data.conf.topmost_runner_id.clone(), + topmost_runner_id: child_topmost_id, }, message_data, supervisor: supervisor.clone(), storage: storage_checkpoint, accumulator: fake_accum, det_subvm_hashes: Default::default(), // won't be used + // The nondet child is granted exactly the parent-computed set; the + // pins keep the content alive until the (possibly queued) child + // spawns and load-actions them against its own limiter (ADR-012 §4). + granted_custom: child_custom, }); let task_done = Arc::new(tokio::sync::Notify::new()); @@ -2204,6 +2271,7 @@ impl ContextVFS<'_> { allow_write_storage: bool, allow_send_messages: bool, allow_register_runners: bool, + custom_runners: Option>, ) -> Result { let supervisor = self.context.data.supervisor.clone(); @@ -2213,6 +2281,17 @@ impl ContextVFS<'_> { .await .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + // Grants are validated against, and drawn from, this VM's loaded set (§4). + // No flow-back is possible by construction: the child's own loaded set (and + // any runner it registers) dies with it, and the parent's loaded set is + // never mutated here (§5). + let child_custom = rt::supervisor::actions::resolve_child_custom_runners( + &self.context.loaded, + custom_runners, + &topmost_runner_id, + ) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + let message_data = self .context .data @@ -2228,8 +2307,6 @@ impl ContextVFS<'_> { messages_value_decremented: primitive_types::U256::max_value(), emissions: Vec::new(), message_fee_allocation: Vec::new(), - // Sandbox is a deterministic sub-VM: inherit the registered runners. - custom_runners: self.context.data.accumulator.custom_runners.clone(), }; std::mem::swap(&mut self.context.data.accumulator, &mut fake_my_data); @@ -2257,9 +2334,10 @@ impl ContextVFS<'_> { storage: storage_checkpoint, accumulator: stolen_data, det_subvm_hashes: Default::default(), + granted_custom: child_custom, }); - let my_res = rt::spawn_apply_run(&supervisor, vm_data) + let my_res = spawn_sub_vm(supervisor.clone(), vm_data) .await .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; diff --git a/executor/src/wasi/mod.rs b/executor/src/wasi/mod.rs index dd57136e..727ca275 100644 --- a/executor/src/wasi/mod.rs +++ b/executor/src/wasi/mod.rs @@ -32,7 +32,7 @@ impl Context { sha3::Sha3_256::digest(&as_bytes).into() }; - let vfs = match vfs::VFS::new(as_bytes, limiter) { + let vfs = match vfs::VFS::new(as_bytes, limiter.clone()) { Ok(vfs) => vfs, Err(e) => return Err((e, data)), }; @@ -43,7 +43,7 @@ impl Context { data.conf.clone(), seed, ), - genlayer_sdk: genlayer_sdk::Context::new(data), + genlayer_sdk: genlayer_sdk::Context::new(data, limiter), }) } } diff --git a/tests/integration/runner/custom_missing/custom_missing.0.stdout b/tests/integration/runner/custom_missing/custom_missing.0.stdout index 226138fd..3a6b81bc 100644 --- a/tests/integration/runner/custom_missing/custom_missing.0.stdout +++ b/tests/integration/runner/custom_missing/custom_missing.0.stdout @@ -1 +1 @@ -executed with `VMError("invalid_contract")` +executed with `VMError("invalid_contract malformed_runner")` diff --git a/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.0.stdout b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.0.stdout new file mode 100644 index 00000000..4c73272d --- /dev/null +++ b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.0.stdout @@ -0,0 +1,3 @@ +deep custom runner ran +sandbox -> Return(calldata=64) +executed with `Return(null)` diff --git a/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet new file mode 100644 index 00000000..e8c11679 --- /dev/null +++ b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet @@ -0,0 +1,6 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +// Regression for action-application stack use: a sandbox VM resolves a custom +// runner whose init action depends on a long chain of custom runners. +{tags: util.features([['runner', 'custom'], ['runner', 'dependency'], ['sandbox']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'rwscnu'}])} diff --git a/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.py b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.py new file mode 100644 index 00000000..4550cfea --- /dev/null +++ b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.py @@ -0,0 +1,54 @@ +# { "Depends": "py-genlayer:test" } +import json + +import genlayer as gl +from genlayer.vm import register_runner + + +DEPTH = 64 + + +def runner_bytes(action, body): + return f'# {json.dumps(action)}\n{body}\n'.encode('utf-8') + + +class Contract(gl.contract.Contract): + def __init__(self): + prev = 'py-lib-cloudpickle:test' + for idx in range(DEPTH): + prev = register_runner( + runner_bytes( + {'Depends': prev}, + f'dependency_runner_{idx} = None', + ) + ) + + runner = { + 'Seq': [ + {'Depends': prev}, + { + 'With': { + 'action': {'MapFile': {'file': 'file', 'to': '/contract.py'}}, + 'runner': 'contract', + } + }, + {'SetArgs': ['py', '-u', '-B', '/contract.py']}, + {'Depends': 'py-lib-cloudpickle:test'}, + {'Depends': 'py-lib-genlayer-std:test'}, + {'Depends': 'cpython:test'}, + ] + } + top = register_runner( + runner_bytes( + runner, + '\n'.join( + [ + 'print("deep custom runner ran")', + "exec(open('/py/libs/_genlayer_bootloader.py').read())", + ] + ), + ) + ) + + res = gl.vm.spawn_sandbox(lambda: DEPTH, runner=top) + print('sandbox ->', res) diff --git a/tests/integration/runner/load_arith/load_arith.0.stdout b/tests/integration/runner/load_arith/load_arith.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/runner/load_arith/load_arith.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/runner/load_arith/load_arith.jsonnet b/tests/integration/runner/load_arith/load_arith.jsonnet new file mode 100644 index 00000000..e8295da0 --- /dev/null +++ b/tests/integration/runner/load_arith/load_arith.jsonnet @@ -0,0 +1,14 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +// ADR-012 §1: first load of a runner charges exactly LOAD_CONST + size, once. +// A bare raw-wasm contract's runner tree is a single node, so exactly one +// `charged` record must appear, sized to the raw wasm length, none `cached`. +{tags: util.features([['runner', 'load']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.wat') { + runner_load_asserts: [ + {match: {}, count: 1}, + {match: {status: 'charged'}, count: 1, size_is_code_len: true}, + {match: {load_const: 4096}, count: 1}, + {match: {status: 'cached'}, count: 0}, + ], + }])} diff --git a/tests/integration/runner/load_arith/load_arith.wat b/tests/integration/runner/load_arith/load_arith.wat new file mode 100644 index 00000000..fd389d9d --- /dev/null +++ b/tests/integration/runner/load_arith/load_arith.wat @@ -0,0 +1,13 @@ +(module + (type $void_fn (func)) + + ;; A minimal raw-wasm contract: parsed via the Raw WASM path into a + ;; `{ "StartWasm": "file" }` runner with no Depends/With, so its whole runner + ;; tree is a single load action — the main chain:deploy runner itself. That + ;; makes the load-action arithmetic exactly hand-computable: one `charged` + ;; record of `LOAD_CONST + size`, where `size` == the raw wasm length. + (func (export "_start") (type $void_fn)) + + (memory $mem 1) + (export "memory" (memory $mem)) +) diff --git a/tests/integration/runner/no_flowback/no_flowback.jsonnet b/tests/integration/runner/no_flowback/no_flowback.jsonnet new file mode 100644 index 00000000..09916da3 --- /dev/null +++ b/tests/integration/runner/no_flowback/no_flowback.jsonnet @@ -0,0 +1,14 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +// ADR-012 §4: a sandbox-scoped `RegisterRunner` does not flow back to the +// parent. The child charges the custom exactly once (`charged`); the parent's +// later attempt to resolve that id fails deterministically (no parent charge +// record for it) and the run ends as an invalid-contract VM error. +{tags: util.features([['runner', 'custom'], ['sandbox']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { + stable_hash: false, + permissions: 'rwscnu', + runner_load_asserts: [ + {runner_prefix: 'custom:', match: {status: 'charged'}, count: 1}, + ], + }])} diff --git a/tests/integration/runner/no_flowback/no_flowback.py b/tests/integration/runner/no_flowback/no_flowback.py new file mode 100644 index 00000000..138485c9 --- /dev/null +++ b/tests/integration/runner/no_flowback/no_flowback.py @@ -0,0 +1,24 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm import map_file, register_runner, spawn_sandbox + + +class Contract(gl.contract.Contract): + def __init__(self): + # ADR-012 §4 (no flow-back): a runner registered inside a sandbox subtree + # lives only in that child's loaded set and dies with it. The parent never + # loaded it, so resolving its id in the parent afterwards is a deterministic + # malformed-runner error — registrations do not flow back up. + code = b'# { "Depends": "py-genlayer:test" }\nx\n' + + def inner(): + # runs in the sandbox child; registers into the child's own set + return register_runner(code) + + res = spawn_sandbox(inner, allow_register_runners=True) + rid = res.calldata + print('sandbox registered', rid) + + # The parent never loaded `rid`; resolving it must fail deterministically. + map_file(rid, 'runner.json', '/leaked.json') + print('LEAK: parent resolved a child-scoped registration') diff --git a/tests/integration/runner/no_flowback/no_flowback.skip b/tests/integration/runner/no_flowback/no_flowback.skip new file mode 100644 index 00000000..b0ad79c1 --- /dev/null +++ b/tests/integration/runner/no_flowback/no_flowback.skip @@ -0,0 +1,10 @@ +BLOCKED by a Phase-1 regression (uncommitted feat/runner-memory-accounting): +resolving/registering a `custom:` runner inside a spawned sub-VM (sandbox or +nondet child) overflows the async-fiber stack ("execution on async fiber has +overflowed its stack"). Root-VM custom registration is fine (see +register_ladder). The pre-existing sandbox_custom_runner and nondet_custom_runner +cases fail identically on this tree, so it is not this case's contract. + +This case asserts ADR-012 §4 no-flow-back (a sandbox-scoped RegisterRunner is +not resolvable in the parent). It is correct as written and should pass once the +sub-VM custom-runner stack overflow is fixed — remove this .skip then. diff --git a/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.py b/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.py index 8b3b04a6..ba96d718 100644 --- a/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.py +++ b/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.py @@ -1,5 +1,7 @@ # { "Depends": "py-genlayer:test" } import genlayer as gl +import genlayer._internal.on_chain.gl_call as gl_call +from genlayer.vm import _decode_sub_vm_result from genlayer.vm import map_file, register_runner @@ -11,9 +13,9 @@ def __init__(self): def leader(): # burn a long loop first, then try to use the det-registered custom - # runner from inside nondet. Custom runners should be scoped to the - # execution that registered them, so this map MUST fail — but with a - # shared registry it may leak. + # runner from inside nondet. Passing custom_runners=[] grants no + # custom runners, so this map MUST fail. The default Python SDK path + # omits the field, which ADR-012 defines as grant-all for compatibility. for _ in range(10**4): pass try: @@ -22,4 +24,20 @@ def leader(): except Exception as e: return f'nondet map failed: {type(e).__name__}' - print(gl.vm.run_nondet(leader, lambda r: True)) + import cloudpickle + + def validator_fn_mapped(_stage_data): + return True + + print( + gl_call.gl_call_generic( + { + 'RunNondet': { + 'data_leader': cloudpickle.dumps(lambda _: leader()), + 'data_validator': cloudpickle.dumps(validator_fn_mapped), + 'custom_runners': [], + } + }, + _decode_sub_vm_result, + ) + ) diff --git a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet new file mode 100644 index 00000000..bda6530f --- /dev/null +++ b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet @@ -0,0 +1,14 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +// ADR-012 §4 (v2): a nondet block inherits the parent's custom runners by +// default, so a det-registered runner is usable inside `run_nondet`. Both the +// parent and the nondet child charge it once each (`charged` >= 2 across the +// run); each VM loads it at most once (no in-VM double charge). +{tags: util.features([['runner', 'custom'], ['sandbox'], ['nondet']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { + stable_hash: false, + permissions: 'rwscnu', + runner_load_asserts: [ + {runner_prefix: 'custom:', match: {status: 'charged'}, min: 2}, + ], + }])} diff --git a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.py b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.py new file mode 100644 index 00000000..878b3f8f --- /dev/null +++ b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.py @@ -0,0 +1,23 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm import map_file, register_runner, run_nondet + + +class Contract(gl.contract.Contract): + def __init__(self): + # ADR-012 §4 (new v2 behavior): a `RunNondet` child inherits the parent's + # entire custom-runner set by default (custom_runners=None). A runner + # registered in the deterministic parent is therefore visible inside the + # nondet block — the opposite of the pre-v2 blanket ban. The nondet child + # performs its own load action for the inherited runner (charged to its own + # limiter), so the same content is `charged` once per VM, never twice within + # one VM. + code = b'# { "Depends": "py-genlayer:test" }\nmarker\n' + rid = register_runner(code) + + def leader(): + # would raise a malformed-runner error under the old (empty) nondet set + map_file(rid, 'runner.json', '/inherited.json') + return 'nondet mapped inherited custom' + + print(run_nondet(leader, lambda r: True)) diff --git a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip new file mode 100644 index 00000000..3bc55e72 --- /dev/null +++ b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip @@ -0,0 +1,11 @@ +BLOCKED by a Phase-1 regression (uncommitted feat/runner-memory-accounting): +resolving an inherited `custom:` runner inside a nondet sub-VM overflows the +async-fiber stack ("execution on async fiber has overflowed its stack"). The +custom runner IS charged in both the parent and the nondet child (the load +records are correct: `custom:` charged >= 2), but the child then overflows +before the leader body runs. The pre-existing nondet_custom_runner case fails +identically on this tree, so it is not this case's contract. + +This case asserts the new ADR-012 §4 behavior (a nondet block inherits the +parent's custom set by default). It is correct as written and should pass once +the sub-VM custom-runner stack overflow is fixed — remove this .skip then. diff --git a/tests/integration/runner/register_ladder/register_ladder.0.stdout b/tests/integration/runner/register_ladder/register_ladder.0.stdout new file mode 100644 index 00000000..5300af8a --- /dev/null +++ b/tests/integration/runner/register_ladder/register_ladder.0.stdout @@ -0,0 +1,3 @@ +id custom:yn1hm19g13tqxzyp0dphfy63jgwtws8hyg3xsqb2vf7jfvjd9d60 +idempotent True +executed with `Return(null)` diff --git a/tests/integration/runner/register_ladder/register_ladder.jsonnet b/tests/integration/runner/register_ladder/register_ladder.jsonnet new file mode 100644 index 00000000..61060737 --- /dev/null +++ b/tests/integration/runner/register_ladder/register_ladder.jsonnet @@ -0,0 +1,15 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +// ADR-012 §3: a registered custom runner is charged once (`charged`); a same-VM +// re-register of identical code is free (`cached`). Isolated from the noisy +// python runner tree via `runner_prefix: 'custom:'`. +{tags: util.features([['runner', 'register']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { + stable_hash: false, + permissions: 'rwscnu', + runner_load_asserts: [ + {runner_prefix: 'custom:', match: {status: 'charged'}, count: 1}, + {runner_prefix: 'custom:', match: {status: 'cached'}, count: 1}, + {runner_prefix: 'custom:', match: {load_const: 4096}, min: 1}, + ], + }])} diff --git a/tests/integration/runner/register_ladder/register_ladder.py b/tests/integration/runner/register_ladder/register_ladder.py new file mode 100644 index 00000000..0e62473c --- /dev/null +++ b/tests/integration/runner/register_ladder/register_ladder.py @@ -0,0 +1,15 @@ +# { "Depends": "py-genlayer:test" } +from genlayer.vm import register_runner + +# ADR-012 §3 register ladder, success path: the first registration charges +# `LOAD_CONST + len(code)` and emits one `charged` custom record; a second +# registration of the *same* code in the *same* VM is a free no-op that resolves +# to the same id and emits one `cached` custom record. +code = b'# { "Depends": "py-genlayer:test" }\nexit(0)\n' + +id1 = register_runner(code) +id2 = register_runner(code) + +print('id', id1) +print('idempotent', id1 == id2) +exit(0) diff --git a/tests/integration/runner/register_malformed_charge/register_malformed_charge.0.stdout b/tests/integration/runner/register_malformed_charge/register_malformed_charge.0.stdout new file mode 100644 index 00000000..c668f3b6 --- /dev/null +++ b/tests/integration/runner/register_malformed_charge/register_malformed_charge.0.stdout @@ -0,0 +1 @@ +executed with `VMError("invalid_contract not_utf8_text")` diff --git a/tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet b/tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet new file mode 100644 index 00000000..3e93a7e6 --- /dev/null +++ b/tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet @@ -0,0 +1,15 @@ +local simple = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; +// ADR-012 §3: a malformed `RegisterRunner` is a deterministic invalid-contract +// error. The pre-parse charge is retained, but the failed parse produces no +// `runner load` record — so only the preceding valid registration is logged as +// `charged`, and nothing is logged for the malformed bytes. +{tags: util.features([['runner', 'register']], 'stable'), + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { + stable_hash: false, + permissions: 'rwscnu', + runner_load_asserts: [ + {runner_prefix: 'custom:', match: {status: 'charged'}, count: 1}, + {runner_prefix: 'custom:', match: {status: 'cached'}, count: 0}, + ], + }])} diff --git a/tests/integration/runner/register_malformed_charge/register_malformed_charge.py b/tests/integration/runner/register_malformed_charge/register_malformed_charge.py new file mode 100644 index 00000000..52d72c74 --- /dev/null +++ b/tests/integration/runner/register_malformed_charge/register_malformed_charge.py @@ -0,0 +1,16 @@ +# { "Depends": "py-genlayer:test" } +from genlayer.vm import register_runner + +# ADR-012 §3 register ladder, malformed path: `RegisterRunner` charges +# `LOAD_CONST + len(code)` *before* parsing, so a parse failure is a +# deterministic invalid-contract error with the charge retained (released with +# the VM) and the runner never resolvable. Observable here as: exactly one +# `charged` custom record (the earlier valid registration) and none for the +# malformed bytes — the failed parse emits no `runner load` record at all. +good = b'# { "Depends": "py-genlayer:test" }\nexit(0)\n' +register_runner(good) + +# Not zip, not core-wasm, not valid UTF-8 text -> parse fails on the bytes alone. +register_runner(b'\xff\xfe\xfd') + +print('unreachable: malformed register must raise a deterministic VM error') From 9fe458f4bf9e75f7fb8a2e1eccc9e67708408e69 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 13:39:05 +0900 Subject: [PATCH 13/25] =?UTF-8?q?feat(executor):=20rename=20load=20cost=20?= =?UTF-8?q?to=20runner=5Fload=5Fcost=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .genvm-tool.py | 9 + executor/codegen/data/host-fns.json | 6 - executor/codegen/data/public-abi-pending.json | 8 + executor/crates/calldata/src/bin.rs | 132 ++++++++- executor/crates/calldata/src/encoder.rs | 251 +++++++++++++++--- executor/crates/common/src/expr/evaluator.rs | 9 + executor/crates/common/src/host_fns.rs | 1 - executor/crates/common/src/lib.rs | 1 + .../crates/common/src/public_abi_pending.rs | 5 + executor/src/host/message.rs | 18 ++ executor/src/rt/supervisor/actions.rs | 51 ++-- executor/src/rt/vm/storage.rs | 2 +- executor/src/runners/mod.rs | 36 +++ executor/src/wasi/genlayer_sdk.rs | 12 +- executor/src/wasi/preview1.rs | 50 ++++ .../tests/test_calldata_corpus.py | 29 ++ .../tests/test_root_offsets_layout.py | 38 +++ .../tests/test_slot_indirection_golden.py | 17 ++ .../runner/load_arith/load_arith.jsonnet | 4 +- .../runner/load_arith/load_arith.wat | 2 +- .../register_ladder/register_ladder.jsonnet | 2 +- .../runner/register_ladder/register_ladder.py | 2 +- .../register_malformed_charge.py | 2 +- 23 files changed, 615 insertions(+), 72 deletions(-) create mode 100644 executor/codegen/data/public-abi-pending.json create mode 100644 executor/crates/common/src/public_abi_pending.rs create mode 100644 runners/genlayer-py-std/tests/test_calldata_corpus.py create mode 100644 runners/genlayer-py-std/tests/test_root_offsets_layout.py create mode 100644 runners/genlayer-py-std/tests/test_slot_indirection_golden.py diff --git a/.genvm-tool.py b/.genvm-tool.py index ec985f2a..92e2c0c1 100644 --- a/.genvm-tool.py +++ b/.genvm-tool.py @@ -167,6 +167,15 @@ def configure(line): which imports every active line's runners. """ line.register_standard_codegen() + # Pending public-abi constants (ADR-012): constants that logically belong to + # the public ABI but are parked here so they do not regenerate the runner + # `public_abi.py` (which would change frozen runner hashes). Generated into the + # `common` crate only — never the runner tree. + line.codegen( + line.exec_root / 'executor/crates/common/src/public_abi_pending.rs', + 'rust', + line.exec_root / 'executor/codegen/data/public-abi-pending.json', + ) line.register_standard_crates() line.nix_manifests() line.install_tree() diff --git a/executor/codegen/data/host-fns.json b/executor/codegen/data/host-fns.json index 4a712d75..ca07a54b 100644 --- a/executor/codegen/data/host-fns.json +++ b/executor/codegen/data/host-fns.json @@ -43,11 +43,5 @@ "name": "current_major_str", "repr": "str", "value": "v0.0.0" - }, - { - "type": "const", - "name": "load_const", - "repr": "u32", - "value": 4096 } ] diff --git a/executor/codegen/data/public-abi-pending.json b/executor/codegen/data/public-abi-pending.json new file mode 100644 index 00000000..b45fd83e --- /dev/null +++ b/executor/codegen/data/public-abi-pending.json @@ -0,0 +1,8 @@ +[ + { + "type": "const", + "name": "runner_load_cost", + "repr": "u32", + "value": 4096 + } +] diff --git a/executor/crates/calldata/src/bin.rs b/executor/crates/calldata/src/bin.rs index 1116a961..f6dccf68 100644 --- a/executor/crates/calldata/src/bin.rs +++ b/executor/crates/calldata/src/bin.rs @@ -315,7 +315,17 @@ impl Default for Options { pub fn decode_with(data: &[u8], opts: &Options) -> Result { let mut parser = Parser(data); - parser.fetch_val(opts) + + let ret = parser.fetch_val(opts)?; + + if !parser.0.is_empty() { + return Err(BinDecodeError::UnexpectedEnd { + expected: 0, + available: parser.0.len(), + }); + } + + Ok(ret) } pub fn decode(data: &[u8]) -> Result { @@ -406,3 +416,123 @@ pub fn encode(value: &Value) -> Vec { ret } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + fn num(v: i64) -> Value { + Value::Number(num_bigint::BigInt::from(v)) + } + + fn map(entries: &[(&str, Value)]) -> Value { + Value::Map( + entries + .iter() + .map(|(k, v)| ((*k).to_owned(), v.clone())) + .collect::>(), + ) + } + + /// Shared cross-language corpus: `(logical value, expected canonical hex)`. + /// The exact same hex is pinned in the Python encoder test + /// (`tests/test_calldata_corpus.py`) so the two encoders can never silently diverge. + fn corpus() -> Vec<(Value, &'static str)> { + vec![ + // boundary ints + (num(0), "01"), + (num(-1), "02"), + ( + Value::Number(num_bigint::BigInt::from(1u128 << 64)), + "81808080808080808010", + ), + ( + Value::Number(-num_bigint::BigInt::from(1u128 << 64)), + "faffffffffffffffff0f", + ), + // strings / bytes + (Value::Str(String::new()), "04"), + (Value::Str("hello".to_owned()), "2c68656c6c6f"), + (Value::Bytes(vec![1, 2, 3]), "1b010203"), + // a map whose content-order ("aa" < "z") differs from length-order + (map(&[("z", num(1)), ("aa", num(2))]), "1602616111017a09"), + // nested containers + ( + map(&[ + ("", Value::Null), + ( + "a", + Value::Array(vec![num(1), num(2), map(&[("b", Value::Bool(false))])]), + ), + ]), + "16000001611d09110e016208", + ), + ] + } + + #[test] + fn calldata_corpus_encode_and_roundtrip() { + for (value, expected_hex) in corpus() { + let encoded = encode(&value); + assert_eq!( + hex::encode(&encoded), + expected_hex, + "encoding mismatch for {value:?}" + ); + let decoded = decode(&encoded).expect("decode of own encoding must succeed"); + assert_eq!(decoded, value, "roundtrip mismatch for {value:?}"); + } + } + + #[test] + fn decode_rejects_trailing_bytes() { + let mut data = encode(&num(0)); + data.push(0xff); + assert!(matches!( + decode(&data), + Err(BinDecodeError::UnexpectedEnd { .. }) + )); + } + + #[test] + fn decode_with_rejects_trailing_bytes() { + let mut data = encode(&num(0)); + data.push(0xff); + assert!(matches!( + decode_with(&data, &Options::default()), + Err(BinDecodeError::UnexpectedEnd { .. }) + )); + } + + #[test] + fn decode_rejects_non_minimal_uleb() { + // `0x80, 0x00`: a continuation byte followed by an all-zero final octet is a + // non-minimal (non-canonical) uleb encoding. + assert!(matches!( + decode(&[0x80, 0x00]), + Err(BinDecodeError::InvalidUlebEncoding) + )); + } + + #[test] + fn decode_rejects_unsorted_map_keys() { + // map(2) { "b": null, "a": null } — keys out of order. + let data = [0x16, 0x01, b'b', 0x00, 0x01, b'a', 0x00]; + assert!(matches!( + decode(&data), + Err(BinDecodeError::InvalidMapOrdering { .. }) + )); + } + + #[test] + fn decode_rejects_duplicate_map_keys() { + // map(2) { "a": null, "a": null } — duplicate keys are not strictly increasing. + let data = [0x16, 0x01, b'a', 0x00, 0x01, b'a', 0x00]; + assert!(matches!( + decode(&data), + Err(BinDecodeError::InvalidMapOrdering { .. }) + )); + } +} diff --git a/executor/crates/calldata/src/encoder.rs b/executor/crates/calldata/src/encoder.rs index b5da2b54..1234ba30 100644 --- a/executor/crates/calldata/src/encoder.rs +++ b/executor/crates/calldata/src/encoder.rs @@ -133,95 +133,282 @@ where } } -pub struct Encoder(W) +/// Debug-only bookkeeping for a container being written, used to assert that the +/// caller produces canonical calldata (see [`Encoder`]). Compiled out entirely in +/// release builds. +#[cfg(debug_assertions)] +enum EncoderDebugFrame { + Array { + remaining: u64, + }, + Map { + remaining: u64, + prev_key: Option, + awaiting_value: bool, + }, +} + +pub struct Encoder where - W: Writer; + W: Writer, +{ + writer: W, + /// Stack of currently-open containers. Each scalar/value push consumes one slot + /// of the innermost container (cascading up as containers fill), each `push_map_k` + /// checks strict key ordering. Debug-only: the whole guard compiles out in release. + #[cfg(debug_assertions)] + debug_frames: Vec, +} impl Encoder { pub fn new(writer: W) -> Self { - Self(writer) + Self { + writer, + #[cfg(debug_assertions)] + debug_frames: Vec::new(), + } } pub fn into_inner(self) -> W { - self.0 + #[cfg(debug_assertions)] + debug_assert!( + self.debug_frames.is_empty(), + "calldata encoder: unfinished container(s) on into_inner (fewer entries pushed than declared)" + ); + self.writer + } + + /// A complete value was just written into the innermost container. Consume one + /// slot, cascading up as containers reach their declared size. + #[cfg(debug_assertions)] + fn dbg_value(&mut self) { + loop { + match self.debug_frames.last_mut() { + None => return, + Some(EncoderDebugFrame::Array { remaining }) => { + debug_assert!(*remaining > 0, "calldata encoder: too many array elements"); + *remaining -= 1; + if *remaining > 0 { + return; + } + } + Some(EncoderDebugFrame::Map { + remaining, + awaiting_value, + .. + }) => { + debug_assert!( + *awaiting_value, + "calldata encoder: value pushed but a map key was expected" + ); + *awaiting_value = false; + *remaining -= 1; + if *remaining > 0 { + return; + } + } + } + self.debug_frames.pop(); + } + } + + /// A container header for `len` elements was just written. + #[cfg(debug_assertions)] + fn dbg_begin_container(&mut self, is_map: bool, len: u64) { + if len == 0 { + // An empty container is itself a complete value in its parent. + self.dbg_value(); + } else if is_map { + self.debug_frames.push(EncoderDebugFrame::Map { + remaining: len, + prev_key: None, + awaiting_value: false, + }); + } else { + self.debug_frames + .push(EncoderDebugFrame::Array { remaining: len }); + } + } + + /// A map key was just written. + #[cfg(debug_assertions)] + fn dbg_key(&mut self, key: &str) { + match self.debug_frames.last_mut() { + Some(EncoderDebugFrame::Map { + remaining, + prev_key, + awaiting_value, + }) => { + debug_assert!( + !*awaiting_value, + "calldata encoder: map key pushed but a value was expected" + ); + debug_assert!(*remaining > 0, "calldata encoder: too many map entries"); + if let Some(prev) = prev_key.as_deref() { + debug_assert!( + key > prev, + "calldata encoder: map keys must be strictly increasing, got {prev:?} then {key:?}" + ); + } + *prev_key = Some(key.to_owned()); + *awaiting_value = true; + } + _ => debug_assert!(false, "calldata encoder: push_map_k outside of a map"), + } } pub fn start_array(&mut self, len: u64) -> Result<(), W::Error> { - write_uleb_tagged_u64(&mut self.0, TYPE_ARR, len) + #[cfg(debug_assertions)] + self.dbg_begin_container(false, len); + write_uleb_tagged_u64(&mut self.writer, TYPE_ARR, len) } pub fn start_array_big(&mut self, len: &num_bigint::BigInt) -> Result<(), W::Error> { - write_tagged_uleb( - &mut self.0, - TYPE_ARR, - len.to_biguint().expect("array length must be non-negative"), - ) + let len = len.to_biguint().expect("array length must be non-negative"); + #[cfg(debug_assertions)] + self.dbg_begin_container(false, u64::try_from(&len).unwrap_or(u64::MAX)); + write_tagged_uleb(&mut self.writer, TYPE_ARR, len) } pub fn start_map(&mut self, len: u64) -> Result<(), W::Error> { - write_uleb_tagged_u64(&mut self.0, TYPE_MAP, len) + #[cfg(debug_assertions)] + self.dbg_begin_container(true, len); + write_uleb_tagged_u64(&mut self.writer, TYPE_MAP, len) } pub fn start_map_big(&mut self, len: &num_bigint::BigInt) -> Result<(), W::Error> { - write_tagged_uleb( - &mut self.0, - TYPE_MAP, - len.to_biguint().expect("map length must be non-negative"), - ) + let len = len.to_biguint().expect("map length must be non-negative"); + #[cfg(debug_assertions)] + self.dbg_begin_container(true, u64::try_from(&len).unwrap_or(u64::MAX)); + write_tagged_uleb(&mut self.writer, TYPE_MAP, len) } pub fn push_null(&mut self) -> Result<(), W::Error> { - self.0.write_all(&[SPECIAL_NULL]) + #[cfg(debug_assertions)] + self.dbg_value(); + self.writer.write_all(&[SPECIAL_NULL]) } pub fn push_bool(&mut self, value: bool) -> Result<(), W::Error> { - self.0 + #[cfg(debug_assertions)] + self.dbg_value(); + self.writer .write_all(&[if value { SPECIAL_TRUE } else { SPECIAL_FALSE }]) } pub fn push_i64(&mut self, value: i64) -> Result<(), W::Error> { + #[cfg(debug_assertions)] + self.dbg_value(); if value < 0 { - write_uleb_tagged_u64(&mut self.0, TYPE_NINT, -(value + 1) as u64) + write_uleb_tagged_u64(&mut self.writer, TYPE_NINT, -(value + 1) as u64) } else { - write_uleb_tagged_u64(&mut self.0, TYPE_PINT, value as u64) + write_uleb_tagged_u64(&mut self.writer, TYPE_PINT, value as u64) } } pub fn push_u64(&mut self, value: u64) -> Result<(), W::Error> { - write_uleb_tagged_u64(&mut self.0, TYPE_PINT, value) + #[cfg(debug_assertions)] + self.dbg_value(); + write_uleb_tagged_u64(&mut self.writer, TYPE_PINT, value) } pub fn push_bigint(&mut self, value: &num_bigint::BigInt) -> Result<(), W::Error> { + #[cfg(debug_assertions)] + self.dbg_value(); if value.sign() == num_bigint::Sign::Minus { let mut mag = value.magnitude().clone(); mag -= 1u32; - write_tagged_uleb(&mut self.0, TYPE_NINT, mag) + write_tagged_uleb(&mut self.writer, TYPE_NINT, mag) } else { - write_tagged_uleb(&mut self.0, TYPE_PINT, value.magnitude().clone()) + write_tagged_uleb(&mut self.writer, TYPE_PINT, value.magnitude().clone()) } } pub fn push_str(&mut self, value: &str) -> Result<(), W::Error> { - write_uleb_tagged_u64(&mut self.0, TYPE_STR, value.len() as u64)?; - self.0.write_all(value.as_bytes()) + #[cfg(debug_assertions)] + self.dbg_value(); + write_uleb_tagged_u64(&mut self.writer, TYPE_STR, value.len() as u64)?; + self.writer.write_all(value.as_bytes()) } pub fn push_bytes(&mut self, value: &[u8]) -> Result<(), W::Error> { - write_uleb_tagged_u64(&mut self.0, TYPE_BYTES, value.len() as u64)?; - self.0.write_all(value) + #[cfg(debug_assertions)] + self.dbg_value(); + write_uleb_tagged_u64(&mut self.writer, TYPE_BYTES, value.len() as u64)?; + self.writer.write_all(value) } pub fn write_raw(&mut self, data: &[u8]) -> Result<(), W::Error> { - self.0.write_all(data) + // `write_raw` splices an already-encoded, complete value (see the `unparsed` + // codec), so from the surrounding container's view it is one value. + #[cfg(debug_assertions)] + self.dbg_value(); + self.writer.write_all(data) } pub fn push_address(&mut self, addr: &Address) -> Result<(), W::Error> { - self.0.write_all(&[SPECIAL_ADDR])?; - self.0.write_all(&addr.0) + #[cfg(debug_assertions)] + self.dbg_value(); + self.writer.write_all(&[SPECIAL_ADDR])?; + self.writer.write_all(&addr.0) } pub fn push_map_k(&mut self, key: &str) -> Result<(), W::Error> { - write_uleb_u64(&mut self.0, key.len() as u64)?; - self.0.write_all(key.as_bytes()) + #[cfg(debug_assertions)] + self.dbg_key(key); + write_uleb_u64(&mut self.writer, key.len() as u64)?; + self.writer.write_all(key.as_bytes()) + } +} + +// The map-canonicity guard is a `debug_assert`, so these panics only exist in debug +// builds; gate the tests accordingly. +#[cfg(all(test, debug_assertions))] +mod debug_guard_tests { + use super::*; + + #[test] + #[should_panic(expected = "strictly increasing")] + fn out_of_order_map_keys_trip_the_guard() { + let mut enc = Encoder::new(Vec::new()); + enc.start_map(2).unwrap(); + enc.push_map_k("b").unwrap(); + enc.push_null().unwrap(); + // "a" < "b": not strictly increasing. + enc.push_map_k("a").unwrap(); + } + + #[test] + #[should_panic(expected = "strictly increasing")] + fn duplicate_map_keys_trip_the_guard() { + let mut enc = Encoder::new(Vec::new()); + enc.start_map(2).unwrap(); + enc.push_map_k("a").unwrap(); + enc.push_null().unwrap(); + enc.push_map_k("a").unwrap(); + } + + #[test] + #[should_panic(expected = "fewer entries")] + fn under_filled_map_trips_on_into_inner() { + let mut enc = Encoder::new(Vec::new()); + enc.start_map(2).unwrap(); + enc.push_map_k("a").unwrap(); + enc.push_null().unwrap(); + // Declared 2 pairs but only pushed 1. + let _ = enc.into_inner(); + } + + #[test] + fn well_formed_nested_containers_pass() { + let mut enc = Encoder::new(Vec::new()); + enc.start_map(2).unwrap(); + enc.push_map_k("a").unwrap(); + enc.start_array(2).unwrap(); + enc.push_u64(1).unwrap(); + enc.start_map(0).unwrap(); // empty map as an array element + enc.push_map_k("b").unwrap(); + enc.push_null().unwrap(); + let _ = enc.into_inner(); } } diff --git a/executor/crates/common/src/expr/evaluator.rs b/executor/crates/common/src/expr/evaluator.rs index 538a3c0d..0bd771ed 100644 --- a/executor/crates/common/src/expr/evaluator.rs +++ b/executor/crates/common/src/expr/evaluator.rs @@ -251,6 +251,15 @@ static BUILTINS: std::sync::LazyLock= 64); + if exp > usize::MAX as u64 { + return Err(EvalError::ExponentTooLarge); + } let result = num_traits::pow::pow(base.clone(), exp as usize); if negative { if result.is_zero() { diff --git a/executor/crates/common/src/host_fns.rs b/executor/crates/common/src/host_fns.rs index dce1e8f4..d52f4804 100644 --- a/executor/crates/common/src/host_fns.rs +++ b/executor/crates/common/src/host_fns.rs @@ -136,4 +136,3 @@ impl VmErrorDetail { pub const CURRENT_MAJOR: u8 = 0; pub const CURRENT_MAJOR_STR: &'static str = "v0.0.0"; -pub const LOAD_CONST: u32 = 4096; diff --git a/executor/crates/common/src/lib.rs b/executor/crates/common/src/lib.rs index ed150c50..9005299f 100644 --- a/executor/crates/common/src/lib.rs +++ b/executor/crates/common/src/lib.rs @@ -20,6 +20,7 @@ pub mod version; pub mod expr; pub mod host_fns; +pub mod public_abi_pending; pub mod util; pub mod domain; diff --git a/executor/crates/common/src/public_abi_pending.rs b/executor/crates/common/src/public_abi_pending.rs new file mode 100644 index 00000000..3332c312 --- /dev/null +++ b/executor/crates/common/src/public_abi_pending.rs @@ -0,0 +1,5 @@ +// This file is auto-generated. Do not edit! + +#![allow(dead_code, clippy::redundant_static_lifetimes)] + +pub const RUNNER_LOAD_COST: u32 = 4096; diff --git a/executor/src/host/message.rs b/executor/src/host/message.rs index c718f821..db1ef821 100644 --- a/executor/src/host/message.rs +++ b/executor/src/host/message.rs @@ -100,3 +100,21 @@ impl std::fmt::Display for SlotID { f.write_str(&genlayer_sdk::gvm32::encode(&self.0)) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Golden vector for the slot indirection derivation + /// `sha3_256(slot_id || offset.to_le_bytes(4))`. The exact same constant is pinned + /// in the Python SDK test (`tests/test_slot_indirection_golden.py`) so the two + /// derivations can never silently diverge. + #[test] + fn slot_indirection_golden_vector() { + let derived = SlotID::ZERO.indirection(2); + assert_eq!( + hex::encode(derived.0), + "ba005630745acf3014aaf162e9933040302ca0bef3f56fe2d73c0a08f82c610b" + ); + } +} diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index c1089813..3584517d 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -338,11 +338,11 @@ fn fold_det_fingerprint( /// The stable per-load-action observability line (ADR-012): operators and the /// integration harness grep for the `runner load` message. `status` is -/// `charged` or `cached`. (`const` is a Rust keyword, hence `load_const`.) +/// `charged` or `cached`. (`const` is a Rust keyword, hence `runner_load_cost`.) fn log_runner_load(id: symbol_table::GlobalSymbol, size: u32, status: &'static str) { log_info!( runner = id.as_str(), - load_const = host_fns::LOAD_CONST, + runner_load_cost = public_abi_pending::RUNNER_LOAD_COST, size = size, status = status; "runner load" @@ -353,13 +353,13 @@ fn out_of_memory() -> anyhow::Error { rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into() } -/// Charges `LOAD_CONST + size` to the VM's limiter, OOM on failure (ADR-012 §1). +/// Charges `RUNNER_LOAD_COST + size` to the VM's limiter, OOM on failure (ADR-012 §1). /// A `size` past `u32::MAX`, or a sum that overflows, cannot fit any budget and /// maps to the same OOM. fn charge_load(limiter: &rt::memlimiter::Limiter, size: usize) -> anyhow::Result<()> { let ok = u32::try_from(size) .ok() - .and_then(|size| host_fns::LOAD_CONST.checked_add(size)) + .and_then(|size| public_abi_pending::RUNNER_LOAD_COST.checked_add(size)) .is_some_and(|amount| limiter.consume(amount)); if !ok { return Err(out_of_memory()); @@ -383,7 +383,7 @@ fn record_charged_load( log_runner_load(id, size, "charged"); } -/// Attaches to an already-materialized shared cell: charges `LOAD_CONST + size` +/// Attaches to an already-materialized shared cell: charges `RUNNER_LOAD_COST + size` /// then records the load. Its charge equals a miss's by content-determinism, so /// a VM cannot observe whether it materialized or attached (ADR-012 §1). fn attach_load( @@ -401,7 +401,7 @@ fn attach_load( /// The **load action**: the single way any runner enters a VM (ADR-012 §1). /// /// 1. already in the VM's loaded set → free, done; -/// 2. else charge `LOAD_CONST + size` to `limiter` (OOM on failure), +/// 2. else charge `RUNNER_LOAD_COST + size` to `limiter` (OOM on failure), /// materialize-or-attach the content, insert the pin, fold the det /// fingerprint. /// @@ -504,7 +504,7 @@ pub(crate) async fn load_action( .into()); } // Read the 4-byte length prefix, charge, then fetch the blob inside - // the creator: a single `LOAD_CONST + code_size` charge covers the peak + // the creator: a single `RUNNER_LOAD_COST + code_size` charge covers the peak // (ADR-012 §1) — the old chain double-charge is gone. let code_size = storage .read_code_len(slot) @@ -558,7 +558,7 @@ pub(crate) fn inherit_load( /// guarantees), in check order: /// /// 1. already in this VM's loaded set → free no-op, same id; -/// 2. charge `LOAD_CONST + code.len()` → OOM error, nothing charged or +/// 2. charge `RUNNER_LOAD_COST + code.len()` → OOM error, nothing charged or /// registered; /// 3. parse (attach to a live registry entry, or parse and insert) → a parse /// failure is a deterministic invalid-contract error; the charge is retained @@ -1333,19 +1333,19 @@ mod tests { } #[test] - fn charge_load_consumes_load_const_plus_size() { - let limiter = limiter_with_budget(host_fns::LOAD_CONST + 100); + fn charge_load_consumes_runner_load_cost_plus_size() { + let limiter = limiter_with_budget(public_abi_pending::RUNNER_LOAD_COST + 100); charge_load(&limiter, 100).unwrap(); assert_eq!( limiter.get_remaining_memory(), 0, - "charge must be exactly LOAD_CONST + size" + "charge must be exactly RUNNER_LOAD_COST + size" ); } #[test] fn charge_load_oom_charges_nothing() { - let budget = host_fns::LOAD_CONST + 99; + let budget = public_abi_pending::RUNNER_LOAD_COST + 99; let limiter = limiter_with_budget(budget); let err = charge_load(&limiter, 100).unwrap_err(); assert!( @@ -1361,7 +1361,7 @@ mod tests { #[test] fn charge_load_size_overflow_is_oom() { - // LOAD_CONST + u32::MAX overflows; must map to OOM, not wrap. + // RUNNER_LOAD_COST + u32::MAX overflows; must map to OOM, not wrap. let limiter = rt::memlimiter::Limiter::new("actions-test"); let err = charge_load(&limiter, u32::MAX as usize).unwrap_err(); assert!( @@ -1376,14 +1376,17 @@ mod tests { #[test] fn inherit_load_charges_once_then_is_free() { // Grant pins have total_size 1 (see `pin`). - let budget = 2 * (host_fns::LOAD_CONST + 1); + let budget = 2 * (public_abi_pending::RUNNER_LOAD_COST + 1); let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); let granted = pin(custom_id(1)); inherit_load(&limiter, &mut loaded, None, granted.clone()).unwrap(); let after_first = limiter.get_remaining_memory(); - assert_eq!(budget - after_first, host_fns::LOAD_CONST + 1); + assert_eq!( + budget - after_first, + public_abi_pending::RUNNER_LOAD_COST + 1 + ); assert!(loaded.contains(custom_id(1)), "grant must be pinned"); // Same id again (e.g. also the child's custom entry point): free. @@ -1397,8 +1400,8 @@ mod tests { #[test] fn inherit_load_oom_leaves_loaded_set_unchanged() { - // One short of LOAD_CONST + total_size(=1). - let budget = host_fns::LOAD_CONST; + // One short of RUNNER_LOAD_COST + total_size(=1). + let budget = public_abi_pending::RUNNER_LOAD_COST; let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1462,10 +1465,10 @@ mod tests { } #[tokio::test] - async fn register_charges_load_const_plus_code_len_and_pins() { + async fn register_charges_runner_load_cost_plus_code_len_and_pins() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let budget = 2 * (host_fns::LOAD_CONST + code.len() as u32); + let budget = 2 * (public_abi_pending::RUNNER_LOAD_COST + code.len() as u32); let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1476,7 +1479,7 @@ mod tests { assert_eq!(id, custom_id_of(&code)); assert_eq!( budget - limiter.get_remaining_memory(), - host_fns::LOAD_CONST + code.len() as u32 + public_abi_pending::RUNNER_LOAD_COST + code.len() as u32 ); assert!(loaded.contains(id), "registered runner must be resolvable"); } @@ -1511,7 +1514,7 @@ mod tests { async fn register_oom_charges_and_registers_nothing() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let budget = host_fns::LOAD_CONST + code.len() as u32 - 1; + let budget = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32 - 1; let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1535,7 +1538,7 @@ mod tests { let registry = runners::cache::WeakCache::new(); // Not a zip, not wasm, not UTF-8 text: parse fails on the bytes alone. let code = bytes::Bytes::from_static(b"\xff\xfe\xfd"); - let budget = host_fns::LOAD_CONST + code.len() as u32; + let budget = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32; let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1587,7 +1590,7 @@ mod tests { ); // Child spawn: inherit load actions charge the child's own limiter. - let cost = host_fns::LOAD_CONST + code.len() as u32; + let cost = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32; let child_limiter = limiter_with_budget(cost); let mut child = runners::cache::LoadedSet::default(); for grant in grants { @@ -1605,7 +1608,7 @@ mod tests { async fn register_dead_content_reparses_and_recharges_identically() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let cost = host_fns::LOAD_CONST + code.len() as u32; + let cost = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32; let limiter = limiter_with_budget(2 * cost); let mut loaded = runners::cache::LoadedSet::default(); diff --git a/executor/src/rt/vm/storage.rs b/executor/src/rt/vm/storage.rs index 1b1f1c2d..61a8c357 100644 --- a/executor/src/rt/vm/storage.rs +++ b/executor/src/rt/vm/storage.rs @@ -485,7 +485,7 @@ impl Storage { /// Reads the 4-byte little-endian length prefix of the code blob at /// `code_slot`. Cheap (one slot read, no fee metering) — the runner load /// action reads this first to learn the size to charge *before* fetching the - /// blob, so a single `LOAD_CONST + code_size` charge covers the peak + /// blob, so a single `RUNNER_LOAD_COST + code_size` charge covers the peak /// (ADR-012 §1). pub async fn read_code_len(&mut self, code_slot: SlotID) -> rt::errors::Result { let mut len_buf = [0; 4]; diff --git a/executor/src/runners/mod.rs b/executor/src/runners/mod.rs index 6cc58fb8..f2e8a6b2 100644 --- a/executor/src/runners/mod.rs +++ b/executor/src/runners/mod.rs @@ -235,6 +235,13 @@ pub fn parse_runner_id(id: &str) -> Option { if name.is_empty() || hash.is_empty() { return None; } + // ADR-011 reserves `contract`, `chain` and `custom` as prefixes. `chain:`/`custom:` + // are already handled above; a `contract:` id must not fall through to the + // generic builtin arm (the bare `contract` literal is handled at the top). + if matches!(name, "contract" | "chain" | "custom") { + log_warn!("`{name}` is a reserved runner name and cannot be used as a builtin"); + return None; + } for c in name.chars() { if !c.is_ascii_alphanumeric() && c != '-' && c != '_' { log_warn!("character `{c}` is not allowed in runner id"); @@ -250,6 +257,35 @@ pub fn parse_runner_id(id: &str) -> Option { }) } +#[cfg(test)] +mod parse_runner_id_tests { + use super::*; + + #[test] + fn bare_contract_literal_is_special_cased() { + assert!(matches!( + parse_runner_id("contract"), + Some(IdUnresolved::Contract) + )); + } + + #[test] + fn contract_is_a_reserved_builtin_name() { + // ADR-011 reserves `contract`; a `contract:` id must not fall through to + // the generic `name:hash` builtin arm. + assert!(parse_runner_id("contract:abc123").is_none()); + } + + #[test] + fn ordinary_builtin_still_parses() { + assert!(matches!( + parse_runner_id("py:abc123"), + Some(IdUnresolved::Builtin { name, hash }) + if name == "py" && hash == "abc123" + )); + } +} + /// The dev-mode magic builtin/custom runner hash. Its GVM32 form is the literal /// string `test` padded with `0`s to 52 chars (`test0000…`), which is what shows /// up on disk and in `all.json`/`latest.json`. The raw bytes below are exactly diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs index 58abe885..a9787531 100644 --- a/executor/src/wasi/genlayer_sdk.rs +++ b/executor/src/wasi/genlayer_sdk.rs @@ -557,6 +557,16 @@ impl Context { pub fn new(data: Box, limiter: rt::memlimiter::Limiter) -> Self { let now = std::time::Instant::now(); + // Every VM's config passes through here. Invariant: only a `Default`-state + // VM reads through its local storage cache (read-your-writes); a VM with a + // non-`Default` state_mode reads bypass the cache, so it must not be able to + // write (otherwise its writes would hit the cache but never be read back). + debug_assert!( + data.conf.state_mode == public_abi::StorageType::Default + || !data.conf.can_write_storage, + "a VM with state_mode != Default must not have can_write_storage" + ); + Self { data: *data, loaded: Default::default(), @@ -1964,7 +1974,7 @@ impl ContextVFS<'_> { } // The load action registers the content (weak registry, dedup while - // alive), charges `LOAD_CONST + code.len()` to this VM before parsing, and + // alive), charges `RUNNER_LOAD_COST + code.len()` to this VM before parsing, and // pins it into this VM's loaded set — scoping it to this execution and its // deterministic children only (ADR-012 §3). RegisterRunner is det-only, so // the load folds into the det fingerprint. diff --git a/executor/src/wasi/preview1.rs b/executor/src/wasi/preview1.rs index 2bf57383..634013d4 100644 --- a/executor/src/wasi/preview1.rs +++ b/executor/src/wasi/preview1.rs @@ -1363,3 +1363,53 @@ fn write_bytes_capacity( let next = ptr.add(len)?; Ok(next) } + +#[cfg(test)] +mod tests { + use rand_core::RngCore as _; + use sha3::Digest as _; + + /// Known-answer test for the deterministic `random_get` byte layout. The seed is a + /// fixed 32-byte value consumed as 8 little-endian `u32` words (matching + /// `Context::new`), and `fill_bytes` produces the exact stream below. This pins the + /// endianness and partial-word layout of the MT19937 output; the calldata seed + /// derivation (`sha3-256(stdin)`) is exercised separately. + #[test] + fn det_random_get_known_answer() { + let seed: [u8; 32] = std::array::from_fn(|i| i as u8); + let seed_words: [u32; 8] = std::array::from_fn(|i| { + u32::from_le_bytes([ + seed[i * 4], + seed[i * 4 + 1], + seed[i * 4 + 2], + seed[i * 4 + 3], + ]) + }); + let mut rng = mt19937::MT19937::new_with_slice_seed(&seed_words); + + let mut out = [0u8; 20]; + rng.fill_bytes(&mut out); + + assert_eq!(hex::encode(out), "a0a49802eee109f3e6e193c8b06d7ee6a717a4d7"); + } + + /// Known-answer test for the sub-VM rolling SHA3-256 accumulator + /// (`SingleVMData::det_subvm_hashes`): the empty digest and one `update` fold step. + /// The spec under-defines this fold, so pin the current behavior to catch drift. + #[test] + fn det_subvm_hash_fold_known_answer() { + let empty = sha3::Sha3_256::new().finalize(); + assert_eq!( + hex::encode(empty), + "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a" + ); + + let child: [u8; 32] = std::array::from_fn(|i| i as u8); + let mut acc = sha3::Sha3_256::new(); + acc.update(child); + assert_eq!( + hex::encode(acc.finalize()), + "050a48733bd5c2756ba95c5828cc83ee16fabcd3c086885b7744f84a0f9e0d94" + ); + } +} diff --git a/runners/genlayer-py-std/tests/test_calldata_corpus.py b/runners/genlayer-py-std/tests/test_calldata_corpus.py new file mode 100644 index 00000000..925992ec --- /dev/null +++ b/runners/genlayer-py-std/tests/test_calldata_corpus.py @@ -0,0 +1,29 @@ +import genlayer.calldata as calldata + +# Shared cross-language corpus: (logical value, expected canonical hex). The exact same +# hex is pinned in the Rust encoder test (crates/calldata/src/bin.rs) so the two encoders +# can never silently diverge. Covers boundary ints, empty/short str+bytes, a map whose +# content-order ("aa" < "z") differs from length-order, and nested containers. +CORPUS = [ + (0, '01'), + (-1, '02'), + (2**64, '81808080808080808010'), + (-(2**64), 'faffffffffffffffff0f'), + ('', '04'), + ('hello', '2c68656c6c6f'), + (bytes([1, 2, 3]), '1b010203'), + ({'z': 1, 'aa': 2}, '1602616111017a09'), + ({'': None, 'a': [1, 2, {'b': False}]}, '16000001611d09110e016208'), +] + + +def test_calldata_corpus_encode(): + for value, expected in CORPUS: + assert calldata.encode(value).hex() == expected, f'encoding mismatch for {value!r}' + + +def test_calldata_corpus_decode_roundtrip(): + for value, expected in CORPUS: + assert calldata.decode(bytes.fromhex(expected)) == value, ( + f'roundtrip mismatch for {value!r}' + ) diff --git a/runners/genlayer-py-std/tests/test_root_offsets_layout.py b/runners/genlayer-py-std/tests/test_root_offsets_layout.py new file mode 100644 index 00000000..c77a9642 --- /dev/null +++ b/runners/genlayer-py-std/tests/test_root_offsets_layout.py @@ -0,0 +1,38 @@ +from genlayer.storage._internal.generate import _known_descs +from genlayer.storage.root import Root +from genlayer.vm import public_abi + + +def test_root_offsets_pinned(): + """Pin the numeric root-slot offsets (mirrors the Rust ``root_offsets`` test).""" + ro = public_abi.root_offsets + assert ( + ro.MAJOR, + ro.CONTRACT, + ro.CODE, + ro.LOCKED_SLOTS, + ro.UPGRADERS, + ro.CODE_SLOT, + ro.PERMISSIONS, + ) == (0, 1, 2, 3, 4, 5, 37) + + +def test_computed_root_layout_matches_public_abi(): + """ + The computed ``Root`` storage layout must agree with ``public_abi.root_offsets``, so + any future field reorder or size change trips this test. + """ + ro = public_abi.root_offsets + expected = { + 'major': ro.MAJOR, + 'contract_instance': ro.CONTRACT, + 'code': ro.CODE, + 'locked_slots': ro.LOCKED_SLOTS, + 'upgraders': ro.UPGRADERS, + 'code_slot': ro.CODE_SLOT, + 'permissions': ro.PERMISSIONS, + } + + props = _known_descs[Root].props # name -> (TypeDesc, byte offset) + for name, off in expected.items(): + assert props[name][1] == off, f'offset mismatch for `{name}`' diff --git a/runners/genlayer-py-std/tests/test_slot_indirection_golden.py b/runners/genlayer-py-std/tests/test_slot_indirection_golden.py new file mode 100644 index 00000000..ccf0a0f4 --- /dev/null +++ b/runners/genlayer-py-std/tests/test_slot_indirection_golden.py @@ -0,0 +1,17 @@ +from genlayer.storage.core import ROOT_SLOT_ID, InmemManager + + +def test_slot_indirection_golden(): + """ + Golden vector for the slot indirection derivation + ``sha3_256(slot_id || offset.to_le_bytes(4))``. The same constant is pinned in the + Rust test (``executor/src/host/message.rs``) so the two derivations can never + silently diverge. + """ + man = InmemManager() + slot = man.get_store_slot(ROOT_SLOT_ID) + derived = slot.indirect(2) + assert ( + derived.id.hex() + == 'ba005630745acf3014aaf162e9933040302ca0bef3f56fe2d73c0a08f82c610b' + ) diff --git a/tests/integration/runner/load_arith/load_arith.jsonnet b/tests/integration/runner/load_arith/load_arith.jsonnet index e8295da0..6fd0767b 100644 --- a/tests/integration/runner/load_arith/load_arith.jsonnet +++ b/tests/integration/runner/load_arith/load_arith.jsonnet @@ -1,6 +1,6 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; -// ADR-012 §1: first load of a runner charges exactly LOAD_CONST + size, once. +// ADR-012 §1: first load of a runner charges exactly RUNNER_LOAD_COST + size, once. // A bare raw-wasm contract's runner tree is a single node, so exactly one // `charged` record must appear, sized to the raw wasm length, none `cached`. {tags: util.features([['runner', 'load']], 'stable'), @@ -8,7 +8,7 @@ local util = import 'templates/util.jsonnet'; runner_load_asserts: [ {match: {}, count: 1}, {match: {status: 'charged'}, count: 1, size_is_code_len: true}, - {match: {load_const: 4096}, count: 1}, + {match: {runner_load_cost: 4096}, count: 1}, {match: {status: 'cached'}, count: 0}, ], }])} diff --git a/tests/integration/runner/load_arith/load_arith.wat b/tests/integration/runner/load_arith/load_arith.wat index fd389d9d..cc2b2097 100644 --- a/tests/integration/runner/load_arith/load_arith.wat +++ b/tests/integration/runner/load_arith/load_arith.wat @@ -5,7 +5,7 @@ ;; `{ "StartWasm": "file" }` runner with no Depends/With, so its whole runner ;; tree is a single load action — the main chain:deploy runner itself. That ;; makes the load-action arithmetic exactly hand-computable: one `charged` - ;; record of `LOAD_CONST + size`, where `size` == the raw wasm length. + ;; record of `RUNNER_LOAD_COST + size`, where `size` == the raw wasm length. (func (export "_start") (type $void_fn)) (memory $mem 1) diff --git a/tests/integration/runner/register_ladder/register_ladder.jsonnet b/tests/integration/runner/register_ladder/register_ladder.jsonnet index 61060737..9035c1d4 100644 --- a/tests/integration/runner/register_ladder/register_ladder.jsonnet +++ b/tests/integration/runner/register_ladder/register_ladder.jsonnet @@ -10,6 +10,6 @@ local util = import 'templates/util.jsonnet'; runner_load_asserts: [ {runner_prefix: 'custom:', match: {status: 'charged'}, count: 1}, {runner_prefix: 'custom:', match: {status: 'cached'}, count: 1}, - {runner_prefix: 'custom:', match: {load_const: 4096}, min: 1}, + {runner_prefix: 'custom:', match: {runner_load_cost: 4096}, min: 1}, ], }])} diff --git a/tests/integration/runner/register_ladder/register_ladder.py b/tests/integration/runner/register_ladder/register_ladder.py index 0e62473c..c6cf2043 100644 --- a/tests/integration/runner/register_ladder/register_ladder.py +++ b/tests/integration/runner/register_ladder/register_ladder.py @@ -2,7 +2,7 @@ from genlayer.vm import register_runner # ADR-012 §3 register ladder, success path: the first registration charges -# `LOAD_CONST + len(code)` and emits one `charged` custom record; a second +# `RUNNER_LOAD_COST + len(code)` and emits one `charged` custom record; a second # registration of the *same* code in the *same* VM is a free no-op that resolves # to the same id and emits one `cached` custom record. code = b'# { "Depends": "py-genlayer:test" }\nexit(0)\n' diff --git a/tests/integration/runner/register_malformed_charge/register_malformed_charge.py b/tests/integration/runner/register_malformed_charge/register_malformed_charge.py index 52d72c74..95371ff9 100644 --- a/tests/integration/runner/register_malformed_charge/register_malformed_charge.py +++ b/tests/integration/runner/register_malformed_charge/register_malformed_charge.py @@ -2,7 +2,7 @@ from genlayer.vm import register_runner # ADR-012 §3 register ladder, malformed path: `RegisterRunner` charges -# `LOAD_CONST + len(code)` *before* parsing, so a parse failure is a +# `RUNNER_LOAD_COST + len(code)` *before* parsing, so a parse failure is a # deterministic invalid-contract error with the charge retained (released with # the VM) and the runner never resolvable. Observable here as: exactly one # `charged` custom record (the earlier valid registration) and none for the From e5dbc587056be7063550f5eb18d4054ec6484966 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 14:47:03 +0900 Subject: [PATCH 14/25] fix(vm): update startup limits --- .git-third-party/config.json | 2 +- .../patches/executor/third-party/wasmtime/5 | 85 +++++++++++++++++++ executor/codegen/data/public-abi-pending.json | 9 ++ .../crates/common/src/public_abi_pending.rs | 4 + executor/src/rt/supervisor/mod.rs | 21 ++++- 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 .git-third-party/patches/executor/third-party/wasmtime/5 diff --git a/.git-third-party/config.json b/.git-third-party/config.json index 90f379a6..eb8cc9e9 100644 --- a/.git-third-party/config.json +++ b/.git-third-party/config.json @@ -3,7 +3,7 @@ "executor/third-party/wasmtime": { "url": "https://github.com/bytecodealliance/wasmtime.git", "commit": "6844a83b530f86ad13a65175282fe2dbcd33cad1", - "patches": 4, + "patches": 5, "submodules": [] }, "executor/third-party/wasm-tools": { diff --git a/.git-third-party/patches/executor/third-party/wasmtime/5 b/.git-third-party/patches/executor/third-party/wasmtime/5 new file mode 100644 index 00000000..199319ca --- /dev/null +++ b/.git-third-party/patches/executor/third-party/wasmtime/5 @@ -0,0 +1,85 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: kp2pml30 +Date: Tue, 7 Jul 2026 14:03:52 +0900 +Subject: [PATCH] feat(genvm): configure wasm stack limits + +--- + crates/wasmtime/src/config.rs | 14 ++++++++++++++ + crates/wasmtime/src/runtime/store.rs | 5 ++++- + crates/wasmtime/src/runtime/vm/vmcontext.rs | 8 ++++++++ + 3 files changed, 26 insertions(+), 1 deletion(-) + +diff --git a/crates/wasmtime/src/config.rs b/crates/wasmtime/src/config.rs +index 1cbbd4b2..746ae852 100644 +--- a/crates/wasmtime/src/config.rs ++++ b/crates/wasmtime/src/config.rs +@@ -160,6 +160,8 @@ pub struct Config { + pub(crate) custom_code_memory: Option>, + pub(crate) allocation_strategy: InstanceAllocationStrategy, + pub(crate) max_wasm_stack: usize, ++ pub(crate) wasm_call_stack_limit: u32, ++ pub(crate) wasm_value_stack_limit: u32, + /// Explicitly enabled features via `Config::wasm_*` methods. This is a + /// signal that the embedder specifically wants something turned on + /// regardless of the defaults that Wasmtime might otherwise have enabled. +@@ -274,6 +276,8 @@ impl Config { + // 1` forces this), or at least it passed when this change was + // committed. + max_wasm_stack: 512 * 1024, ++ wasm_call_stack_limit: 1024, ++ wasm_value_stack_limit: 65535, + wasm_backtrace: true, + wasm_backtrace_details_env_used: false, + native_unwind_info: None, +@@ -741,6 +745,16 @@ impl Config { + self + } + ++ /// Configures semantic WebAssembly stack limits. ++ /// ++ /// `call_depth` is the maximum number of nested Wasm function calls and ++ /// `value_slots` is the maximum number of Wasm operand-stack slots. ++ pub fn wasm_stack_limits(&mut self, call_depth: u32, value_slots: u32) -> &mut Self { ++ self.wasm_call_stack_limit = call_depth; ++ self.wasm_value_stack_limit = value_slots; ++ self ++ } ++ + /// Configures the size of the stacks used for asynchronous execution. + /// + /// This setting configures the size of the stacks that are allocated for +diff --git a/crates/wasmtime/src/runtime/store.rs b/crates/wasmtime/src/runtime/store.rs +index 3656a449..ce31edb4 100644 +--- a/crates/wasmtime/src/runtime/store.rs ++++ b/crates/wasmtime/src/runtime/store.rs +@@ -753,7 +753,10 @@ impl Store { + let inner = StoreOpaque { + _marker: marker::PhantomPinned, + engine: engine.clone(), +- vm_store_context: Default::default(), ++ vm_store_context: VMStoreContext::new( ++ engine.config().wasm_call_stack_limit, ++ engine.config().wasm_value_stack_limit, ++ ), + #[cfg(feature = "stack-switching")] + continuations: Vec::new(), + instances: wasmtime_environ::collections::PrimaryMap::new(), +diff --git a/crates/wasmtime/src/runtime/vm/vmcontext.rs b/crates/wasmtime/src/runtime/vm/vmcontext.rs +index 01e3373a..cc0421c3 100644 +--- a/crates/wasmtime/src/runtime/vm/vmcontext.rs ++++ b/crates/wasmtime/src/runtime/vm/vmcontext.rs +@@ -1196,6 +1196,14 @@ pub struct VMStoreContext { + } + + impl VMStoreContext { ++ pub(crate) fn new(wasm_call_stack_limit: u32, wasm_value_stack_limit: u32) -> VMStoreContext { ++ VMStoreContext { ++ call_stack_limit_reverse: UnsafeCell::new(wasm_call_stack_limit), ++ value_stack_limit_reverse: UnsafeCell::new(wasm_value_stack_limit), ++ ..Default::default() ++ } ++ } ++ + /// From the current saved trampoline FP, get the FP of the last + /// Wasm frame. If the current saved trampoline FP is null, return + /// null. diff --git a/executor/codegen/data/public-abi-pending.json b/executor/codegen/data/public-abi-pending.json index b45fd83e..da8c0d08 100644 --- a/executor/codegen/data/public-abi-pending.json +++ b/executor/codegen/data/public-abi-pending.json @@ -4,5 +4,14 @@ "name": "runner_load_cost", "repr": "u32", "value": 4096 + }, + { + "type": "consts", + "name": "wasm_stack_limits", + "repr": "u32", + "values": { + "call_depth": 1024, + "value_slots": 65535 + } } ] diff --git a/executor/crates/common/src/public_abi_pending.rs b/executor/crates/common/src/public_abi_pending.rs index 3332c312..077e6911 100644 --- a/executor/crates/common/src/public_abi_pending.rs +++ b/executor/crates/common/src/public_abi_pending.rs @@ -3,3 +3,7 @@ #![allow(dead_code, clippy::redundant_static_lifetimes)] pub const RUNNER_LOAD_COST: u32 = 4096; +pub mod wasm_stack_limits { + pub const CALL_DEPTH: u32 = 1024; + pub const VALUE_SLOTS: u32 = 65535; +} diff --git a/executor/src/rt/supervisor/mod.rs b/executor/src/rt/supervisor/mod.rs index 06793fdc..a2affa68 100644 --- a/executor/src/rt/supervisor/mod.rs +++ b/executor/src/rt/supervisor/mod.rs @@ -7,7 +7,7 @@ use anyhow::Context as _; use genvm_common::*; use crate::{ - config, host, public_abi, + config, host, public_abi, public_abi_pending, rt::{self, memlimiter, DetNondet}, runners, wasi, }; @@ -95,6 +95,19 @@ impl Drop for Supervisor { } } +const fn get_native_stack_size() -> u32 { + let native_stack_size = (6 as u32) << 20; + + let approximation = public_abi_pending::wasm_stack_limits::VALUE_SLOTS * 8 * 4 + + public_abi_pending::wasm_stack_limits::CALL_DEPTH * 64; + + if native_stack_size < approximation { + panic!("native stack size is smaller than the configured call depth limit"); + } + + native_stack_size +} + pub fn create_engines( config_base: impl FnOnce(&mut wasmtime::Config) -> anyhow::Result<()>, ) -> anyhow::Result> { @@ -106,7 +119,11 @@ pub fn create_engines( .consume_fuel(false) .cranelift_opt_level(wasmtime::OptLevel::None) .async_stack_size(8 << 20) - .max_wasm_stack(6 << 20); + .max_wasm_stack(get_native_stack_size()) + .wasm_stack_limits( + public_abi_pending::wasm_stack_limits::CALL_DEPTH, + public_abi_pending::wasm_stack_limits::VALUE_SLOTS, + ); base_conf .wasm_tail_call(true) From 9855f48b9a777f583b745c6c7335385774c07b09 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 16:41:29 +0900 Subject: [PATCH 15/25] feat: add signer_address, rework to meta-fields --- executor/crates/common/src/domain.rs | 8 + executor/crates/sdk-rs/src/abi/entry.rs | 5 + executor/src/exe/run.rs | 23 +- executor/src/host/mod.rs | 4 + executor/src/lib.rs | 29 ++- executor/src/rt/mod.rs | 5 +- executor/src/rt/supervisor/actions.rs | 62 +++++- executor/src/rt/supervisor/mod.rs | 91 +++++++- executor/src/wasi/base.rs | 29 ++- executor/src/wasi/genlayer_sdk.rs | 206 ++++++++++-------- executor/src/wasi/preview1.rs | 2 +- .../genlayer-py-std/src/genlayer/message.py | 9 + runners/support/versions/current.nix | 2 +- runners/support/versions/dev-mode.nix | 2 +- .../deep_dependency_sandbox.jsonnet | 2 +- .../map_no_read_perm.0.stdout | 2 +- .../map_no_read_perm/map_no_read_perm.py | 2 +- .../map_runner_file/map_runner_file.jsonnet | 2 +- .../runner/no_flowback/no_flowback.jsonnet | 2 +- .../nondet_custom_runner.jsonnet | 2 +- .../nondet_inherit_custom.jsonnet | 2 +- .../register-runner/register-runner.jsonnet | 2 +- .../register_ladder/register_ladder.jsonnet | 2 +- .../register_loop/register_loop.jsonnet | 2 +- .../register_malformed_charge.jsonnet | 2 +- .../register_no_perm.0.stdout | 2 +- .../register_no_perm/register_no_perm.py | 2 +- .../register_nondet/register_nondet.jsonnet | 2 +- .../runner/register_nondet/register_nondet.py | 2 +- .../sandbox_custom_runner.jsonnet | 2 +- 30 files changed, 353 insertions(+), 156 deletions(-) diff --git a/executor/crates/common/src/domain.rs b/executor/crates/common/src/domain.rs index 9aa772b0..b5ed4b8a 100644 --- a/executor/crates/common/src/domain.rs +++ b/executor/crates/common/src/domain.rs @@ -5,6 +5,10 @@ pub use genlayer_sdk::abi::entry::MessageData; pub mod fees; +fn default_record_actions() -> Vec { + Vec::new() +} + #[allow(clippy::enum_variant_names)] #[derive(Debug, Clone, PartialEq, Eq, genlayer_calldata::Encode)] #[calldata(tag = "type")] @@ -79,4 +83,8 @@ pub struct ExecutionData { pub message_fee_allocation: Vec, /// Initial time-unit budget for this execution. pub initial_time_units_allocation: u32, + /// Auditable supervisor action kinds to return in the execution result. + #[serde(default)] + #[calldata(default = default_record_actions)] + pub record_actions: Vec, } diff --git a/executor/crates/sdk-rs/src/abi/entry.rs b/executor/crates/sdk-rs/src/abi/entry.rs index 8faf62f6..b2d2b6a6 100644 --- a/executor/crates/sdk-rs/src/abi/entry.rs +++ b/executor/crates/sdk-rs/src/abi/entry.rs @@ -123,6 +123,7 @@ pub struct MessageData { pub contract_address: Address, pub sender_address: Address, pub origin_address: Address, + pub signer_address: Address, /// View methods call chain. /// It is empty for entrypoint (refer to [`contract_address`]) #[serde(default)] @@ -164,6 +165,7 @@ impl<'a> arbitrary::Arbitrary<'a> for MessageData { contract_address: Arbitrary::arbitrary(u)?, sender_address: Arbitrary::arbitrary(u)?, origin_address: Arbitrary::arbitrary(u)?, + signer_address: Arbitrary::arbitrary(u)?, stack: Vec::new(), chain_id: num_bigint::BigInt::from_bytes_be( num_bigint::Sign::Plus, @@ -187,6 +189,7 @@ pub struct ExtendedMessageFlat { pub contract_address: Address, pub sender_address: Address, pub origin_address: Address, + pub signer_address: Address, #[serde(default)] #[calldata(default = default_stack)] pub stack: Vec
, @@ -224,6 +227,7 @@ impl From for ExtendedMessage { contract_address: flat.contract_address, sender_address: flat.sender_address, origin_address: flat.origin_address, + signer_address: flat.signer_address, stack: flat.stack, chain_id: flat.chain_id, value: flat.value, @@ -243,6 +247,7 @@ impl From for ExtendedMessageFlat { contract_address: msg.message.contract_address, sender_address: msg.message.sender_address, origin_address: msg.message.origin_address, + signer_address: msg.message.signer_address, stack: msg.message.stack, chain_id: msg.message.chain_id, value: msg.message.value, diff --git a/executor/src/exe/run.rs b/executor/src/exe/run.rs index 9ceac5ee..c5771876 100644 --- a/executor/src/exe/run.rs +++ b/executor/src/exe/run.rs @@ -36,10 +36,16 @@ pub struct Args { sync: bool, #[clap( long, - default_value = "rwscn", - help = "r?w?s?c?n?u?, read/write/send messages/call contracts/spawn nondet/register runners" + default_value = "wscn", + help = "w?s?c?n?, write/send messages/call contracts/spawn nondet" )] permissions: String, + #[arg( + long, + value_delimiter = ',', + help = "record auditable actions: runner_load,vm_spawn" + )] + record_actions: Vec, #[clap(long, help = "override LLM module address from config")] module_llm: Option, #[clap(long, help = "override web module address from config")] @@ -79,6 +85,15 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { let execution_data = calldata::decode_obj::(&execution_data_bytes) .with_context(|| "decoding execution data")?; + let mut execution_data = execution_data; + execution_data + .record_actions + .extend(args.record_actions.iter().cloned()); + for action in &execution_data.record_actions { + if !matches!(action.as_str(), "runner_load" | "vm_spawn") { + anyhow::bail!("Invalid action recorder kind {action}"); + } + } let message = &execution_data.message; let host_data = rt::parse_host_data(&execution_data)?; @@ -210,6 +225,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { data_fees_remaining, data_fees_consumed, primitive_types::U256::zero(), + Vec::new(), )); hosts[host_for(genvm::host::host_fns::Methods::ConsumeResult)] @@ -228,7 +244,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { } let mut perm_size = 0; - for perm in ["r", "w", "s", "c", "n", "u"] { + for perm in ["w", "s", "c", "n"] { if args.permissions.contains(perm) { perm_size += 1; } @@ -250,6 +266,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { gas_data: execution_data.gas_data.clone(), initial_time_units_allocation: execution_data.initial_time_units_allocation, leader_nondet_results: execution_data.leader_nondet_results.clone(), + record_actions: execution_data.record_actions.clone(), }, host_data, shared_data, diff --git a/executor/src/host/mod.rs b/executor/src/host/mod.rs index dba91941..64fd32ee 100644 --- a/executor/src/host/mod.rs +++ b/executor/src/host/mod.rs @@ -169,6 +169,7 @@ pub struct FullResult { pub data_fees_consumed: rt::fees::BucketsConsumed, pub llm_consumption: primitive_types::U256, + pub recorded_actions: Vec, } impl FullResult { @@ -186,6 +187,7 @@ impl FullResult { data_fees_remaining: Vec::new(), data_fees_consumed: rt::fees::BucketsConsumed::default(), llm_consumption: primitive_types::U256::zero(), + recorded_actions: Vec::new(), } } } @@ -198,6 +200,7 @@ impl FullResult { data_fees_remaining: Vec, data_fees_consumed: rt::fees::BucketsConsumed, llm_consumption: primitive_types::U256, + recorded_actions: Vec, ) -> Self { struct Hashable<'a> { backtrace: &'a Option, @@ -278,6 +281,7 @@ impl FullResult { data_fees_remaining, data_fees_consumed, llm_consumption, + recorded_actions, } } } diff --git a/executor/src/lib.rs b/executor/src/lib.rs index 5766b12a..27fc0ecf 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -67,6 +67,7 @@ pub struct CreateSupervisorNamedArgs { pub gas_data: std::collections::BTreeMap, pub initial_time_units_allocation: u32, pub leader_nondet_results: Option>, + pub record_actions: Vec, } pub fn create_supervisor( @@ -141,6 +142,7 @@ pub fn create_supervisor( locked_slots, leader_nondet_results: named.leader_nondet_results, multi_host, + record_actions: named.record_actions, }; rt::supervisor::Supervisor::start(config, ctor) @@ -232,19 +234,23 @@ pub async fn run_with_impl( let essential_data = Box::new(wasi::genlayer_sdk::SingleVMData { depth: 0, - // Permission model: doc/website/src/spec/03-vm/05-permissions.rst + spawn_kind: "initial".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst conf: wasi::base::Config { needs_error_fingerprint: true, - is_deterministic: true, - can_read_storage: permissions.contains("r"), - can_write_storage: permissions.contains("w"), - can_send_messages: permissions.contains("s"), - can_call_others: permissions.contains("c"), - can_spawn_nondet: permissions.contains("n"), - can_register_runners: permissions.contains("u"), - can_use_balance_for_message_fees, - state_mode: crate::public_abi::StorageType::Default, - topmost_runner_id, + permissions: wasi::base::Permissions { + deterministic: true, + write_storage: permissions.contains("w"), + send_messages: permissions.contains("s"), + call_others: permissions.contains("c"), + spawn_nondet: permissions.contains("n"), + register_runners: true, + can_use_balance_for_message_fees, + }, + execution: wasi::base::Execution { + state_mode: crate::public_abi::StorageType::Default, + topmost_runner_id, + }, }, message_data: ExtendedMessage { message: entry_data.message, @@ -354,6 +360,7 @@ pub async fn run_with( data_fees_remaining, data_fees_consumed, llm_consumption, + supervisor.take_recorded_actions().await, )), Err(e) => Err(e), }; diff --git a/executor/src/rt/mod.rs b/executor/src/rt/mod.rs index f460c511..28f4fff1 100644 --- a/executor/src/rt/mod.rs +++ b/executor/src/rt/mod.rs @@ -97,7 +97,10 @@ async fn spawn_apply_run_inner( supervisor: &Arc, vm: Box, ) -> std::result::Result { - let limiter = supervisor.limiter.get(vm.conf.is_deterministic).derived(); + let limiter = supervisor + .limiter + .get(vm.conf.permissions.deterministic) + .derived(); let vm = supervisor::spawn(supervisor, vm, limiter).await?; diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index 3584517d..72dcf60d 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -349,6 +349,25 @@ fn log_runner_load(id: symbol_table::GlobalSymbol, size: u32, status: &'static s ); } +async fn record_runner_load( + supervisor: &rt::supervisor::Supervisor, + id: symbol_table::GlobalSymbol, + size: u32, + status: &'static str, +) { + supervisor + .action_recorder + .record_or_log( + "runner_load", + BTreeMap::from([ + ("runner_id".to_owned(), id.as_str().to_owned()), + ("size".to_owned(), size.to_string()), + ("status".to_owned(), status.to_owned()), + ]), + ) + .await; +} + fn out_of_memory() -> anyhow::Error { rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into() } @@ -418,6 +437,7 @@ pub(crate) async fn load_action( ) -> anyhow::Result { if let Some(pin) = loaded.get(resolved.id) { log_runner_load(resolved.id, pin.total_size(), "cached"); + record_runner_load(supervisor, resolved.id, pin.total_size(), "cached").await; return Ok(pin.clone()); } @@ -429,12 +449,14 @@ pub(crate) async fn load_action( ResolvedKind::Disk { name, hash } => { let cell = supervisor.runner_cache.cell(id); if cell.initialized() { - return attach_load( + let pin = attach_load( limiter, loaded, det_fingerprint, runners::cache::pin_of(cell), - ); + )?; + record_runner_load(supervisor, id, pin.total_size(), "charged").await; + return Ok(pin); } // Miss: learn the size from the file, charge, then materialize (so the // charge precedes the resident copy). @@ -463,17 +485,20 @@ pub(crate) async fn load_action( "materialized disk archive size differs from the charged size" ); record_charged_load(loaded, det_fingerprint, pin.clone()); + record_runner_load(supervisor, id, pin.total_size(), "charged").await; Ok(pin) } ResolvedKind::Chain { address, on, slot } => { let cell = supervisor.runner_cache.cell(id); if cell.initialized() { - return attach_load( + let pin = attach_load( limiter, loaded, det_fingerprint, runners::cache::pin_of(cell), - ); + )?; + record_runner_load(supervisor, id, pin.total_size(), "charged").await; + return Ok(pin); } let mode = on.host_storage_type().ok_or_else(|| { make_malformed_runner_error("deploy-state chain runner is not available on chain") @@ -530,6 +555,7 @@ pub(crate) async fn load_action( "chain archive total_size differs from the charged code size" ); record_charged_load(loaded, det_fingerprint, pin.clone()); + record_runner_load(supervisor, id, pin.total_size(), "charged").await; Ok(pin) } } @@ -577,14 +603,26 @@ pub(crate) async fn register_runner_load( det_fingerprint: Option<&mut sha3::Sha3_256>, code: bytes::Bytes, ) -> anyhow::Result { - register_runner_load_into( + let hash = runners::custom_runner_hash(&code); + let id = runners::Id::Custom { hash }.canonical(); + let was_loaded = loaded.get(id).map(|pin| pin.total_size()); + let id = register_runner_load_into( &supervisor.custom_runners, limiter, loaded, det_fingerprint, code, ) - .await + .await?; + let (status, size) = match was_loaded { + Some(size) => ("cached", size), + None => ( + "charged", + loaded.get(id).map(|pin| pin.total_size()).unwrap_or(0), + ), + }; + record_runner_load(supervisor, id, size, status).await; + Ok(id) } /// Core of [`register_runner_load`], parameterized by the weak registry for @@ -725,7 +763,7 @@ impl Ctx<'_, '_> { // `custom:` dependency resolves against this VM's loaded set only — // a granted `custom:` runner cannot pull in a non-granted one (ADR-012 §4). let id = resolved.id; - let is_det = self.vm.config_copy.is_deterministic; + let is_det = self.vm.config_copy.permissions.deterministic; let data = self.vm.store.data_mut(); let limiter = data.limits.clone(); let crate::wasi::genlayer_sdk::Context { loaded, data, .. } = @@ -940,7 +978,8 @@ impl Ctx<'_, '_> { .with_context(|| format!("linking wasm {path:?}")), &contexts ); - let module = module.into_gep(|x| x.get(self.vm.config_copy.is_deterministic)); + let module = + module.into_gep(|x| x.get(self.vm.config_copy.permissions.deterministic)); let instance = { let instance = try_context!( @@ -1015,7 +1054,8 @@ impl Ctx<'_, '_> { &contexts ); - let module = module.into_gep(|x| x.get(self.vm.config_copy.is_deterministic)); + let module = + module.into_gep(|x| x.get(self.vm.config_copy.permissions.deterministic)); return Ok(Some(try_context!( self.vm @@ -1027,7 +1067,9 @@ impl Ctx<'_, '_> { ))); } InitAction::When { cond, action: next } => { - if (cond == runners::WasmMode::Det) == self.vm.config_copy.is_deterministic { + if (cond == runners::WasmMode::Det) + == self.vm.config_copy.permissions.deterministic + { stack.push(Work::Action(*next)); } } diff --git a/executor/src/rt/supervisor/mod.rs b/executor/src/rt/supervisor/mod.rs index a2affa68..2e2190b1 100644 --- a/executor/src/rt/supervisor/mod.rs +++ b/executor/src/rt/supervisor/mod.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashSet}, sync::{atomic::AtomicU32, Arc}, }; @@ -58,6 +58,46 @@ pub struct Ctor { pub locked_slots: host::LockedSlotsSet, pub leader_nondet_results: Option>, pub multi_host: host::MultiHost, + pub record_actions: Vec, +} + +#[derive(Clone, Debug, genlayer_calldata::Encode)] +pub struct RecordedAction { + pub kind: String, + pub fields: BTreeMap, +} + +pub struct ActionRecorder { + enabled: BTreeSet, + records: tokio::sync::Mutex>, +} + +impl ActionRecorder { + fn new(enabled: Vec) -> Self { + Self { + enabled: enabled.into_iter().collect(), + records: tokio::sync::Mutex::new(Vec::new()), + } + } + + pub fn is_enabled(&self, kind: &str) -> bool { + self.enabled.contains(kind) + } + + pub async fn record_or_log(&self, kind: &'static str, fields: BTreeMap) { + if self.is_enabled(kind) { + self.records.lock().await.push(RecordedAction { + kind: kind.to_owned(), + fields, + }); + } else { + log_debug!(kind = kind, fields:serde = fields; "auditable action"); + } + } + + pub async fn take(&self) -> Vec { + std::mem::take(&mut *self.records.lock().await) + } } pub struct Supervisor { @@ -83,6 +123,7 @@ pub struct Supervisor { pub(crate) engines: rt::DetNondet, pub(crate) host: Arc, + pub(crate) action_recorder: ActionRecorder, } #[cfg(debug_assertions)] @@ -119,7 +160,7 @@ pub fn create_engines( .consume_fuel(false) .cranelift_opt_level(wasmtime::OptLevel::None) .async_stack_size(8 << 20) - .max_wasm_stack(get_native_stack_size()) + .max_wasm_stack(get_native_stack_size() as usize) .wasm_stack_limits( public_abi_pending::wasm_stack_limits::CALL_DEPTH, public_abi_pending::wasm_stack_limits::VALUE_SLOTS, @@ -242,6 +283,10 @@ impl Supervisor { self.nondet_results.lock().await.clone() } + pub async fn take_recorded_actions(&self) -> Vec { + self.action_recorder.take().await + } + pub fn get_leader_nondet_result(&self, call_no: u32) -> Option { self.leader_nondet_results .as_ref() @@ -350,6 +395,7 @@ impl Supervisor { custom_runners: runners::cache::WeakCache::new(), host: Arc::new(ctor.multi_host), engines, + action_recorder: ActionRecorder::new(ctor.record_actions), }); let read_permit = zelf @@ -381,9 +427,23 @@ pub async fn spawn( }); } + zelf.action_recorder + .record_or_log( + "vm_spawn", + BTreeMap::from([ + ("spawn_kind".to_owned(), vm.spawn_kind.clone()), + ("depth".to_owned(), vm.depth.to_string()), + ( + "runner_id".to_owned(), + vm.conf.execution.topmost_runner_id.to_string(), + ), + ]), + ) + .await; + let config_copy = vm.conf.clone(); - let engine = zelf.engines.get(vm.conf.is_deterministic); + let engine = zelf.engines.get(vm.conf.permissions.deterministic); let mut store = wasmtime::Store::new( engine, @@ -429,7 +489,7 @@ pub async fn spawn( // The pins were carried in `SingleVMData`, so the content survives even if the // granting parent has died (queued nondet VMs). { - let is_det = vm_base.config_copy.is_deterministic; + let is_det = vm_base.config_copy.permissions.deterministic; let child_limiter = vm_base.store.data().limits.clone(); let store_data = vm_base.store.data_mut(); let wasi::genlayer_sdk::Context { loaded, data, .. } = @@ -437,6 +497,13 @@ pub async fn spawn( let grants = std::mem::take(&mut data.granted_custom); let mut det_fingerprint = is_det.then_some(&mut data.det_subvm_hashes); for grant in grants { + let runner_id = grant.runner_id(); + let size = grant.total_size(); + let status = if loaded.contains(runner_id) { + "cached" + } else { + "charged" + }; if let Err(e) = actions::inherit_load( &child_limiter, loaded, @@ -448,6 +515,16 @@ pub async fn spawn( state: Box::new(rt::SpawnErrorState::Spawned(vm_base)), }); } + zelf.action_recorder + .record_or_log( + "runner_load", + BTreeMap::from([ + ("runner_id".to_owned(), runner_id.as_str().to_owned()), + ("size".to_owned(), size.to_string()), + ("status".to_owned(), status.to_owned()), + ]), + ) + .await; } } @@ -481,7 +558,7 @@ async fn apply_contract_actions_inner( ) -> anyhow::Result { let data = &mut vm.vm_base.store.data_mut().genlayer_ctx.genlayer_sdk.data; - let topmost_runner_id = data.conf.topmost_runner_id.clone(); + let topmost_runner_id = data.conf.execution.topmost_runner_id.clone(); let contract_major = data .storage .read_major() @@ -496,13 +573,13 @@ async fn apply_contract_actions_inner( .into()); } - let topmost_runner_id = data.conf.topmost_runner_id.clone(); + let topmost_runner_id = data.conf.execution.topmost_runner_id.clone(); // Main-runner load action (ADR-012 §1). Runs *after* the spawn-time inherit // loads, so a custom entry point granted to this VM is already loaded (free // here, not double charged). Charges this VM's own limiter. let arch = { - let is_det = vm.vm_base.config_copy.is_deterministic; + let is_det = vm.vm_base.config_copy.permissions.deterministic; let store_data = vm.vm_base.store.data_mut(); let wasi::genlayer_sdk::Context { loaded, data, .. } = &mut store_data.genlayer_ctx.genlayer_sdk; diff --git a/executor/src/wasi/base.rs b/executor/src/wasi/base.rs index 5d065ea7..ae5df025 100644 --- a/executor/src/wasi/base.rs +++ b/executor/src/wasi/base.rs @@ -3,22 +3,31 @@ use serde::Serialize; use crate::{public_abi, runners}; #[derive(Clone, Serialize, genlayer_calldata::Encode)] -pub struct Config { - pub needs_error_fingerprint: bool, - pub is_deterministic: bool, - pub can_read_storage: bool, - pub can_write_storage: bool, - pub can_spawn_nondet: bool, - pub can_send_messages: bool, - pub can_call_others: bool, - pub can_register_runners: bool, +pub struct Permissions { + pub deterministic: bool, + pub write_storage: bool, + pub send_messages: bool, + pub call_others: bool, + pub spawn_nondet: bool, + pub register_runners: bool, pub can_use_balance_for_message_fees: bool, +} + +#[derive(Clone, Serialize, genlayer_calldata::Encode)] +pub struct Execution { pub state_mode: public_abi::StorageType, pub topmost_runner_id: runners::Id, } +#[derive(Clone, Serialize, genlayer_calldata::Encode)] +pub struct Config { + pub needs_error_fingerprint: bool, + pub permissions: Permissions, + pub execution: Execution, +} + impl Config { pub fn is_main(&self) -> bool { - self.state_mode == public_abi::StorageType::Default + self.execution.state_mode == public_abi::StorageType::Default } } diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs index a9787531..2345ae75 100644 --- a/executor/src/wasi/genlayer_sdk.rs +++ b/executor/src/wasi/genlayer_sdk.rs @@ -345,6 +345,7 @@ impl ExtendedMessageExt for ExtendedMessage { contract_address: self.message.contract_address, sender_address: self.message.sender_address, origin_address: self.message.origin_address, + signer_address: self.message.signer_address, stack: self.message.stack.clone(), chain_id: self.message.chain_id.clone(), value: self.message.value.clone(), @@ -417,6 +418,7 @@ impl VMDataAccumulator { pub struct SingleVMData { pub conf: base::Config, pub depth: u32, + pub spawn_kind: String, pub message_data: ExtendedMessage, pub supervisor: Arc, pub storage: rt::vm::storage::Storage, @@ -562,8 +564,8 @@ impl Context { // non-`Default` state_mode reads bypass the cache, so it must not be able to // write (otherwise its writes would hit the cache but never be read back). debug_assert!( - data.conf.state_mode == public_abi::StorageType::Default - || !data.conf.can_write_storage, + data.conf.execution.state_mode == public_abi::StorageType::Default + || !data.conf.permissions.write_storage, "a VM with state_mode != Default must not have can_write_storage" ); @@ -779,10 +781,10 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { calldata, value, } => { - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } - if !self.context.data.conf.can_send_messages { + if !self.context.data.conf.permissions.send_messages { return Err(generated::types::Errno::Forbidden.into()); } @@ -867,10 +869,10 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { Ok(file_fd_none()) } gl_call::Message::EthCall { address, calldata } => { - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } - if !self.context.data.conf.can_call_others { + if !self.context.data.conf.permissions.call_others { return Err(generated::types::Errno::Forbidden.into()); } @@ -888,17 +890,13 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { calldata, mut state, } => { - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } - if !self.context.data.conf.can_call_others { + if !self.context.data.conf.permissions.call_others { return Err(generated::types::Errno::Forbidden.into()); } - if state == public_abi::StorageType::Default { - state = public_abi::StorageType::LatestNonFinal; - } - let supervisor = self.context.data.supervisor.clone(); let my_conf = self.context.data.conf.clone(); @@ -912,7 +910,9 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { .fork(public_abi::EntryKind::Main, calldata_encoded.into()); my_data.message.stack.push(my_data.message.contract_address); - let calldata_encoded = calldata::encode_obj(&calldata); + if state == public_abi::StorageType::Default { + state = my_conf.execution.state_mode; + } let mut child_storage = rt::vm::storage::Storage::new( address, @@ -935,26 +935,30 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { let vm_data = Box::new(SingleVMData { depth: self.context.data.depth + 1, - // Permission model: doc/website/src/spec/03-vm/05-permissions.rst + spawn_kind: "call_contract".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst conf: base::Config { needs_error_fingerprint: true, - is_deterministic: true, - can_read_storage: my_conf.can_read_storage, - can_write_storage: false, - can_spawn_nondet: my_conf.can_spawn_nondet, - can_call_others: my_conf.can_call_others, - can_send_messages: false, - can_register_runners: my_conf.can_register_runners, - can_use_balance_for_message_fees: false, - state_mode: state, - topmost_runner_id: runners::Id::Chain { - address, - on: if state == public_abi::StorageType::LatestFinal { - runners::ChainState::Finalized - } else { - runners::ChainState::Accepted + permissions: base::Permissions { + deterministic: true, + write_storage: false, + spawn_nondet: false, + call_others: my_conf.permissions.call_others, + send_messages: false, + register_runners: my_conf.permissions.register_runners, + can_use_balance_for_message_fees: false, + }, + execution: base::Execution { + state_mode: state, + topmost_runner_id: runners::Id::Chain { + address, + on: if state == public_abi::StorageType::LatestFinal { + runners::ChainState::Finalized + } else { + runners::ChainState::Accepted + }, + slot: code_slot, }, - slot: code_slot, }, }, message_data: ExtendedMessage { @@ -962,6 +966,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { contract_address: address, sender_address: my_data.message.sender_address, origin_address: my_data.message.origin_address, + signer_address: my_data.message.signer_address, value: num_bigint::BigInt::ZERO, is_init: false, datetime: my_data.message.datetime, @@ -976,11 +981,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { supervisor: supervisor.clone(), accumulator: VMDataAccumulator { data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), - messages_value_decremented: self - .context - .data - .accumulator - .messages_value_decremented, + messages_value_decremented: primitive_types::U256::zero(), emissions: Vec::new(), message_fee_allocation: Vec::new(), }, @@ -1007,7 +1008,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { self.set_vm_run_result(res.run_ok).map(|x| x.0) } gl_call::Message::EmitEvent { topics, blob } => { - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { log_warn!("EmitEvent requires deterministic mode"); return Err(generated::types::Errno::Forbidden.into()); @@ -1016,7 +1017,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { // same write capability as storage. A read-only sub-VM (e.g. a // `CallContract` child) must not emit events; otherwise the // emission is charged but later discarded with the child. - if !self.context.data.conf.can_write_storage { + if !self.context.data.conf.permissions.write_storage { log_warn!("EmitEvent requires write_storage permission"); return Err(generated::types::Errno::Forbidden.into()); @@ -1093,17 +1094,21 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { use_balance = use_balance; "PostMessage dispatched" ); - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { log_debug!("PostMessage rejected: non-deterministic context (Forbidden)"); return Err(generated::types::Errno::Forbidden.into()); } - if !self.context.data.conf.can_send_messages { + if !self.context.data.conf.permissions.send_messages { log_debug!("PostMessage rejected: can_send_messages=false (Forbidden)"); return Err(generated::types::Errno::Forbidden.into()); } let balance_params = validate_balance_fee( - self.context.data.conf.can_use_balance_for_message_fees, + self.context + .data + .conf + .permissions + .can_use_balance_for_message_fees, use_balance, fee_params, )?; @@ -1286,15 +1291,19 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { use_balance, fee_params, } => { - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } - if !self.context.data.conf.can_send_messages { + if !self.context.data.conf.permissions.send_messages { return Err(generated::types::Errno::Forbidden.into()); } let balance_params = validate_balance_fee( - self.context.data.conf.can_use_balance_for_message_fees, + self.context + .data + .conf + .permissions + .can_use_balance_for_message_fees, use_balance, fee_params, )?; @@ -1444,7 +1453,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { Ok(file_fd_none()) } gl_call::Message::WebRender(render_payload) => { - let is_det = self.context.data.conf.is_deterministic; + let is_det = self.context.data.conf.permissions.deterministic; if is_det { return Err(generated::types::Errno::Forbidden.into()); } @@ -1482,7 +1491,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) } gl_call::Message::WebRequest(request_payload) => { - let is_det = self.context.data.conf.is_deterministic; + let is_det = self.context.data.conf.permissions.deterministic; if is_det { return Err(generated::types::Errno::Forbidden.into()); } @@ -1520,7 +1529,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) } gl_call::Message::ExecPrompt(prompt_payload) => { - if self.context.data.conf.is_deterministic { + if self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } @@ -1583,7 +1592,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) } gl_call::Message::ExecPromptTemplate(prompt_template_payload) => { - if self.context.data.conf.is_deterministic { + if self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } @@ -1715,10 +1724,6 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { ) -> Result<(), generated::types::Error> { let buf = buf.as_array(buf_len); - if !self.context.data.conf.can_read_storage { - return Err(generated::types::Errno::Forbidden.into()); - } - if index.checked_add(buf_len).is_none() { return Err(generated::types::Errno::Inval.into()); } @@ -1738,7 +1743,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { (true, vec_buf.as_mut_slice()) }; - if self.context.data.conf.state_mode == public_abi::StorageType::Default { + if self.context.data.conf.execution.state_mode == public_abi::StorageType::Default { self.context .data .storage @@ -1752,7 +1757,13 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { .host .lock_for(host::host_fns::Methods::StorageRead) .await - .storage_read(self.context.data.conf.state_mode, account, slot, index, vec) + .storage_read( + self.context.data.conf.execution.state_mode, + account, + slot, + index, + vec, + ) .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; } @@ -1773,10 +1784,10 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { ) -> Result<(), generated::types::Error> { let buf = buf.as_array(buf_len); - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } - if !self.context.data.conf.can_write_storage { + if !self.context.data.conf.permissions.write_storage { return Err(generated::types::Errno::Forbidden.into()); } @@ -1820,7 +1831,7 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { mem: &mut wiggle::GuestMemory<'_>, result: wiggle::GuestPtr, ) -> Result<(), generated::types::Error> { - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } @@ -1924,7 +1935,7 @@ impl ContextVFS<'_> { Ok(file_fd_none()) } gl_call::TracePayload::RuntimeMicroSec => { - let elapsed_micros = if self.context.data.conf.is_deterministic + let elapsed_micros = if self.context.data.conf.permissions.deterministic && !self .context .data @@ -1950,7 +1961,7 @@ impl ContextVFS<'_> { async fn gl_call_get_timestamp( &mut self, ) -> Result { - let timestamp = if self.context.data.conf.is_deterministic { + let timestamp = if self.context.data.conf.permissions.deterministic { self.context.data.message_data.message.datetime.timestamp() } else { chrono::Utc::now().timestamp() @@ -1966,10 +1977,10 @@ impl ContextVFS<'_> { &mut self, code: bytes::Bytes, ) -> Result { - if !self.context.data.conf.is_deterministic { + if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } - if !self.context.data.conf.can_register_runners { + if !self.context.data.conf.permissions.register_runners { return Err(generated::types::Errno::Forbidden.into()); } @@ -2001,19 +2012,12 @@ impl ContextVFS<'_> { path_in_runner: String, path_in_vfs: String, ) -> Result { - // Resolving a `chain:` runner reads another contract's storage, so this - // is gated on the same permission as `storage_read` to avoid becoming a - // read-storage bypass. - if !self.context.data.conf.can_read_storage { - return Err(generated::types::Errno::Forbidden.into()); - } - let supervisor = self.context.data.supervisor.clone(); - let is_det = self.context.data.conf.is_deterministic; + let is_det = self.context.data.conf.permissions.deterministic; // Charge the VM's own limiter (refunded at VM death, coinciding with the // pin drop), not the long-lived root limiter. let limiter = self.context.limiter.clone(); - let topmost_runner_id = self.context.data.conf.topmost_runner_id.clone(); + let topmost_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); let runner = rt::supervisor::actions::resolve_runner_id(&supervisor, &topmost_runner_id, &runner) @@ -2048,7 +2052,7 @@ impl ContextVFS<'_> { runner: Option, custom_runners: Option>, ) -> Result { - if !self.context.data.conf.can_spawn_nondet { + if !self.context.data.conf.permissions.spawn_nondet { return Err(generated::types::Errno::Forbidden.into()); } @@ -2056,7 +2060,7 @@ impl ContextVFS<'_> { // in this (deterministic parent) scope, so malformed inputs fail // deterministically at gl_call time (ADR-012 §4). let supervisor = self.context.data.supervisor.clone(); - let parent_runner_id = self.context.data.conf.topmost_runner_id.clone(); + let parent_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); let child_topmost_id = match &runner { None => parent_runner_id.clone(), Some(r) => { @@ -2209,19 +2213,23 @@ impl ContextVFS<'_> { let vm_data = Box::new(SingleVMData { depth: self.context.data.depth + 1, - // Permission model: doc/website/src/spec/03-vm/05-permissions.rst + spawn_kind: "run_nondet".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst conf: base::Config { needs_error_fingerprint: false, - is_deterministic: false, - can_read_storage: self.context.data.conf.can_read_storage, - can_write_storage: false, - can_spawn_nondet: false, - can_call_others: false, - can_send_messages: false, - can_register_runners: false, - can_use_balance_for_message_fees: false, - state_mode: public_abi::StorageType::Default, - topmost_runner_id: child_topmost_id, + permissions: base::Permissions { + deterministic: false, + write_storage: false, + send_messages: false, + call_others: false, + spawn_nondet: false, + register_runners: false, + can_use_balance_for_message_fees: false, + }, + execution: base::Execution { + state_mode: public_abi::StorageType::Default, + topmost_runner_id: child_topmost_id, + }, }, message_data, supervisor: supervisor.clone(), @@ -2285,7 +2293,7 @@ impl ContextVFS<'_> { ) -> Result { let supervisor = self.context.data.supervisor.clone(); - let parent_runner_id = self.context.data.conf.topmost_runner_id.clone(); + let parent_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); let topmost_runner_id = rt::supervisor::actions::resolve_runner_id(&supervisor, &parent_runner_id, &runner) .await @@ -2325,19 +2333,27 @@ impl ContextVFS<'_> { let vm_data = Box::new(SingleVMData { depth: self.context.data.depth + 1, - // Permission model: doc/website/src/spec/03-vm/05-permissions.rst + spawn_kind: "sandbox".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst conf: base::Config { needs_error_fingerprint: false, - is_deterministic: zelf_conf.is_deterministic, - can_read_storage: zelf_conf.can_read_storage, - can_write_storage: zelf_conf.can_write_storage & allow_write_storage, - can_spawn_nondet: false, - can_call_others: false, - can_send_messages: zelf_conf.can_send_messages & allow_send_messages, - can_register_runners: zelf_conf.can_register_runners & allow_register_runners, - can_use_balance_for_message_fees: zelf_conf.can_use_balance_for_message_fees, - state_mode: zelf_conf.state_mode, - topmost_runner_id, + permissions: base::Permissions { + deterministic: zelf_conf.permissions.deterministic, + write_storage: zelf_conf.permissions.write_storage & allow_write_storage, + send_messages: zelf_conf.permissions.send_messages & allow_send_messages, + call_others: false, + spawn_nondet: false, + register_runners: zelf_conf.permissions.register_runners + & allow_register_runners, + can_use_balance_for_message_fees: zelf_conf + .permissions + .can_use_balance_for_message_fees + & allow_send_messages, + }, + execution: base::Execution { + state_mode: zelf_conf.execution.state_mode, + topmost_runner_id, + }, }, message_data, supervisor: supervisor.clone(), @@ -2351,7 +2367,7 @@ impl ContextVFS<'_> { .await .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - if self.context.data.conf.is_deterministic { + if self.context.data.conf.permissions.deterministic { let hash = my_res.small_hash(); self.context.data.det_subvm_hashes.update(&hash); } diff --git a/executor/src/wasi/preview1.rs b/executor/src/wasi/preview1.rs index 634013d4..6f2e5d9a 100644 --- a/executor/src/wasi/preview1.rs +++ b/executor/src/wasi/preview1.rs @@ -1222,7 +1222,7 @@ impl generated::wasi_snapshot_preview1::WasiSnapshotPreview1 for ContextVFS<'_> let mut mem: Vec = std::iter::repeat_n(0, buf_len as usize).collect(); - if self.context.conf.is_deterministic { + if self.context.conf.permissions.deterministic { use rand_core::RngCore as _; self.context.mt19937_rng.fill_bytes(&mut mem[..]); diff --git a/runners/genlayer-py-std/src/genlayer/message.py b/runners/genlayer-py-std/src/genlayer/message.py index fcdaf2aa..a785ca0d 100644 --- a/runners/genlayer-py-std/src/genlayer/message.py +++ b/runners/genlayer-py-std/src/genlayer/message.py @@ -4,6 +4,7 @@ 'contract_address', 'sender_address', 'origin_address', + 'signer_address', 'stack', 'value', 'chain_id', @@ -37,6 +38,11 @@ class MessageRawType(typing.TypedDict): Entire transaction initiator """ + signer_address: Address + """ + Externally-owned account that signed the transaction + """ + stack: list[Address] """ Stack of view method calls, excluding last (``contract_address``) @@ -81,6 +87,9 @@ class MessageRawType(typing.TypedDict): origin_address: Address = ... # type: ignore """Entire transaction initiator""" +signer_address: Address = ... # type: ignore +"""Externally-owned account that signed the transaction""" + stack: list[Address] = ... # type: ignore """Stack of view method calls, excluding last (``contract_address``)""" diff --git a/runners/support/versions/current.nix b/runners/support/versions/current.nix index 49957a54..8c4a8ed6 100644 --- a/runners/support/versions/current.nix +++ b/runners/support/versions/current.nix @@ -49,7 +49,7 @@ let }; genlayer-std = { - hash = "sha256-x4U4R5V/KgdDj5GW5l8gZq3id+51GR2O/1PiodyTlj0="; + hash = "test"; depends = [ top ]; diff --git a/runners/support/versions/dev-mode.nix b/runners/support/versions/dev-mode.nix index c508d536..27ba77dd 100644 --- a/runners/support/versions/dev-mode.nix +++ b/runners/support/versions/dev-mode.nix @@ -1 +1 @@ -false +true diff --git a/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet index e8c11679..72ccffde 100644 --- a/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet +++ b/tests/integration/runner/deep_dependency_sandbox/deep_dependency_sandbox.jsonnet @@ -3,4 +3,4 @@ local util = import 'templates/util.jsonnet'; // Regression for action-application stack use: a sandbox VM resolves a custom // runner whose init action depends on a long chain of custom runners. {tags: util.features([['runner', 'custom'], ['runner', 'dependency'], ['sandbox']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'rwscnu'}])} + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'wscn'}])} diff --git a/tests/integration/runner/map_no_read_perm/map_no_read_perm.0.stdout b/tests/integration/runner/map_no_read_perm/map_no_read_perm.0.stdout index e26bb8a0..7580dc37 100644 --- a/tests/integration/runner/map_no_read_perm/map_no_read_perm.0.stdout +++ b/tests/integration/runner/map_no_read_perm/map_no_read_perm.0.stdout @@ -1,2 +1,2 @@ -forbidden: SystemError +mapped executed with `Return(null)` diff --git a/tests/integration/runner/map_no_read_perm/map_no_read_perm.py b/tests/integration/runner/map_no_read_perm/map_no_read_perm.py index 17737d5f..e9123b30 100644 --- a/tests/integration/runner/map_no_read_perm/map_no_read_perm.py +++ b/tests/integration/runner/map_no_read_perm/map_no_read_perm.py @@ -1,7 +1,7 @@ # { "Depends": "py-genlayer:test" } from genlayer.vm import map_file -# permissions for this case omit `r` (read_storage), so map_file must be refused +# The storage-read permission was removed; map_file works without an `r` grant. try: map_file('contract', 'file', '/mapped') print('mapped') diff --git a/tests/integration/runner/map_runner_file/map_runner_file.jsonnet b/tests/integration/runner/map_runner_file/map_runner_file.jsonnet index 2b925996..fb982a8d 100644 --- a/tests/integration/runner/map_runner_file/map_runner_file.jsonnet +++ b/tests/integration/runner/map_runner_file/map_runner_file.jsonnet @@ -1,4 +1,4 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'register']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'rwscnu'}])} + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'wscn'}])} diff --git a/tests/integration/runner/no_flowback/no_flowback.jsonnet b/tests/integration/runner/no_flowback/no_flowback.jsonnet index 09916da3..5c19daeb 100644 --- a/tests/integration/runner/no_flowback/no_flowback.jsonnet +++ b/tests/integration/runner/no_flowback/no_flowback.jsonnet @@ -7,7 +7,7 @@ local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'custom'], ['sandbox']], 'stable'), entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { stable_hash: false, - permissions: 'rwscnu', + permissions: 'wscn', runner_load_asserts: [ {runner_prefix: 'custom:', match: {status: 'charged'}, count: 1}, ], diff --git a/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.jsonnet b/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.jsonnet index 00d9b1e5..4ab684e8 100644 --- a/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.jsonnet +++ b/tests/integration/runner/nondet_custom_runner/nondet_custom_runner.jsonnet @@ -1,4 +1,4 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'custom'], ['sandbox'], ['nondet']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {permissions: 'rwscnu'}])} + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {permissions: 'wscn'}])} diff --git a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet index bda6530f..2e6fe50c 100644 --- a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet +++ b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.jsonnet @@ -7,7 +7,7 @@ local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'custom'], ['sandbox'], ['nondet']], 'stable'), entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { stable_hash: false, - permissions: 'rwscnu', + permissions: 'wscn', runner_load_asserts: [ {runner_prefix: 'custom:', match: {status: 'charged'}, min: 2}, ], diff --git a/tests/integration/runner/register-runner/register-runner.jsonnet b/tests/integration/runner/register-runner/register-runner.jsonnet index 2b925996..fb982a8d 100644 --- a/tests/integration/runner/register-runner/register-runner.jsonnet +++ b/tests/integration/runner/register-runner/register-runner.jsonnet @@ -1,4 +1,4 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'register']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'rwscnu'}])} + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'wscn'}])} diff --git a/tests/integration/runner/register_ladder/register_ladder.jsonnet b/tests/integration/runner/register_ladder/register_ladder.jsonnet index 9035c1d4..3cfdbe95 100644 --- a/tests/integration/runner/register_ladder/register_ladder.jsonnet +++ b/tests/integration/runner/register_ladder/register_ladder.jsonnet @@ -6,7 +6,7 @@ local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'register']], 'stable'), entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { stable_hash: false, - permissions: 'rwscnu', + permissions: 'wscn', runner_load_asserts: [ {runner_prefix: 'custom:', match: {status: 'charged'}, count: 1}, {runner_prefix: 'custom:', match: {status: 'cached'}, count: 1}, diff --git a/tests/integration/runner/register_loop/register_loop.jsonnet b/tests/integration/runner/register_loop/register_loop.jsonnet index 2b925996..fb982a8d 100644 --- a/tests/integration/runner/register_loop/register_loop.jsonnet +++ b/tests/integration/runner/register_loop/register_loop.jsonnet @@ -1,4 +1,4 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'register']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'rwscnu'}])} + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'wscn'}])} diff --git a/tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet b/tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet index 3e93a7e6..eed0fd51 100644 --- a/tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet +++ b/tests/integration/runner/register_malformed_charge/register_malformed_charge.jsonnet @@ -7,7 +7,7 @@ local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'register']], 'stable'), entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') { stable_hash: false, - permissions: 'rwscnu', + permissions: 'wscn', runner_load_asserts: [ {runner_prefix: 'custom:', match: {status: 'charged'}, count: 1}, {runner_prefix: 'custom:', match: {status: 'cached'}, count: 0}, diff --git a/tests/integration/runner/register_no_perm/register_no_perm.0.stdout b/tests/integration/runner/register_no_perm/register_no_perm.0.stdout index 31d88ed5..ebbc44ab 100644 --- a/tests/integration/runner/register_no_perm/register_no_perm.0.stdout +++ b/tests/integration/runner/register_no_perm/register_no_perm.0.stdout @@ -1,2 +1,2 @@ -6: forbidden +registered executed with `Return(null)` diff --git a/tests/integration/runner/register_no_perm/register_no_perm.py b/tests/integration/runner/register_no_perm/register_no_perm.py index 8e3d1cd6..e13f86cb 100644 --- a/tests/integration/runner/register_no_perm/register_no_perm.py +++ b/tests/integration/runner/register_no_perm/register_no_perm.py @@ -1,7 +1,7 @@ # { "Depends": "py-genlayer:test" } from genlayer.vm import register_runner -# default permissions (rwscn) do NOT grant `register_runners` (u) +# register_runner is granted to the initial VM without a top-level permission flag. try: register_runner(b'# { "Depends": "py-genlayer:test" }\nx\n') print('registered') diff --git a/tests/integration/runner/register_nondet/register_nondet.jsonnet b/tests/integration/runner/register_nondet/register_nondet.jsonnet index 92dacf2a..2d5aeb45 100644 --- a/tests/integration/runner/register_nondet/register_nondet.jsonnet +++ b/tests/integration/runner/register_nondet/register_nondet.jsonnet @@ -1,4 +1,4 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'register'], ['nasty-determinism'], ['nondet']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'rwscnu'}])} + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'wscn'}])} diff --git a/tests/integration/runner/register_nondet/register_nondet.py b/tests/integration/runner/register_nondet/register_nondet.py index 3a1afbe2..1703a38c 100644 --- a/tests/integration/runner/register_nondet/register_nondet.py +++ b/tests/integration/runner/register_nondet/register_nondet.py @@ -7,7 +7,7 @@ class Contract(gl.contract.Contract): def __init__(self): def leader(): # register_runner is deterministic-only; calling it from inside a - # nondet block must be refused even though `u` (register) is granted, + # nondet block must be refused even though register_runner is granted, # i.e. the rejection comes from the is_deterministic gate, not perms. try: register_runner(b'# { "Depends": "py-genlayer:test" }\nx\n') diff --git a/tests/integration/runner/sandbox_custom_runner/sandbox_custom_runner.jsonnet b/tests/integration/runner/sandbox_custom_runner/sandbox_custom_runner.jsonnet index e3127146..9d6f441c 100644 --- a/tests/integration/runner/sandbox_custom_runner/sandbox_custom_runner.jsonnet +++ b/tests/integration/runner/sandbox_custom_runner/sandbox_custom_runner.jsonnet @@ -1,4 +1,4 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['runner', 'custom'], ['sandbox']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'rwscnu'}])} + entry: util.addPaths([simple.run('${jsonnetDir}/${fileBaseName}.py') {stable_hash: false, permissions: 'wscn'}])} From ed816e68758c421e3a103b313c25d9039b180b57 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 16:53:18 +0900 Subject: [PATCH 16/25] chore: refactor --- executor/src/wasi/genlayer_sdk.rs | 2047 +++++++++-------- .../runner/no_flowback/no_flowback.0.stdout | 2 + .../runner/no_flowback/no_flowback.skip | 10 - .../nondet_inherit_custom.0.stdout | 2 + .../nondet_inherit_custom.skip | 11 - 5 files changed, 1069 insertions(+), 1003 deletions(-) create mode 100644 tests/integration/runner/no_flowback/no_flowback.0.stdout delete mode 100644 tests/integration/runner/no_flowback/no_flowback.skip create mode 100644 tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.0.stdout delete mode 100644 tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs index 2345ae75..651e4435 100644 --- a/executor/src/wasi/genlayer_sdk.rs +++ b/executor/src/wasi/genlayer_sdk.rs @@ -780,1128 +780,1211 @@ impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { address, calldata, value, + } => self.gl_call_eth_send(address, calldata, value).await, + gl_call::Message::EthCall { address, calldata } => { + self.gl_call_eth_call(address, calldata).await + } + gl_call::Message::CallContract { + address, + calldata, + state, + } => self.gl_call_contract(address, calldata, state).await, + gl_call::Message::EmitEvent { topics, blob } => { + self.gl_call_emit_event(topics, blob).await + } + gl_call::Message::PostMessage { + address, + calldata, + value, + on, + use_balance, + fee_params, } => { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.send_messages { - return Err(generated::types::Errno::Forbidden.into()); - } + self.gl_call_post_message(address, calldata, value, on, use_balance, fee_params) + .await + } + gl_call::Message::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + use_balance, + fee_params, + } => { + self.gl_call_deploy_contract( + calldata, + code, + value, + on, + salt_nonce, + use_balance, + fee_params, + ) + .await + } + gl_call::Message::WebRender(render_payload) => { + self.gl_call_web_render(render_payload).await + } + gl_call::Message::WebRequest(request_payload) => { + self.gl_call_web_request(request_payload).await + } + gl_call::Message::ExecPrompt(prompt_payload) => { + self.gl_call_exec_prompt(prompt_payload).await + } + gl_call::Message::ExecPromptTemplate(prompt_template_payload) => { + self.gl_call_exec_prompt_template(prompt_template_payload) + .await + } + gl_call::Message::UserError(msg) => Err(generated::types::Error::trap( + crate::anyhow_to_wasmtime(rt::errors::Error::user(msg).into()), + )), + gl_call::Message::Return(value) => Err(generated::types::Error::trap( + crate::anyhow_to_wasmtime(ContractReturn(value).into()), + )), + gl_call::Message::RunNondet { + data_leader, + data_validator, + runner, + custom_runners, + } => { + self.run_nondet(data_leader, data_validator, runner, custom_runners) + .await + } + gl_call::Message::Sandbox { + data, + runner, + allow_write_storage, + allow_send_messages, + allow_register_runners, + custom_runners, + } => { + self.sandbox( + data, + runner, + allow_write_storage, + allow_send_messages, + allow_register_runners, + custom_runners, + ) + .await + } + gl_call::Message::RegisterRunner { code } => self.register_runner(code).await, + gl_call::Message::MapFile { + runner, + path_in_runner, + path_in_vfs, + } => self.map_file(runner, path_in_runner, path_in_vfs).await, + gl_call::Message::Trace(message) => self.gl_call_trace(message).await, + gl_call::Message::Yield => Ok(file_fd_none()), + gl_call::Message::GetTimestamp => self.gl_call_get_timestamp().await, + } + } - if !value.is_zero() { - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - - if !checked_sum_le( - value, - self.context.data.accumulator.messages_value_decremented, - my_balance, - ) { - return Err(generated::types::Errno::Inbalance.into()); - } - } + async fn storage_read( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + slot: wiggle::GuestPtr, + index: u32, + buf: wiggle::GuestPtr, + buf_len: u32, + ) -> Result<(), generated::types::Error> { + let buf = buf.as_array(buf_len); - let mut call_key = abi::CallKey([0u8; 32]); - if calldata.len() < 4 { - log_warn!(len = calldata.len(); "calldata too short for method selector, using unnamed call key"); - } else { - call_key.0[..4].copy_from_slice(&calldata[..4]); - } + if index.checked_add(buf_len).is_none() { + return Err(generated::types::Errno::Inval.into()); + } - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_external(address, call_key) - .map(|params| (node, params)) - }) - else { - log_warn!( - recipient = address, - call_key:? = call_key; - "no matching node for message fee allocation" - ); - - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().no_matching_node(), - host_fns::VmErrorDetail::external(), - ))); - }; + mem.bounds_check(buf)?; - let calldata_length = calldata.len() as u64; + let account = self.context.data.message_data.message.contract_address; - let fees = consume_message_fee_external( - &self.context.data.supervisor.shared_data, - matched_node, - matched_params, - gl_call::On::Finalized, - ConsumeExternalArgs { - is_deploy: false, - calldata_length, - }, + let slot = SlotID::read_from_mem(mem, slot)?; + let mem_size = buf_len as usize; + + let mut vec_buf = Vec::new(); + let (should_copy, vec) = if let Some(buf) = mem.as_slice_mut(buf)? { + (false, buf) + } else { + vec_buf.resize(mem_size, 0); + (true, vec_buf.as_mut_slice()) + }; + + if self.context.data.conf.execution.state_mode == public_abi::StorageType::Default { + self.context + .data + .storage + .read(slot, index, vec) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; + } else { + self.context + .data + .supervisor + .host + .lock_for(host::host_fns::Methods::StorageRead) + .await + .storage_read( + self.context.data.conf.execution.state_mode, + account, + slot, + index, + vec, ) - .await?; + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + } - self.context - .data - .accumulator - .emissions - .push(domain::ExecutionEmission::EthSend { - address, - calldata, - value, - message_fee: fees.message_fee.reported_fee(), - receipt_fee: fees.receipt_fee.reported_fee(), - fee_params: matched_params, - }); - - self.context.data.accumulator.messages_value_decremented = self - .context - .data - .accumulator - .messages_value_decremented - .saturating_add(value); - Ok(file_fd_none()) - } - gl_call::Message::EthCall { address, calldata } => { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.call_others { - return Err(generated::types::Errno::Forbidden.into()); - } + if should_copy { + mem.copy_from_slice(&vec_buf, buf)?; + } - let supervisor = self.context.data.supervisor.clone(); - let data = supervisor - .host - .lock_for(host::host_fns::Methods::EthCall) - .await - .eth_call(address, &calldata) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) - } - gl_call::Message::CallContract { - address, - calldata, - mut state, - } => { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.call_others { - return Err(generated::types::Errno::Forbidden.into()); - } + Ok(()) + } - let supervisor = self.context.data.supervisor.clone(); + async fn storage_write( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + slot: wiggle::GuestPtr, + index: u32, + buf: wiggle::GuestPtr, + buf_len: u32, + ) -> Result<(), generated::types::Error> { + let buf = buf.as_array(buf_len); - let my_conf = self.context.data.conf.clone(); + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.write_storage { + return Err(generated::types::Errno::Forbidden.into()); + } - let calldata_encoded = calldata::encode_obj(&calldata); + if index.checked_add(buf_len).is_none() { + return Err(generated::types::Errno::Inval.into()); + } - let mut my_data = self - .context - .data - .message_data - .fork(public_abi::EntryKind::Main, calldata_encoded.into()); - my_data.message.stack.push(my_data.message.contract_address); + mem.bounds_check(buf)?; - if state == public_abi::StorageType::Default { - state = my_conf.execution.state_mode; - } + let slot = SlotID::read_from_mem(mem, slot)?; - let mut child_storage = rt::vm::storage::Storage::new( - address, - supervisor.get_storage_limiter(), - StorageHostHolder( - supervisor.host.clone(), - ReadToken { - account: address, - mode: state, - }, - ), - ); + if self.context.data.supervisor.locked_slots.contains(slot) { + return Err(generated::types::Errno::Forbidden.into()); + } - let code_slot = child_storage - .check_major_and_resolve_code_slot() - .await - .map_err(|e| { - generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())) - })?; - - let vm_data = Box::new(SingleVMData { - depth: self.context.data.depth + 1, - spawn_kind: "call_contract".to_owned(), - // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst - conf: base::Config { - needs_error_fingerprint: true, - permissions: base::Permissions { - deterministic: true, - write_storage: false, - spawn_nondet: false, - call_others: my_conf.permissions.call_others, - send_messages: false, - register_runners: my_conf.permissions.register_runners, - can_use_balance_for_message_fees: false, - }, - execution: base::Execution { - state_mode: state, - topmost_runner_id: runners::Id::Chain { - address, - on: if state == public_abi::StorageType::LatestFinal { - runners::ChainState::Finalized - } else { - runners::ChainState::Accepted - }, - slot: code_slot, - }, - }, - }, - message_data: ExtendedMessage { - message: genlayer_sdk::abi::entry::MessageData { - contract_address: address, - sender_address: my_data.message.sender_address, - origin_address: my_data.message.origin_address, - signer_address: my_data.message.signer_address, - value: num_bigint::BigInt::ZERO, - is_init: false, - datetime: my_data.message.datetime, - chain_id: my_data.message.chain_id, - stack: my_data.message.stack, - }, - entry_kind: my_data.entry_kind, - entry_data: my_data.entry_data, - entry_stage_data: default_entry_stage_data(), - }, - storage: child_storage, - supervisor: supervisor.clone(), - accumulator: VMDataAccumulator { - data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), - messages_value_decremented: primitive_types::U256::zero(), - emissions: Vec::new(), - message_fee_allocation: Vec::new(), - }, - det_subvm_hashes: Default::default(), - // A CallContract child is granted the caller's full custom set - // (ADR-012 §4); its spawn load-actions each into the child. - granted_custom: self.context.loaded.custom_pins(), - }); + let ptr = mem.as_cow(buf)?; - let res = spawn_sub_vm(supervisor.clone(), vm_data) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + self.context + .data + .storage + .write(slot, index, &ptr) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into()))) + } - // The child is read-only (static), so its accumulator must be - // empty — otherwise an effect was charged but discarded here. - res.vm_data - .accumulator - .check_empty() - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + async fn get_balance( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + account: wiggle::GuestPtr, + result: wiggle::GuestPtr, + ) -> Result<(), generated::types::Error> { + let address = read_addr_from_mem(mem, account)?; - let hash = res.small_hash(); - self.context.data.det_subvm_hashes.update(&hash); + self.context + .get_balance_impl_wasi(mem, address, result, false) + .await + } - self.set_vm_run_result(res.run_ok).map(|x| x.0) - } - gl_call::Message::EmitEvent { topics, blob } => { - if !self.context.data.conf.permissions.deterministic { - log_warn!("EmitEvent requires deterministic mode"); + async fn get_self_balance( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + result: wiggle::GuestPtr, + ) -> Result<(), generated::types::Error> { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } - return Err(generated::types::Errno::Forbidden.into()); - } - // Events are state-mutating log emissions, so they require the - // same write capability as storage. A read-only sub-VM (e.g. a - // `CallContract` child) must not emit events; otherwise the - // emission is charged but later discarded with the child. - if !self.context.data.conf.permissions.write_storage { - log_warn!("EmitEvent requires write_storage permission"); - - return Err(generated::types::Errno::Forbidden.into()); - } + self.context + .get_balance_impl_wasi( + mem, + self.context.data.message_data.message.contract_address, + result, + true, + ) + .await + } +} - if topics.len() > public_abi::EVENT_MAX_TOPICS as usize { - log_warn!(cnt = topics.len(), max = public_abi::EVENT_MAX_TOPICS; "too many topics"); - return Err(generated::types::Errno::Inval.into()); - } +impl Context { + async fn get_balance_impl_wasi( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + address: calldata::Address, + result: wiggle::GuestPtr, + is_self: bool, + ) -> Result<(), generated::types::Error> { + let mut res = self.get_balance_impl(address).await?; - let mut real_topics: Vec = - Vec::with_capacity(public_abi::EVENT_MAX_TOPICS as usize + 1); + if is_self && self.data.conf.is_main() { + let messages_decremented = self.data.accumulator.messages_value_decremented; - for (i, t) in topics.iter().enumerate() { - if t.len() != 32 { - log_warn!(len = t.len(); "invalid topic length"); + res -= messages_decremented; + } - return Err(generated::types::Errno::Inval.into()); - } + let res = res.to_little_endian(); + mem.copy_from_slice(&res, result.as_array(32))?; - real_topics.push(t.clone()); - } + Ok(()) + } - struct CountingWriter(usize); - impl calldata::Writer for CountingWriter { - type Error = std::convert::Infallible; + pub async fn get_balance_impl( + &mut self, + address: calldata::Address, + ) -> Result { + if let Some(res) = self.data.supervisor.balances.get(&address) { + return Ok(*res); + } - fn write_all(&mut self, data: &[u8]) -> Result<(), Self::Error> { - self.0 += data.len(); - Ok(()) - } - } + let res = self + .data + .supervisor + .host + .lock_for(host::host_fns::Methods::GetBalance) + .await + .get_balance(address) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - let mut enc = calldata::Encoder::new(CountingWriter(0)); - blob.encode(&mut enc).unwrap_or_else(|e| match e {}); - let blob_size = enc.into_inner().0 as u64; + let _ = self.data.supervisor.balances.insert(address, res); - let supervisor = self.context.data.supervisor.clone(); - let topics_count = topics.len() as u64; + Ok(res) + } - let storage_fee = supervisor - .shared_data - .data_fees_limit - .consume_event(blob_size, topics_count) - .await - .map_err(internal_trap)? - .ok_or_else(|| { - internal_trap(rt::errors::Error::vm( - abi::consts::VmError::out_of().receipt().event(), - )) - })?; + pub fn log(&self) -> calldata::Value { + let msg = calldata::to_value(&self.data.message_data); + let conf = calldata::to_value(&self.data.conf); - self.context.data.accumulator.emissions.push( - domain::ExecutionEmission::EmitEvent { - topics: real_topics, - blob, - storage_fee, - }, - ); + calldata::Value::Map(BTreeMap::from([ + ("config".to_owned(), conf), + ("message".to_owned(), msg), + ])) + } +} - Ok(file_fd_none()) - } - gl_call::Message::PostMessage { - address, - calldata, +impl ContextVFS<'_> { + async fn gl_call_eth_send( + &mut self, + address: calldata::Address, + calldata: bytes::Bytes, + value: primitive_types::U256, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.send_messages { + return Err(generated::types::Errno::Forbidden.into()); + } + + if !value.is_zero() { + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + + if !checked_sum_le( value, - on, - use_balance, - fee_params, - } => { - log_debug!( - recipient = address, - on:? = on, - use_balance = use_balance; - "PostMessage dispatched" - ); - if !self.context.data.conf.permissions.deterministic { - log_debug!("PostMessage rejected: non-deterministic context (Forbidden)"); - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.send_messages { - log_debug!("PostMessage rejected: can_send_messages=false (Forbidden)"); - return Err(generated::types::Errno::Forbidden.into()); - } + self.context.data.accumulator.messages_value_decremented, + my_balance, + ) { + return Err(generated::types::Errno::Inbalance.into()); + } + } - let balance_params = validate_balance_fee( - self.context - .data - .conf - .permissions - .can_use_balance_for_message_fees, - use_balance, - fee_params, - )?; - - // Peek the (possibly deferred) calldata to derive the fee call key. - let calldata_materialized = calldata.clone().materialize().ok(); - let method_name = calldata_materialized - .as_ref() - .and_then(|x| x.as_map()) - .and_then(|x| x.get("")) - .and_then(|x| x.as_str()); - let call_key = if let Some(method_name) = method_name { - abi::CallKey::for_method(method_name) - } else { - abi::CallKey::UNNAMED - }; + let mut call_key = abi::CallKey([0u8; 32]); + if calldata.len() < 4 { + log_warn!(len = calldata.len(); "calldata too short for method selector, using unnamed call key"); + } else { + call_key.0[..4].copy_from_slice(&calldata[..4]); + } - if let Some(params) = balance_params { - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc) - .unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; + let Some((matched_node, matched_params)) = self + .context + .data + .accumulator + .message_fee_allocation + .iter_mut() + .find_map(|node| { + node.matches_external(address, call_key) + .map(|params| (node, params)) + }) + else { + log_warn!( + recipient = address, + call_key:? = call_key; + "no matching node for message fee allocation" + ); + + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::external(), + ))); + }; - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - let messages_value_decremented = - self.context.data.accumulator.messages_value_decremented; - - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Balance { - value, - messages_value_decremented, - my_balance, - }, - Arc::new(params.clone()), - on, - ConsumeInternalArgs { - is_deploy: false, - calldata_length, - code_length: 0, - subtree_length: 0, - }, - ) - .await?; - - let metered_fee = fees.message_fee.reported_fee(); - - // Empty subtree: use_balance nesting is fail-closed on-chain. - self.context.data.accumulator.emissions.push( - domain::ExecutionEmission::PostMessage { - call_key, - address, - calldata, - value, - on, - message_fee: metered_fee, - receipt_fee: fees.receipt_fee.reported_fee(), - fee_params: params, - subtree: bytes::Bytes::new(), - use_balance: true, - }, - ); + let calldata_length = calldata.len() as u64; - self.context.data.accumulator.messages_value_decremented = - messages_value_decremented - .saturating_add(value) - .saturating_add(metered_fee); + let fees = consume_message_fee_external( + &self.context.data.supervisor.shared_data, + matched_node, + matched_params, + gl_call::On::Finalized, + ConsumeExternalArgs { + is_deploy: false, + calldata_length, + }, + ) + .await?; - return Ok(file_fd_none()); - } + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::EthSend { + address, + calldata, + value, + message_fee: fees.message_fee.reported_fee(), + receipt_fee: fees.receipt_fee.reported_fee(), + fee_params: matched_params, + }); - if !value.is_zero() { - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - - if !checked_sum_le( - value, - self.context.data.accumulator.messages_value_decremented, - my_balance, - ) { - return Err(generated::types::Errno::Inbalance.into()); - } - } + self.context.data.accumulator.messages_value_decremented = self + .context + .data + .accumulator + .messages_value_decremented + .saturating_add(value); + Ok(file_fd_none()) + } + async fn gl_call_eth_call( + &mut self, + address: calldata::Address, + calldata: bytes::Bytes, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.call_others { + return Err(generated::types::Errno::Forbidden.into()); + } - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_internal(on, address, call_key) - .map(|params| (node, params)) - }) - else { - log_warn!( - recipient = address, - call_key:? = call_key, - on:? = on; - "no matching node for message fee allocation" - ); - - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().no_matching_node(), - host_fns::VmErrorDetail::internal(), - ))); - }; + let supervisor = self.context.data.supervisor.clone(); + let data = supervisor + .host + .lock_for(host::host_fns::Methods::EthCall) + .await + .eth_call(address, &calldata) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) + } + async fn gl_call_contract( + &mut self, + address: calldata::Address, + calldata: calldata::unparsed::Maybe, + mut state: public_abi::StorageType, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.call_others { + return Err(generated::types::Errno::Forbidden.into()); + } - log_debug!( - recipient = address, - call_key:? = call_key, - on:? = on; - "PostMessage matched fee allocation node" - ); + let supervisor = self.context.data.supervisor.clone(); - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; + let my_conf = self.context.data.conf.clone(); - let fee_params = (*matched_params).clone(); - let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - )); + let calldata_encoded = calldata::encode_obj(&calldata); - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Allocation(matched_node), - matched_params, - on, - ConsumeInternalArgs { - is_deploy: false, - calldata_length, - code_length: 0, - subtree_length: subtree.len() as u64, - }, - ) - .await?; + let mut my_data = self + .context + .data + .message_data + .fork(public_abi::EntryKind::Main, calldata_encoded.into()); + my_data.message.stack.push(my_data.message.contract_address); - self.context.data.accumulator.emissions.push( - domain::ExecutionEmission::PostMessage { - call_key, - address, - calldata, - value, - on, - message_fee: fees.message_fee.reported_fee(), - receipt_fee: fees.receipt_fee.reported_fee(), - fee_params, - subtree, - use_balance: false, - }, - ); + if state == public_abi::StorageType::Default { + state = my_conf.execution.state_mode; + } - log_debug!( - depth = self.context.data.depth, - emissions_total = self.context.data.accumulator.emissions.len(); - "PostMessage emission pushed to accumulator" - ); + let mut child_storage = rt::vm::storage::Storage::new( + address, + supervisor.get_storage_limiter(), + StorageHostHolder( + supervisor.host.clone(), + ReadToken { + account: address, + mode: state, + }, + ), + ); - self.context.data.accumulator.messages_value_decremented = self - .context - .data - .accumulator - .messages_value_decremented - .saturating_add(value); + let code_slot = child_storage + .check_major_and_resolve_code_slot() + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; - Ok(file_fd_none()) - } - gl_call::Message::DeployContract { - calldata, - code, - value, - on, - salt_nonce, - use_balance, - fee_params, - } => { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.send_messages { - return Err(generated::types::Errno::Forbidden.into()); - } + let vm_data = Box::new(SingleVMData { + depth: self.context.data.depth + 1, + spawn_kind: "call_contract".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst + conf: base::Config { + needs_error_fingerprint: true, + permissions: base::Permissions { + deterministic: true, + write_storage: false, + spawn_nondet: false, + call_others: my_conf.permissions.call_others, + send_messages: false, + register_runners: my_conf.permissions.register_runners, + can_use_balance_for_message_fees: false, + }, + execution: base::Execution { + state_mode: state, + topmost_runner_id: runners::Id::Chain { + address, + on: if state == public_abi::StorageType::LatestFinal { + runners::ChainState::Finalized + } else { + runners::ChainState::Accepted + }, + slot: code_slot, + }, + }, + }, + message_data: ExtendedMessage { + message: genlayer_sdk::abi::entry::MessageData { + contract_address: address, + sender_address: my_data.message.sender_address, + origin_address: my_data.message.origin_address, + signer_address: my_data.message.signer_address, + value: num_bigint::BigInt::ZERO, + is_init: false, + datetime: my_data.message.datetime, + chain_id: my_data.message.chain_id, + stack: my_data.message.stack, + }, + entry_kind: my_data.entry_kind, + entry_data: my_data.entry_data, + entry_stage_data: default_entry_stage_data(), + }, + storage: child_storage, + supervisor: supervisor.clone(), + accumulator: VMDataAccumulator { + data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), + messages_value_decremented: primitive_types::U256::zero(), + emissions: Vec::new(), + message_fee_allocation: Vec::new(), + }, + det_subvm_hashes: Default::default(), + // A CallContract child is granted the caller's full custom set + // (ADR-012 §4); its spawn load-actions each into the child. + granted_custom: self.context.loaded.custom_pins(), + }); - let balance_params = validate_balance_fee( - self.context - .data - .conf - .permissions - .can_use_balance_for_message_fees, - use_balance, - fee_params, - )?; + let res = spawn_sub_vm(supervisor.clone(), vm_data) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - if let Some(params) = balance_params { - let code_length = code.len() as u64; - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc) - .unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; + // The child is read-only (static), so its accumulator must be + // empty — otherwise an effect was charged but discarded here. + res.vm_data + .accumulator + .check_empty() + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - let messages_value_decremented = - self.context.data.accumulator.messages_value_decremented; - - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Balance { - value, - messages_value_decremented, - my_balance, - }, - Arc::new(params.clone()), - on, - ConsumeInternalArgs { - is_deploy: true, - calldata_length, - code_length, - subtree_length: 0, - }, - ) - .await?; - - let metered_fee = fees.message_fee.reported_fee(); - - self.context.data.accumulator.emissions.push( - domain::ExecutionEmission::DeployContract { - calldata, - code, - value, - on, - salt_nonce, - receipt_fee: fees.receipt_fee.reported_fee(), - message_fee: metered_fee, - fee_params: params, - subtree: bytes::Bytes::new(), - use_balance: true, - }, - ); + let hash = res.small_hash(); + self.context.data.det_subvm_hashes.update(&hash); - self.context.data.accumulator.messages_value_decremented = - messages_value_decremented - .saturating_add(value) - .saturating_add(metered_fee); + self.set_vm_run_result(res.run_ok).map(|x| x.0) + } + async fn gl_call_emit_event( + &mut self, + topics: Vec, + blob: calldata::unparsed::Maybe>, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + log_warn!("EmitEvent requires deterministic mode"); - return Ok(file_fd_none()); - } + return Err(generated::types::Errno::Forbidden.into()); + } + // Events are state-mutating log emissions, so they require the + // same write capability as storage. A read-only sub-VM (e.g. a + // `CallContract` child) must not emit events; otherwise the + // emission is charged but later discarded with the child. + if !self.context.data.conf.permissions.write_storage { + log_warn!("EmitEvent requires write_storage permission"); - if !value.is_zero() { - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - - if !checked_sum_le( - value, - self.context.data.accumulator.messages_value_decremented, - my_balance, - ) { - return Err(generated::types::Errno::Inbalance.into()); - } - } + return Err(generated::types::Errno::Forbidden.into()); + } - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_internal(on, calldata::Address::zero(), abi::CallKey::DEPLOY) - .map(|params| (node, params)) - }) - else { - log_warn!( - recipient = calldata::Address::zero(), - call_key:? = abi::CallKey::DEPLOY, - on:? = on; - "no matching node for message fee allocation" - ); - - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().no_matching_node(), - host_fns::VmErrorDetail::internal(), - ))); - }; + if topics.len() > public_abi::EVENT_MAX_TOPICS as usize { + log_warn!(cnt = topics.len(), max = public_abi::EVENT_MAX_TOPICS; "too many topics"); + return Err(generated::types::Errno::Inval.into()); + } - let code_length = code.len() as u64; - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; + let mut real_topics: Vec = + Vec::with_capacity(public_abi::EVENT_MAX_TOPICS as usize + 1); - let fee_params = (*matched_params).clone(); - let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - )); + for t in topics.iter() { + if t.len() != 32 { + log_warn!(len = t.len(); "invalid topic length"); - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Allocation(matched_node), - matched_params, - on, - ConsumeInternalArgs { - is_deploy: true, - calldata_length, - code_length, - subtree_length: subtree.len() as u64, - }, - ) - .await?; + return Err(generated::types::Errno::Inval.into()); + } - self.context.data.accumulator.emissions.push( - domain::ExecutionEmission::DeployContract { - calldata, - code, - value, - on, - salt_nonce, - receipt_fee: fees.receipt_fee.reported_fee(), - message_fee: fees.message_fee.reported_fee(), - fee_params, - subtree, - use_balance: false, - }, - ); + real_topics.push(t.clone()); + } - self.context.data.accumulator.messages_value_decremented = self - .context - .data - .accumulator - .messages_value_decremented - .saturating_add(value); + struct CountingWriter(usize); + impl calldata::Writer for CountingWriter { + type Error = std::convert::Infallible; - Ok(file_fd_none()) + fn write_all(&mut self, data: &[u8]) -> Result<(), Self::Error> { + self.0 += data.len(); + Ok(()) } - gl_call::Message::WebRender(render_payload) => { - let is_det = self.context.data.conf.permissions.deterministic; - if is_det { - return Err(generated::types::Errno::Forbidden.into()); - } + } - let space_left = self - .context - .data - .supervisor - .limiter - .get(is_det) - .get_remaining_memory(); + let mut enc = calldata::Encoder::new(CountingWriter(0)); + blob.encode(&mut enc).unwrap_or_else(|e| match e {}); + let blob_size = enc.into_inner().0 as u64; - if space_left < abi::consts::top_limits::WEB_RENDER_MIN_SPACE { - log_warn!(space_left = space_left; "not enough memory for web render"); - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), - ))); - } + let supervisor = self.context.data.supervisor.clone(); + let topics_count = topics.len() as u64; - let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; + let storage_fee = supervisor + .shared_data + .data_fees_limit + .consume_event(blob_size, topics_count) + .await + .map_err(internal_trap)? + .ok_or_else(|| { + internal_trap(rt::errors::Error::vm( + abi::consts::VmError::out_of().receipt().event(), + )) + })?; - let web = self.context.data.supervisor.modules.web.clone(); - let task = taskify(async move { - web.send::( - genvm_modules_interfaces::web::Message::Render( - gl_call_to_mi::render_payload(render_payload), - space_left_with_overhead, - ), - ) - .await - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::EmitEvent { + topics: real_topics, + blob, + storage_fee, + }); - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - gl_call::Message::WebRequest(request_payload) => { - let is_det = self.context.data.conf.permissions.deterministic; - if is_det { - return Err(generated::types::Errno::Forbidden.into()); - } + Ok(file_fd_none()) + } + async fn gl_call_post_message( + &mut self, + address: calldata::Address, + calldata: calldata::unparsed::Maybe, + value: primitive_types::U256, + on: gl_call::On, + use_balance: bool, + fee_params: Option, + ) -> Result { + log_debug!( + recipient = address, + on:? = on, + use_balance = use_balance; + "PostMessage dispatched" + ); + if !self.context.data.conf.permissions.deterministic { + log_debug!("PostMessage rejected: non-deterministic context (Forbidden)"); + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.send_messages { + log_debug!("PostMessage rejected: can_send_messages=false (Forbidden)"); + return Err(generated::types::Errno::Forbidden.into()); + } - let space_left = self - .context - .data - .supervisor - .limiter - .get(is_det) - .get_remaining_memory(); + let balance_params = validate_balance_fee( + self.context + .data + .conf + .permissions + .can_use_balance_for_message_fees, + use_balance, + fee_params, + )?; - if space_left < abi::consts::top_limits::WEB_REQUEST_MIN_SPACE { - log_warn!(space_left = space_left; "not enough memory for web request"); - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), - ))); - } + // Peek the (possibly deferred) calldata to derive the fee call key. + let calldata_materialized = calldata.clone().materialize().ok(); + let method_name = calldata_materialized + .as_ref() + .and_then(|x| x.as_map()) + .and_then(|x| x.get("")) + .and_then(|x| x.as_str()); + let call_key = if let Some(method_name) = method_name { + abi::CallKey::for_method(method_name) + } else { + abi::CallKey::UNNAMED + }; - let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; + if let Some(params) = balance_params { + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; - let web = self.context.data.supervisor.modules.web.clone(); - let task = taskify(async move { - web.send::( - genvm_modules_interfaces::web::Message::Request( - gl_call_to_mi::request_payload(request_payload), - space_left_with_overhead, - ), - ) - .await - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + let messages_value_decremented = + self.context.data.accumulator.messages_value_decremented; + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + }, + Arc::new(params.clone()), + on, + ConsumeInternalArgs { + is_deploy: false, + calldata_length, + code_length: 0, + subtree_length: 0, + }, + ) + .await?; - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - gl_call::Message::ExecPrompt(prompt_payload) => { - if self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } + let metered_fee = fees.message_fee.reported_fee(); - if prompt_payload.images.len() > 2 { - return Err(generated::types::Errno::Inval.into()); - } + // Empty subtree: use_balance nesting is fail-closed on-chain. + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::PostMessage { + call_key, + address, + calldata, + value, + on, + message_fee: metered_fee, + receipt_fee: fees.receipt_fee.reported_fee(), + fee_params: params, + subtree: bytes::Bytes::new(), + use_balance: true, + }); - let remaining_fuel_as_gen = self - .context - .data - .supervisor - .host - .lock_for(host::host_fns::Methods::RemainingFuelAsGen) - .await - .remaining_fuel_as_gen() - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let sup = self.context.data.supervisor.clone(); - - let task = taskify(async move { - let result = sup - .modules - .llm - .send::( - genvm_modules_interfaces::llm::Message::Prompt { - payload: gl_call_to_mi::prompt_payload(prompt_payload), - remaining_fuel_as_gen, - }, - ) - .await?; + self.context.data.accumulator.messages_value_decremented = messages_value_decremented + .saturating_add(value) + .saturating_add(metered_fee); - let result = match result { - Ok(r) => r, - Err(e) => { - return Ok(Err(e)); - } - }; + return Ok(file_fd_none()); + } - sup.host - .lock_for(host::host_fns::Methods::ConsumeFuel) - .await - .consume_fuel(result.consumed_gen)?; + if !value.is_zero() { + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; - if result.consumed_gen == primitive_types::U256::MAX { - return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); - } + if !checked_sum_le( + value, + self.context.data.accumulator.messages_value_decremented, + my_balance, + ) { + return Err(generated::types::Errno::Inbalance.into()); + } + } - { - let mut acc = sup.shared_data.llm_consumption.lock().await; - *acc = acc.saturating_add(result.consumed_gen); - } + let Some((matched_node, matched_params)) = self + .context + .data + .accumulator + .message_fee_allocation + .iter_mut() + .find_map(|node| { + node.matches_internal(on, address, call_key) + .map(|params| (node, params)) + }) + else { + log_warn!( + recipient = address, + call_key:? = call_key, + on:? = on; + "no matching node for message fee allocation" + ); + + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::internal(), + ))); + }; - let result = result.data; + log_debug!( + recipient = address, + call_key:? = call_key, + on:? = on; + "PostMessage matched fee allocation node" + ); - Ok(Ok(result)) - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - gl_call::Message::ExecPromptTemplate(prompt_template_payload) => { - if self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } + let fee_params = (*matched_params).clone(); + let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( + &matched_node.children, + )); - let expect_bool = !matches!( - &prompt_template_payload, - gl_call::llm_iface::PromptTemplatePayload::EqNonComparativeLeader(_) - ); + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Allocation(matched_node), + matched_params, + on, + ConsumeInternalArgs { + is_deploy: false, + calldata_length, + code_length: 0, + subtree_length: subtree.len() as u64, + }, + ) + .await?; - // Get remaining fuel from host - let remaining_fuel_as_gen = self - .context - .data - .supervisor - .host - .lock_for(host::host_fns::Methods::RemainingFuelAsGen) - .await - .remaining_fuel_as_gen() - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let sup = self.context.data.supervisor.clone(); - let task = taskify(async move { - let answer = sup - .modules - .llm - .send::( - genvm_modules_interfaces::llm::Message::PromptTemplate { - payload: gl_call_to_mi::prompt_template_payload( - prompt_template_payload, - ), - remaining_fuel_as_gen, - }, - ) - .await?; - use genvm_modules_interfaces::llm::{PromptAnswer, PromptAnswerData}; - - if let Ok(PromptAnswer { consumed_gen, .. }) = &answer { - sup.host - .lock_for(host::host_fns::Methods::ConsumeFuel) - .await - .consume_fuel(*consumed_gen)?; - if *consumed_gen == primitive_types::U256::MAX { - return Err( - rt::errors::Error::vm(abi::consts::VmError::timeout()).into() - ); - } - - { - let mut acc = sup.shared_data.llm_consumption.lock().await; - *acc = acc.saturating_add(*consumed_gen); - } - } + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::PostMessage { + call_key, + address, + calldata, + value, + on, + message_fee: fees.message_fee.reported_fee(), + receipt_fee: fees.receipt_fee.reported_fee(), + fee_params, + subtree, + use_balance: false, + }); - match (expect_bool, answer) { - (_, Err(e)) => Ok(Err(e)), - ( - true, - Ok(PromptAnswer { - data: PromptAnswerData::Bool(answer), - .. - }), - ) => Ok(Ok(PromptAnswerData::Bool(answer))), - ( - false, - Ok(PromptAnswer { - data: PromptAnswerData::Text(answer), - .. - }), - ) => Ok(Ok(PromptAnswerData::Text(answer))), - (_, Ok(_)) => Err(anyhow::anyhow!("unmatched result")), - } - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + log_debug!( + depth = self.context.data.depth, + emissions_total = self.context.data.accumulator.emissions.len(); + "PostMessage emission pushed to accumulator" + ); - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - gl_call::Message::UserError(msg) => Err(generated::types::Error::trap( - crate::anyhow_to_wasmtime(rt::errors::Error::user(msg).into()), - )), - gl_call::Message::Return(value) => Err(generated::types::Error::trap( - crate::anyhow_to_wasmtime(ContractReturn(value).into()), - )), - gl_call::Message::RunNondet { - data_leader, - data_validator, - runner, - custom_runners, - } => { - self.run_nondet(data_leader, data_validator, runner, custom_runners) - .await - } - gl_call::Message::Sandbox { - data, - runner, - allow_write_storage, - allow_send_messages, - allow_register_runners, - custom_runners, - } => { - self.sandbox( - data, - runner, - allow_write_storage, - allow_send_messages, - allow_register_runners, - custom_runners, - ) - .await - } - gl_call::Message::RegisterRunner { code } => self.register_runner(code).await, - gl_call::Message::MapFile { - runner, - path_in_runner, - path_in_vfs, - } => self.map_file(runner, path_in_runner, path_in_vfs).await, - gl_call::Message::Trace(message) => self.gl_call_trace(message).await, - gl_call::Message::Yield => Ok(file_fd_none()), - gl_call::Message::GetTimestamp => self.gl_call_get_timestamp().await, + self.context.data.accumulator.messages_value_decremented = self + .context + .data + .accumulator + .messages_value_decremented + .saturating_add(value); + + Ok(file_fd_none()) + } + async fn gl_call_deploy_contract( + &mut self, + calldata: calldata::unparsed::Maybe, + code: bytes::Bytes, + value: primitive_types::U256, + on: gl_call::On, + salt_nonce: primitive_types::U256, + use_balance: bool, + fee_params: Option, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.send_messages { + return Err(generated::types::Errno::Forbidden.into()); } - } - async fn storage_read( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - slot: wiggle::GuestPtr, - index: u32, - buf: wiggle::GuestPtr, - buf_len: u32, - ) -> Result<(), generated::types::Error> { - let buf = buf.as_array(buf_len); + let balance_params = validate_balance_fee( + self.context + .data + .conf + .permissions + .can_use_balance_for_message_fees, + use_balance, + fee_params, + )?; - if index.checked_add(buf_len).is_none() { - return Err(generated::types::Errno::Inval.into()); - } + if let Some(params) = balance_params { + let code_length = code.len() as u64; + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; - mem.bounds_check(buf)?; + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + let messages_value_decremented = + self.context.data.accumulator.messages_value_decremented; + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + }, + Arc::new(params.clone()), + on, + ConsumeInternalArgs { + is_deploy: true, + calldata_length, + code_length, + subtree_length: 0, + }, + ) + .await?; - let account = self.context.data.message_data.message.contract_address; + let metered_fee = fees.message_fee.reported_fee(); - let slot = SlotID::read_from_mem(mem, slot)?; - let mem_size = buf_len as usize; + self.context.data.accumulator.emissions.push( + domain::ExecutionEmission::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + receipt_fee: fees.receipt_fee.reported_fee(), + message_fee: metered_fee, + fee_params: params, + subtree: bytes::Bytes::new(), + use_balance: true, + }, + ); - let mut vec_buf = Vec::new(); - let (should_copy, vec) = if let Some(buf) = mem.as_slice_mut(buf)? { - (false, buf) - } else { - vec_buf.resize(mem_size, 0); - (true, vec_buf.as_mut_slice()) - }; + self.context.data.accumulator.messages_value_decremented = messages_value_decremented + .saturating_add(value) + .saturating_add(metered_fee); - if self.context.data.conf.execution.state_mode == public_abi::StorageType::Default { - self.context - .data - .storage - .read(slot, index, vec) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; - } else { - self.context - .data - .supervisor - .host - .lock_for(host::host_fns::Methods::StorageRead) - .await - .storage_read( - self.context.data.conf.execution.state_mode, - account, - slot, - index, - vec, - ) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + return Ok(file_fd_none()); } - if should_copy { - mem.copy_from_slice(&vec_buf, buf)?; + if !value.is_zero() { + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + + if !checked_sum_le( + value, + self.context.data.accumulator.messages_value_decremented, + my_balance, + ) { + return Err(generated::types::Errno::Inbalance.into()); + } } - Ok(()) - } + let Some((matched_node, matched_params)) = self + .context + .data + .accumulator + .message_fee_allocation + .iter_mut() + .find_map(|node| { + node.matches_internal(on, calldata::Address::zero(), abi::CallKey::DEPLOY) + .map(|params| (node, params)) + }) + else { + log_warn!( + recipient = calldata::Address::zero(), + call_key:? = abi::CallKey::DEPLOY, + on:? = on; + "no matching node for message fee allocation" + ); + + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::internal(), + ))); + }; - async fn storage_write( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - slot: wiggle::GuestPtr, - index: u32, - buf: wiggle::GuestPtr, - buf_len: u32, - ) -> Result<(), generated::types::Error> { - let buf = buf.as_array(buf_len); + let code_length = code.len() as u64; + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.write_storage { + let fee_params = (*matched_params).clone(); + let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( + &matched_node.children, + )); + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Allocation(matched_node), + matched_params, + on, + ConsumeInternalArgs { + is_deploy: true, + calldata_length, + code_length, + subtree_length: subtree.len() as u64, + }, + ) + .await?; + + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + receipt_fee: fees.receipt_fee.reported_fee(), + message_fee: fees.message_fee.reported_fee(), + fee_params, + subtree, + use_balance: false, + }); + + self.context.data.accumulator.messages_value_decremented = self + .context + .data + .accumulator + .messages_value_decremented + .saturating_add(value); + + Ok(file_fd_none()) + } + async fn gl_call_web_render( + &mut self, + render_payload: gl_call::web_iface::RenderPayload, + ) -> Result { + let is_det = self.context.data.conf.permissions.deterministic; + if is_det { return Err(generated::types::Errno::Forbidden.into()); } - if index.checked_add(buf_len).is_none() { - return Err(generated::types::Errno::Inval.into()); + let space_left = self + .context + .data + .supervisor + .limiter + .get(is_det) + .get_remaining_memory(); + + if space_left < abi::consts::top_limits::WEB_RENDER_MIN_SPACE { + log_warn!(space_left = space_left; "not enough memory for web render"); + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ))); } - mem.bounds_check(buf)?; + let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; - let slot = SlotID::read_from_mem(mem, slot)?; + let web = self.context.data.supervisor.modules.web.clone(); + let task = taskify(async move { + web.send::( + genvm_modules_interfaces::web::Message::Render( + gl_call_to_mi::render_payload(render_payload), + space_left_with_overhead, + ), + ) + .await + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - if self.context.data.supervisor.locked_slots.contains(slot) { + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) + } + async fn gl_call_web_request( + &mut self, + request_payload: gl_call::web_iface::RequestPayload, + ) -> Result { + let is_det = self.context.data.conf.permissions.deterministic; + if is_det { return Err(generated::types::Errno::Forbidden.into()); } - let ptr = mem.as_cow(buf)?; - - self.context + let space_left = self + .context .data - .storage - .write(slot, index, &ptr) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into()))) - } + .supervisor + .limiter + .get(is_det) + .get_remaining_memory(); - async fn get_balance( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - account: wiggle::GuestPtr, - result: wiggle::GuestPtr, - ) -> Result<(), generated::types::Error> { - let address = read_addr_from_mem(mem, account)?; + if space_left < abi::consts::top_limits::WEB_REQUEST_MIN_SPACE { + log_warn!(space_left = space_left; "not enough memory for web request"); + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ))); + } - self.context - .get_balance_impl_wasi(mem, address, result, false) + let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; + + let web = self.context.data.supervisor.modules.web.clone(); + let task = taskify(async move { + web.send::( + genvm_modules_interfaces::web::Message::Request( + gl_call_to_mi::request_payload(request_payload), + space_left_with_overhead, + ), + ) .await - } + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - async fn get_self_balance( + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) + } + async fn gl_call_exec_prompt( &mut self, - mem: &mut wiggle::GuestMemory<'_>, - result: wiggle::GuestPtr, - ) -> Result<(), generated::types::Error> { - if !self.context.data.conf.permissions.deterministic { + prompt_payload: gl_call::llm_iface::PromptPayload, + ) -> Result { + if self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); } - self.context - .get_balance_impl_wasi( - mem, - self.context.data.message_data.message.contract_address, - result, - true, - ) + if prompt_payload.images.len() > 2 { + return Err(generated::types::Errno::Inval.into()); + } + + let remaining_fuel_as_gen = self + .context + .data + .supervisor + .host + .lock_for(host::host_fns::Methods::RemainingFuelAsGen) .await - } -} + .remaining_fuel_as_gen() + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; -impl Context { - async fn get_balance_impl_wasi( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - address: calldata::Address, - result: wiggle::GuestPtr, - is_self: bool, - ) -> Result<(), generated::types::Error> { - let mut res = self.get_balance_impl(address).await?; + let sup = self.context.data.supervisor.clone(); - if is_self && self.data.conf.is_main() { - let messages_decremented = self.data.accumulator.messages_value_decremented; + let task = taskify(async move { + let result = sup + .modules + .llm + .send::( + genvm_modules_interfaces::llm::Message::Prompt { + payload: gl_call_to_mi::prompt_payload(prompt_payload), + remaining_fuel_as_gen, + }, + ) + .await?; - res -= messages_decremented; - } + let result = match result { + Ok(r) => r, + Err(e) => { + return Ok(Err(e)); + } + }; - let res = res.to_little_endian(); - mem.copy_from_slice(&res, result.as_array(32))?; + sup.host + .lock_for(host::host_fns::Methods::ConsumeFuel) + .await + .consume_fuel(result.consumed_gen)?; - Ok(()) - } + if result.consumed_gen == primitive_types::U256::MAX { + return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); + } - pub async fn get_balance_impl( + { + let mut acc = sup.shared_data.llm_consumption.lock().await; + *acc = acc.saturating_add(result.consumed_gen); + } + + let result = result.data; + + Ok(Ok(result)) + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) + } + async fn gl_call_exec_prompt_template( &mut self, - address: calldata::Address, - ) -> Result { - if let Some(res) = self.data.supervisor.balances.get(&address) { - return Ok(*res); + prompt_template_payload: gl_call::llm_iface::PromptTemplatePayload, + ) -> Result { + if self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); } - let res = self + let expect_bool = !matches!( + &prompt_template_payload, + gl_call::llm_iface::PromptTemplatePayload::EqNonComparativeLeader(_) + ); + + // Get remaining fuel from host + let remaining_fuel_as_gen = self + .context .data .supervisor .host - .lock_for(host::host_fns::Methods::GetBalance) + .lock_for(host::host_fns::Methods::RemainingFuelAsGen) .await - .get_balance(address) + .remaining_fuel_as_gen() .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - let _ = self.data.supervisor.balances.insert(address, res); + let sup = self.context.data.supervisor.clone(); + let task = taskify(async move { + let answer = sup + .modules + .llm + .send::( + genvm_modules_interfaces::llm::Message::PromptTemplate { + payload: gl_call_to_mi::prompt_template_payload(prompt_template_payload), + remaining_fuel_as_gen, + }, + ) + .await?; + use genvm_modules_interfaces::llm::{PromptAnswer, PromptAnswerData}; - Ok(res) - } + if let Ok(PromptAnswer { consumed_gen, .. }) = &answer { + sup.host + .lock_for(host::host_fns::Methods::ConsumeFuel) + .await + .consume_fuel(*consumed_gen)?; + if *consumed_gen == primitive_types::U256::MAX { + return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); + } - pub fn log(&self) -> calldata::Value { - let msg = calldata::to_value(&self.data.message_data); - let conf = calldata::to_value(&self.data.conf); + { + let mut acc = sup.shared_data.llm_consumption.lock().await; + *acc = acc.saturating_add(*consumed_gen); + } + } - calldata::Value::Map(BTreeMap::from([ - ("config".to_owned(), conf), - ("message".to_owned(), msg), - ])) + match (expect_bool, answer) { + (_, Err(e)) => Ok(Err(e)), + ( + true, + Ok(PromptAnswer { + data: PromptAnswerData::Bool(answer), + .. + }), + ) => Ok(Ok(PromptAnswerData::Bool(answer))), + ( + false, + Ok(PromptAnswer { + data: PromptAnswerData::Text(answer), + .. + }), + ) => Ok(Ok(PromptAnswerData::Text(answer))), + (_, Ok(_)) => Err(anyhow::anyhow!("unmatched result")), + } + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) } -} -impl ContextVFS<'_> { async fn gl_call_trace( &mut self, msg: gl_call::TracePayload, diff --git a/tests/integration/runner/no_flowback/no_flowback.0.stdout b/tests/integration/runner/no_flowback/no_flowback.0.stdout new file mode 100644 index 00000000..b539dcf1 --- /dev/null +++ b/tests/integration/runner/no_flowback/no_flowback.0.stdout @@ -0,0 +1,2 @@ +sandbox registered custom:2b7ggf7f4ky8k3ytwfxdm1wjyvqy62anfb0ym0qc2qn5hw9pnyj0 +executed with `VMError("invalid_contract malformed_runner")` diff --git a/tests/integration/runner/no_flowback/no_flowback.skip b/tests/integration/runner/no_flowback/no_flowback.skip deleted file mode 100644 index b0ad79c1..00000000 --- a/tests/integration/runner/no_flowback/no_flowback.skip +++ /dev/null @@ -1,10 +0,0 @@ -BLOCKED by a Phase-1 regression (uncommitted feat/runner-memory-accounting): -resolving/registering a `custom:` runner inside a spawned sub-VM (sandbox or -nondet child) overflows the async-fiber stack ("execution on async fiber has -overflowed its stack"). Root-VM custom registration is fine (see -register_ladder). The pre-existing sandbox_custom_runner and nondet_custom_runner -cases fail identically on this tree, so it is not this case's contract. - -This case asserts ADR-012 §4 no-flow-back (a sandbox-scoped RegisterRunner is -not resolvable in the parent). It is correct as written and should pass once the -sub-VM custom-runner stack overflow is fixed — remove this .skip then. diff --git a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.0.stdout b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.0.stdout new file mode 100644 index 00000000..5b5958a4 --- /dev/null +++ b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.0.stdout @@ -0,0 +1,2 @@ +nondet mapped inherited custom +executed with `Return(null)` diff --git a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip b/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip deleted file mode 100644 index 3bc55e72..00000000 --- a/tests/integration/runner/nondet_inherit_custom/nondet_inherit_custom.skip +++ /dev/null @@ -1,11 +0,0 @@ -BLOCKED by a Phase-1 regression (uncommitted feat/runner-memory-accounting): -resolving an inherited `custom:` runner inside a nondet sub-VM overflows the -async-fiber stack ("execution on async fiber has overflowed its stack"). The -custom runner IS charged in both the parent and the nondet child (the load -records are correct: `custom:` charged >= 2), but the child then overflows -before the leader body runs. The pre-existing nondet_custom_runner case fails -identically on this tree, so it is not this case's contract. - -This case asserts the new ADR-012 §4 behavior (a nondet block inherits the -parent's custom set by default). It is correct as written and should pass once -the sub-VM custom-runner stack overflow is fixed — remove this .skip then. From c3578a85c3f798ebe816467d006983aac6234c5b Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 17:05:15 +0900 Subject: [PATCH 17/25] chore: split file --- executor/src/wasi/genlayer_sdk.rs | 2686 --------------------- executor/src/wasi/genlayer_sdk/message.rs | 822 +++++++ executor/src/wasi/genlayer_sdk/mod.rs | 1262 ++++++++++ executor/src/wasi/genlayer_sdk/run.rs | 499 ++++ executor/src/wasi/genlayer_sdk/tests.rs | 113 + 5 files changed, 2696 insertions(+), 2686 deletions(-) delete mode 100644 executor/src/wasi/genlayer_sdk.rs create mode 100644 executor/src/wasi/genlayer_sdk/message.rs create mode 100644 executor/src/wasi/genlayer_sdk/mod.rs create mode 100644 executor/src/wasi/genlayer_sdk/run.rs create mode 100644 executor/src/wasi/genlayer_sdk/tests.rs diff --git a/executor/src/wasi/genlayer_sdk.rs b/executor/src/wasi/genlayer_sdk.rs deleted file mode 100644 index 651e4435..00000000 --- a/executor/src/wasi/genlayer_sdk.rs +++ /dev/null @@ -1,2686 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::Arc; - -use genvm_common::sync::DArc; -use genvm_common::*; - -use genvm_modules_interfaces::GenericValue; -use sha3::digest::Update; -use wiggle::GuestError; - -use crate::host::{self, SlotID}; -use crate::{anyhow_to_wasmtime, calldata, public_abi, rt, runners, wasi}; - -use crate::rt::errors::ResultExt as _; -use genlayer_calldata::codec::Encode; -pub use genlayer_sdk::abi::entry::ExtendedMessage; -use genlayer_sdk::abi::{self, gl_call}; - -use super::{base, vfs}; - -fn default_entry_stage_data() -> calldata::Value { - calldata::Value::Null -} - -fn internal_trap(error: rt::errors::Error) -> generated::types::Error { - generated::types::Error::trap(anyhow_to_wasmtime(error.into())) -} - -/// Named arguments for [`consume_message_fee_internal`]. -struct ConsumeInternalArgs { - is_deploy: bool, - calldata_length: u64, - code_length: u64, - subtree_length: u64, -} - -/// How an internal message's fee is funded. -enum FeeFunding<'a> { - /// Sender-pool allocation: fee capped by `node.budget`; consumes the - /// message-fee and receipt buckets. - Allocation(&'a mut domain::fees::MessageAllocationNode), - /// Balance-funded (`useBalance`): the metered fee is the `declaredBudget`, - /// reserved from the contract balance. On-chain such messages are excluded - /// from the sender pool, so the message-fee bucket is skipped and only the - /// receipt is charged. The balance is checked here, before the receipt is - /// consumed, so nothing is charged unless funding is guaranteed. - Balance { - value: primitive_types::U256, - messages_value_decremented: primitive_types::U256, - my_balance: primitive_types::U256, - }, -} - -async fn consume_message_fee_internal( - shared_data: &rt::SharedData, - funding: FeeFunding<'_>, - fee_params: Arc, - on: gl_call::On, - args: ConsumeInternalArgs, -) -> Result { - let balance_funded = matches!(funding, FeeFunding::Balance { .. }); - let fee_cost = shared_data - .data_fees_limit - .calculate_message_fee_internal(on, balance_funded, &fee_params) - .map_err(internal_trap)?; - let fee_total = fee_cost.reported_fee(); - - let receipt_cost = shared_data - .data_fees_limit - .calculate_message_receipt(rt::fees::MessageReceiptParams { - is_internal: true, - is_deploy: args.is_deploy, - calldata_length: args.calldata_length, - code_length: args.code_length, - subtree_length: args.subtree_length, - }) - .ctx("calculate_message_receipt") - .map_err(internal_trap)?; - - match funding { - FeeFunding::Allocation(node) => { - if fee_total > node.budget { - log_warn!( - node:cd = *node, - fee_cost:cd = fee_total, - budget: cd = node.budget; - "message fee cost exceeds node budget" - ); - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::out_of().message_fee().node(), - host_fns::VmErrorDetail::internal(), - ))); - } - - if !shared_data - .data_fees_limit - .consume_message_fee(&fee_cost, &receipt_cost) - .await - { - log_warn!( - node:cd = *node, - fee_cost:cd = fee_total, - buckets:? = shared_data.data_fees_limit; - "not enough remaining fee limit to consume message fee" - ); - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::out_of().message_fee().total(), - host_fns::VmErrorDetail::internal(), - ))); - } - - node.budget -= fee_total; - } - FeeFunding::Balance { - value, - messages_value_decremented, - my_balance, - } => { - // `value + fee_total` can itself overflow U256. - let Some(total) = value.checked_add(fee_total) else { - return Err(generated::types::Errno::Inbalance.into()); - }; - if !checked_sum_le(total, messages_value_decremented, my_balance) { - log_warn!( - fee_cost:cd = fee_total, - value:cd = value, - my_balance:cd = my_balance; - "insufficient balance to fund balance-funded message fee" - ); - return Err(generated::types::Errno::Inbalance.into()); - } - - if !shared_data - .data_fees_limit - .consume_message_receipt_only(&receipt_cost) - .await - { - log_warn!( - receipt_cost:cd = receipt_cost.reported_fee(), - buckets:? = shared_data.data_fees_limit; - "not enough remaining receipt limit to consume message receipt" - ); - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::out_of().receipt().message(), - host_fns::VmErrorDetail::internal(), - ))); - } - } - } - - Ok(rt::fees::MessageFeeConsumption { - message_fee: fee_cost, - receipt_fee: receipt_cost, - }) -} - -/// Named arguments for [`consume_message_fee_external`]. -struct ConsumeExternalArgs { - is_deploy: bool, - calldata_length: u64, -} - -async fn consume_message_fee_external( - shared_data: &rt::SharedData, - node: &mut domain::fees::MessageAllocationNode, - params: domain::fees::ExternalMessageParams, - // External messages are always emitted on finalization; carried for signature - // symmetry with the internal path. - _on: gl_call::On, - args: ConsumeExternalArgs, -) -> Result { - let fee_cost = shared_data - .data_fees_limit - .calculate_message_fee_external(¶ms) - .map_err(internal_trap)?; - - let fee_total = fee_cost.reported_fee(); - if fee_total > node.budget { - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::out_of().message_fee().node(), - host_fns::VmErrorDetail::external(), - ))); - } - - let receipt_cost = shared_data - .data_fees_limit - .calculate_message_receipt(rt::fees::MessageReceiptParams { - is_internal: false, - is_deploy: args.is_deploy, - calldata_length: args.calldata_length, - code_length: 0, - subtree_length: 0, - }) - .map_err(internal_trap)?; - - if !shared_data - .data_fees_limit - .consume_message_fee(&fee_cost, &receipt_cost) - .await - { - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::out_of().message_fee().total(), - host_fns::VmErrorDetail::external(), - ))); - } - - node.budget -= fee_total; - - Ok(rt::fees::MessageFeeConsumption { - message_fee: fee_cost, - receipt_fee: receipt_cost, - }) -} - -/// Magnitude bounds (in significant bits; a larger field is rejected) on -/// guest-supplied fee params. Invariant the code cannot express: the worst-case -/// `messageFeeFloor` product must stay within U256, since the fee evaluator's -/// `rational_to_u256` treats overflow as an internal abort. Three guest fields -/// multiply into one floor term (`maxPrice × rotations entry × validatorTU`), -/// so with the default 18-round validator table (counts ≤ 1537 < 2^11) the -/// accepted-lifecycle worst case is -/// lifecycle(<2^4) × [price(<2^96) × rounds(<2^5) × rot(<2^33) -/// × (leaderTU + vpr × validatorTU)(<2^44) -/// + price(<2^96) × leaderRounds(<2^36)] -/// < 2^183 ≪ 2^256. -/// Economically generous: 2^96 atto-GEN ≈ 8e10 GEN for prices/budgets; 2^32 -/// for counts (time units per phase, rotations per round). -const FEE_PARAM_PRICE_BITS: usize = 96; -const FEE_PARAM_COUNT_BITS: usize = 32; - -/// Validates the balance-funded fee fields (`use_balance` / `fee_params`) shared -/// by `PostMessage` and `DeployContract` (GVM-281 §4.2). Returns the fee params -/// to meter against the contract balance when `use_balance` is set, or `None` -/// for the ordinary allocation-matched path. -/// -/// Guest params are structurally validated so no guest input can drive the fee -/// evaluator into an internal abort or emit a message the chain would reject at -/// reveal: `rotations` must be non-empty (the yaml floor derives -/// `appealRounds = len - 1` and would go negative otherwise), the three -/// price caps must be non-zero (the chain reverts `FeeValueMustBeNonZero(4/5/6)` -/// at reveal), and every numeric field — rotations entries included — is -/// bounded by [`FEE_PARAM_PRICE_BITS`] / [`FEE_PARAM_COUNT_BITS`] so the -/// metered floor fits in U256. The rotations-length upper bound is -/// node-config-dependent (validator table length) and is enforced in the yaml -/// fee expression instead. -fn validate_balance_fee( - can_use_balance: bool, - use_balance: bool, - fee_params: Option, -) -> Result, generated::types::Error> { - match (use_balance, fee_params) { - (true, _) if !can_use_balance => { - log_debug!("balance-funded message rejected: permission absent (Forbidden)"); - Err(generated::types::Errno::Forbidden.into()) - } - (true, None) => { - // this is a feature for future - log_debug!("balance-funded message rejected: fee_params absent (Inval)"); - Err(generated::types::Errno::Inval.into()) - } - (true, Some(params)) => { - if params.rotations.is_empty() { - log_debug!("balance-funded message rejected: rotations empty (Inval)"); - return Err(generated::types::Errno::Inval.into()); - } - if params.max_price_gen_per_time_unit.is_zero() - || params.storage_fee_max_gas_price.is_zero() - || params.receipt_fee_max_gas_price.is_zero() - { - log_debug!("balance-funded message rejected: zero price cap (Inval)"); - return Err(generated::types::Errno::Inval.into()); - } - let too_large = params.max_price_gen_per_time_unit.bits() > FEE_PARAM_PRICE_BITS - || params.storage_fee_max_gas_price.bits() > FEE_PARAM_PRICE_BITS - || params.receipt_fee_max_gas_price.bits() > FEE_PARAM_PRICE_BITS - || params.execution_budget_per_round.bits() > FEE_PARAM_PRICE_BITS - || params.leader_timeunits_allocation.bits() > FEE_PARAM_COUNT_BITS - || params.validator_timeunits_allocation.bits() > FEE_PARAM_COUNT_BITS - || params - .rotations - .iter() - .any(|r| r.bits() > FEE_PARAM_COUNT_BITS); - if too_large { - log_debug!("balance-funded message rejected: fee param too large (Inval)"); - return Err(generated::types::Errno::Inval.into()); - } - Ok(Some(params)) - } - (false, Some(_)) => { - log_debug!("message rejected: fee_params without use_balance (Inval)"); - Err(generated::types::Errno::Inval.into()) - } - (false, None) => Ok(None), - } -} - -async fn consume_nondet_output( - shared_data: &rt::SharedData, - output_length: u64, -) -> Result<(), generated::types::Error> { - if !shared_data - .data_fees_limit - .consume_nondet_output(output_length) - .await - .map_err(internal_trap)? - { - return Err(internal_trap(rt::errors::Error::vm( - abi::consts::VmError::out_of().receipt().nondet_output(), - ))); - } - Ok(()) -} - -/// Extension methods for ExtendedMessage specific to the executor -pub trait ExtendedMessageExt { - fn fork_leader( - &self, - entry_kind: public_abi::EntryKind, - entry_data: bytes::Bytes, - entry_leader_data: Option, - ) -> ExtendedMessage; - - fn fork(&self, entry_kind: public_abi::EntryKind, entry_data: bytes::Bytes) -> ExtendedMessage; -} - -impl ExtendedMessageExt for ExtendedMessage { - fn fork_leader( - &self, - entry_kind: public_abi::EntryKind, - entry_data: bytes::Bytes, - entry_leader_data: Option, - ) -> ExtendedMessage { - use genlayer_sdk::abi::entry::MessageData; - - let entry_leader_data = match entry_leader_data { - None => default_entry_stage_data(), - Some(entry_leader_data) => calldata::Value::Map(BTreeMap::from([( - "leaders_result".into(), - calldata::Value::Bytes(entry_leader_data.as_bytes()), - )])), - }; - - ExtendedMessage { - message: MessageData { - contract_address: self.message.contract_address, - sender_address: self.message.sender_address, - origin_address: self.message.origin_address, - signer_address: self.message.signer_address, - stack: self.message.stack.clone(), - chain_id: self.message.chain_id.clone(), - value: self.message.value.clone(), - is_init: false, - datetime: self.message.datetime, - }, - entry_kind, - entry_data, - entry_stage_data: entry_leader_data, - } - } - - fn fork(&self, entry_kind: public_abi::EntryKind, entry_data: bytes::Bytes) -> ExtendedMessage { - self.fork_leader(entry_kind, entry_data, None) - } -} - -#[derive(Clone)] -pub struct ReadToken { - pub mode: public_abi::StorageType, - pub account: calldata::Address, -} - -pub struct StorageHostLock<'a>(tokio::sync::MutexGuard<'a, host::Host>, ReadToken); - -impl rt::vm::storage::HostStorage for StorageHostLock<'_> { - fn storage_read(&mut self, slot_id: SlotID, index: u32, buf: &mut [u8]) -> anyhow::Result<()> { - self.0 - .storage_read(self.1.mode, self.1.account, slot_id, index, buf) - } -} - -#[derive(Clone)] -pub struct StorageHostHolder(pub Arc, pub ReadToken); - -impl rt::vm::storage::HostStorageLocking for StorageHostHolder { - type ReturnType<'a> = StorageHostLock<'a>; - - async fn lock(&self) -> Self::ReturnType<'_> { - StorageHostLock( - self.0.lock_for(host::host_fns::Methods::StorageRead).await, - self.1.clone(), - ) - } -} - -pub struct VMDataAccumulator { - pub data_fees_limit: DArc, - pub messages_value_decremented: primitive_types::U256, - pub emissions: Vec, - pub message_fee_allocation: Vec, -} - -impl VMDataAccumulator { - /// Asserts the accumulator carries no surfaced effects (events, - /// message-fee allocations). Call it after running a sub-VM whose - /// accumulator is discarded (e.g. a `CallContract` child) to guarantee no - /// effect was charged but silently dropped. Returns a fatal error otherwise. - pub fn check_empty(&self) -> anyhow::Result<()> { - anyhow::ensure!( - self.emissions.is_empty() && self.message_fee_allocation.is_empty(), - "internal error: discarded sub-VM accumulator is not empty ({} emission(s), {} message-fee allocation(s))", - self.emissions.len(), - self.message_fee_allocation.len(), - ); - Ok(()) - } -} - -pub struct SingleVMData { - pub conf: base::Config, - pub depth: u32, - pub spawn_kind: String, - pub message_data: ExtendedMessage, - pub supervisor: Arc, - pub storage: rt::vm::storage::Storage, - pub accumulator: VMDataAccumulator, - pub det_subvm_hashes: sha3::Sha3_256, - /// `custom:` runner pins granted to this VM by its parent, carried from the - /// spawning gl_call (so the content survives the parent's death for a queued - /// nondet VM). The child's spawn performs a load action for each, charging its - /// own limiter, before its main runner loads (ADR-012 §1/§4). Empty for the - /// root VM. Drained at spawn. - pub granted_custom: Vec, -} - -pub struct Context { - pub data: SingleVMData, - - /// This VM's **loaded set**: the charge-dedup set and pin holder for every - /// runner it has loaded (ADR-012 §1). A sibling of `data` (not inside its - /// accumulator), so it does not round-trip through sandbox children and is - /// dropped when this VM's store is torn down — coinciding with the VM - /// limiter's refund of the load charges. - pub loaded: crate::runners::cache::LoadedSet, - - /// A handle to this VM's own memory limiter (a clone of its store limiter, so - /// it refunds at VM death). Runtime load actions (`RegisterRunner`, `MapFile`) - /// charge it directly, since a gl_call handler cannot otherwise reach the - /// store limiter. - pub limiter: rt::memlimiter::Limiter, - - pub start_time: std::time::Instant, - pub prev_time: std::time::Instant, -} - -pub struct ContextVFS<'a> { - pub(super) vfs: &'a mut vfs::VFS, - pub(super) preview1: &'a mut super::preview1::Context, - pub(super) context: &'a mut Context, -} - -async fn spawn_sub_vm( - supervisor: Arc, - vm_data: Box, -) -> anyhow::Result { - tokio::task::spawn(async move { rt::spawn_apply_run(&supervisor, vm_data).await }) - .await - .map_err(|e| anyhow::anyhow!("sub-VM task failed to join: {e}"))? -} - -#[allow(clippy::too_many_arguments)] -pub(crate) mod generated { - wiggle::from_witx!({ - witx: ["$CARGO_MANIFEST_DIR/src/wasi/witx/genlayer_sdk.witx"], - errors: { errno => trappable Error }, - wasmtime: false, - tracing: false, - - async: { - genlayer_sdk::{ - gl_call, - storage_read, storage_write, - get_balance, get_self_balance, - } - }, - }); - - wiggle::wasmtime_integration!({ - witx: ["$CARGO_MANIFEST_DIR/src/wasi/witx/genlayer_sdk.witx"], - errors: { errno => trappable Error }, - target: self, - tracing: false, - - async: { - genlayer_sdk::{ - gl_call, - storage_read, storage_write, - get_balance, get_self_balance, - } - }, - }); -} - -impl From for generated::types::Fd { - fn from(fd: wasi::vfs::Fd) -> Self { - fd.as_u32().into() - } -} - -impl From for wasi::vfs::Fd { - fn from(fd: generated::types::Fd) -> Self { - wasi::vfs::Fd::new(fd.into()) - } -} - -fn read_addr_from_mem( - mem: &mut wiggle::GuestMemory<'_>, - addr: wiggle::GuestPtr, -) -> Result { - let cow = mem.as_cow( - addr.as_array( - calldata::ADDRESS_SIZE - .try_into() - .expect("ADDRESS_SIZE exceeds target type"), - ), - )?; - let mut ret = calldata::Address::zero(); - ret.ref_mut().copy_from_slice(&cow); - Ok(ret) -} - -impl SlotID { - fn read_from_mem( - mem: &mut wiggle::GuestMemory<'_>, - addr: wiggle::GuestPtr, - ) -> Result { - let cow = mem.as_cow( - addr.as_array( - SlotID::len() - .try_into() - .expect("SlotID::len exceeds target type"), - ), - )?; - let mut ret = SlotID::zero(); - for (x, y) in ret.0.iter_mut().zip(cow.iter()) { - *x = *y; - } - Ok(ret) - } -} - -fn read_owned_vec( - mem: &mut wiggle::GuestMemory<'_>, - ptr: wiggle::GuestPtr<[u8]>, -) -> Result, generated::types::Error> { - Ok(mem.as_cow(ptr)?.into_owned()) -} - -impl Context { - pub fn new(data: Box, limiter: rt::memlimiter::Limiter) -> Self { - let now = std::time::Instant::now(); - - // Every VM's config passes through here. Invariant: only a `Default`-state - // VM reads through its local storage cache (read-your-writes); a VM with a - // non-`Default` state_mode reads bypass the cache, so it must not be able to - // write (otherwise its writes would hit the cache but never be read back). - debug_assert!( - data.conf.execution.state_mode == public_abi::StorageType::Default - || !data.conf.permissions.write_storage, - "a VM with state_mode != Default must not have can_write_storage" - ); - - Self { - data: *data, - loaded: Default::default(), - limiter, - start_time: now, - prev_time: now, - } - } -} - -impl wiggle::GuestErrorType for generated::types::Errno { - fn success() -> Self { - Self::Success - } -} - -pub trait AddToLinkerFn { - fn call<'a>(&self, arg: &'a mut T) -> ContextVFS<'a>; -} - -pub(super) fn add_to_linker_sync( - linker: &mut wasmtime::Linker, - f: F, -) -> anyhow::Result<()> -where - F: AddToLinkerFn + Copy + Send + Sync + 'static, -{ - #[derive(Clone, Copy)] - struct Fwd(F); - - impl generated::AddGenlayerSdkToLinkerFn for Fwd - where - F: AddToLinkerFn + Copy + Send + Sync + 'static, - { - fn call(&self, arg: &mut T) -> impl generated::genlayer_sdk::GenlayerSdk { - self.0.call(arg) - } - } - generated::add_genlayer_sdk_to_linker(linker, Fwd(f))?; - Ok(()) -} - -#[derive(Debug)] -pub struct ContractReturn(pub calldata::unparsed::Maybe); - -impl std::error::Error for ContractReturn {} - -impl std::fmt::Display for ContractReturn { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Returned {:?}", self.0) - } -} - -impl From for generated::types::Error { - fn from(err: GuestError) -> Self { - use wiggle::GuestError::*; - match err { - InvalidFlagValue { .. } => generated::types::Errno::Inval.into(), - InvalidEnumValue { .. } => generated::types::Errno::Inval.into(), - // As per - // https://github.com/WebAssembly/wasi/blob/main/legacy/tools/witx-docs.md#pointers - // - // > If a misaligned pointer is passed to a function, the function - // > shall trap. - // > - // > If an out-of-bounds pointer is passed to a function and the - // > function needs to dereference it, the function shall trap. - // - // so this turns OOB and misalignment errors into traps. - PtrOverflow | PtrOutOfBounds { .. } | PtrNotAligned { .. } => { - generated::types::Error::trap(crate::anyhow_to_wasmtime(err.into())) - } - InvalidUtf8 { .. } => generated::types::Errno::Ilseq.into(), - TryFromIntError { .. } => generated::types::Errno::Overflow.into(), - SliceLengthsDiffer => generated::types::Errno::Fault.into(), - InFunc { err, .. } => generated::types::Error::from(*err), - MemoryNotExported => generated::types::Errno::Fault.into(), - } - } -} - -impl From for generated::types::Error { - fn from(_err: std::num::TryFromIntError) -> Self { - generated::types::Errno::Overflow.into() - } -} - -impl From for generated::types::Error { - fn from(err: serde_json::Error) -> Self { - log_info!(error:err = err; "deserialization failed, returning inval"); - - generated::types::Errno::Inval.into() - } -} - -impl ContextVFS<'_> { - fn place_content( - &mut self, - content: vfs::FileContents, - ) -> Result { - self.vfs - .place_content(content) - .map(generated::types::Fd::from) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e))) - } - - fn set_vm_run_result( - &mut self, - data: rt::vm::RunOk, - ) -> Result<(generated::types::Fd, usize), generated::types::Error> { - let data = match data { - rt::vm::RunOk::VMError(e, cause) => { - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm_cause(e, cause).into(), - ))) - } - data => data, - }; - let data: Vec = data.as_bytes(); - let len = data.len(); - self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) - .map(|fd| (fd, len)) - } -} - -async fn taskify( - fut: impl std::future::Future>> - + Send - + 'static, -) -> anyhow::Result> -where - T: calldata::codec::Encode, Error = std::convert::Infallible> + Send, -{ - match fut.await? { - Ok(r) => { - let r = calldata::to_value(&r); - let data = calldata::Value::Map(BTreeMap::from([("ok".to_owned(), r)])); - - Ok(Box::from(calldata::encode(&data))) - } - Err(e) => { - let e = calldata::to_value(&e); - let data = calldata::Value::Map(BTreeMap::from([("error".to_owned(), e)])); - - Ok(Box::from(calldata::encode(&data))) - } - } -} - -const NO_FILE: u32 = u32::MAX; - -#[inline] -fn file_fd_none() -> generated::types::Fd { - generated::types::Fd::from(NO_FILE) -} - -/// Returns `true` iff `a + b <= c`, computed without wrapping. -/// -/// `a + b` is done with checked arithmetic: on overflow the sum cannot fit in a -/// `U256` and is therefore strictly greater than any real `c`, so the result is -/// `false`. This avoids the wraparound that would let an oversized `a` (e.g. -/// `2^256 - 1`) appear to satisfy a balance check. -#[inline] -fn checked_sum_le( - a: primitive_types::U256, - b: primitive_types::U256, - c: primitive_types::U256, -) -> bool { - if b > c { - return false; - } - let c_minus_b = c - b; - a <= c_minus_b -} - -#[allow(unused_variables)] -impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { - async fn gl_call( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - request: wiggle::GuestPtr, - request_len: u32, - ) -> Result { - let request = request.as_array(request_len); - let request = read_owned_vec(mem, request)?; - - let request = match calldata::decode(&request) { - Err(e) => { - log_info!(error:err = &e; "calldata parse failed"); - - return Err(generated::types::Errno::Inval.into()); - } - Ok(v) => v, - }; - - log_trace!(request:cd = request; "gl_call"); - - let request: gl_call::Message = match calldata::from_value(request) { - Ok(v) => v, - Err(e) => { - log_info!(error:err = e; "calldata deserialization failed"); - - return Err(generated::types::Errno::Inval.into()); - } - }; - - match request { - gl_call::Message::EthSend { - address, - calldata, - value, - } => self.gl_call_eth_send(address, calldata, value).await, - gl_call::Message::EthCall { address, calldata } => { - self.gl_call_eth_call(address, calldata).await - } - gl_call::Message::CallContract { - address, - calldata, - state, - } => self.gl_call_contract(address, calldata, state).await, - gl_call::Message::EmitEvent { topics, blob } => { - self.gl_call_emit_event(topics, blob).await - } - gl_call::Message::PostMessage { - address, - calldata, - value, - on, - use_balance, - fee_params, - } => { - self.gl_call_post_message(address, calldata, value, on, use_balance, fee_params) - .await - } - gl_call::Message::DeployContract { - calldata, - code, - value, - on, - salt_nonce, - use_balance, - fee_params, - } => { - self.gl_call_deploy_contract( - calldata, - code, - value, - on, - salt_nonce, - use_balance, - fee_params, - ) - .await - } - gl_call::Message::WebRender(render_payload) => { - self.gl_call_web_render(render_payload).await - } - gl_call::Message::WebRequest(request_payload) => { - self.gl_call_web_request(request_payload).await - } - gl_call::Message::ExecPrompt(prompt_payload) => { - self.gl_call_exec_prompt(prompt_payload).await - } - gl_call::Message::ExecPromptTemplate(prompt_template_payload) => { - self.gl_call_exec_prompt_template(prompt_template_payload) - .await - } - gl_call::Message::UserError(msg) => Err(generated::types::Error::trap( - crate::anyhow_to_wasmtime(rt::errors::Error::user(msg).into()), - )), - gl_call::Message::Return(value) => Err(generated::types::Error::trap( - crate::anyhow_to_wasmtime(ContractReturn(value).into()), - )), - gl_call::Message::RunNondet { - data_leader, - data_validator, - runner, - custom_runners, - } => { - self.run_nondet(data_leader, data_validator, runner, custom_runners) - .await - } - gl_call::Message::Sandbox { - data, - runner, - allow_write_storage, - allow_send_messages, - allow_register_runners, - custom_runners, - } => { - self.sandbox( - data, - runner, - allow_write_storage, - allow_send_messages, - allow_register_runners, - custom_runners, - ) - .await - } - gl_call::Message::RegisterRunner { code } => self.register_runner(code).await, - gl_call::Message::MapFile { - runner, - path_in_runner, - path_in_vfs, - } => self.map_file(runner, path_in_runner, path_in_vfs).await, - gl_call::Message::Trace(message) => self.gl_call_trace(message).await, - gl_call::Message::Yield => Ok(file_fd_none()), - gl_call::Message::GetTimestamp => self.gl_call_get_timestamp().await, - } - } - - async fn storage_read( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - slot: wiggle::GuestPtr, - index: u32, - buf: wiggle::GuestPtr, - buf_len: u32, - ) -> Result<(), generated::types::Error> { - let buf = buf.as_array(buf_len); - - if index.checked_add(buf_len).is_none() { - return Err(generated::types::Errno::Inval.into()); - } - - mem.bounds_check(buf)?; - - let account = self.context.data.message_data.message.contract_address; - - let slot = SlotID::read_from_mem(mem, slot)?; - let mem_size = buf_len as usize; - - let mut vec_buf = Vec::new(); - let (should_copy, vec) = if let Some(buf) = mem.as_slice_mut(buf)? { - (false, buf) - } else { - vec_buf.resize(mem_size, 0); - (true, vec_buf.as_mut_slice()) - }; - - if self.context.data.conf.execution.state_mode == public_abi::StorageType::Default { - self.context - .data - .storage - .read(slot, index, vec) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; - } else { - self.context - .data - .supervisor - .host - .lock_for(host::host_fns::Methods::StorageRead) - .await - .storage_read( - self.context.data.conf.execution.state_mode, - account, - slot, - index, - vec, - ) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - } - - if should_copy { - mem.copy_from_slice(&vec_buf, buf)?; - } - - Ok(()) - } - - async fn storage_write( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - slot: wiggle::GuestPtr, - index: u32, - buf: wiggle::GuestPtr, - buf_len: u32, - ) -> Result<(), generated::types::Error> { - let buf = buf.as_array(buf_len); - - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.write_storage { - return Err(generated::types::Errno::Forbidden.into()); - } - - if index.checked_add(buf_len).is_none() { - return Err(generated::types::Errno::Inval.into()); - } - - mem.bounds_check(buf)?; - - let slot = SlotID::read_from_mem(mem, slot)?; - - if self.context.data.supervisor.locked_slots.contains(slot) { - return Err(generated::types::Errno::Forbidden.into()); - } - - let ptr = mem.as_cow(buf)?; - - self.context - .data - .storage - .write(slot, index, &ptr) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into()))) - } - - async fn get_balance( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - account: wiggle::GuestPtr, - result: wiggle::GuestPtr, - ) -> Result<(), generated::types::Error> { - let address = read_addr_from_mem(mem, account)?; - - self.context - .get_balance_impl_wasi(mem, address, result, false) - .await - } - - async fn get_self_balance( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - result: wiggle::GuestPtr, - ) -> Result<(), generated::types::Error> { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - - self.context - .get_balance_impl_wasi( - mem, - self.context.data.message_data.message.contract_address, - result, - true, - ) - .await - } -} - -impl Context { - async fn get_balance_impl_wasi( - &mut self, - mem: &mut wiggle::GuestMemory<'_>, - address: calldata::Address, - result: wiggle::GuestPtr, - is_self: bool, - ) -> Result<(), generated::types::Error> { - let mut res = self.get_balance_impl(address).await?; - - if is_self && self.data.conf.is_main() { - let messages_decremented = self.data.accumulator.messages_value_decremented; - - res -= messages_decremented; - } - - let res = res.to_little_endian(); - mem.copy_from_slice(&res, result.as_array(32))?; - - Ok(()) - } - - pub async fn get_balance_impl( - &mut self, - address: calldata::Address, - ) -> Result { - if let Some(res) = self.data.supervisor.balances.get(&address) { - return Ok(*res); - } - - let res = self - .data - .supervisor - .host - .lock_for(host::host_fns::Methods::GetBalance) - .await - .get_balance(address) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let _ = self.data.supervisor.balances.insert(address, res); - - Ok(res) - } - - pub fn log(&self) -> calldata::Value { - let msg = calldata::to_value(&self.data.message_data); - let conf = calldata::to_value(&self.data.conf); - - calldata::Value::Map(BTreeMap::from([ - ("config".to_owned(), conf), - ("message".to_owned(), msg), - ])) - } -} - -impl ContextVFS<'_> { - async fn gl_call_eth_send( - &mut self, - address: calldata::Address, - calldata: bytes::Bytes, - value: primitive_types::U256, - ) -> Result { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.send_messages { - return Err(generated::types::Errno::Forbidden.into()); - } - - if !value.is_zero() { - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - - if !checked_sum_le( - value, - self.context.data.accumulator.messages_value_decremented, - my_balance, - ) { - return Err(generated::types::Errno::Inbalance.into()); - } - } - - let mut call_key = abi::CallKey([0u8; 32]); - if calldata.len() < 4 { - log_warn!(len = calldata.len(); "calldata too short for method selector, using unnamed call key"); - } else { - call_key.0[..4].copy_from_slice(&calldata[..4]); - } - - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_external(address, call_key) - .map(|params| (node, params)) - }) - else { - log_warn!( - recipient = address, - call_key:? = call_key; - "no matching node for message fee allocation" - ); - - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().no_matching_node(), - host_fns::VmErrorDetail::external(), - ))); - }; - - let calldata_length = calldata.len() as u64; - - let fees = consume_message_fee_external( - &self.context.data.supervisor.shared_data, - matched_node, - matched_params, - gl_call::On::Finalized, - ConsumeExternalArgs { - is_deploy: false, - calldata_length, - }, - ) - .await?; - - self.context - .data - .accumulator - .emissions - .push(domain::ExecutionEmission::EthSend { - address, - calldata, - value, - message_fee: fees.message_fee.reported_fee(), - receipt_fee: fees.receipt_fee.reported_fee(), - fee_params: matched_params, - }); - - self.context.data.accumulator.messages_value_decremented = self - .context - .data - .accumulator - .messages_value_decremented - .saturating_add(value); - Ok(file_fd_none()) - } - async fn gl_call_eth_call( - &mut self, - address: calldata::Address, - calldata: bytes::Bytes, - ) -> Result { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.call_others { - return Err(generated::types::Errno::Forbidden.into()); - } - - let supervisor = self.context.data.supervisor.clone(); - let data = supervisor - .host - .lock_for(host::host_fns::Methods::EthCall) - .await - .eth_call(address, &calldata) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) - } - async fn gl_call_contract( - &mut self, - address: calldata::Address, - calldata: calldata::unparsed::Maybe, - mut state: public_abi::StorageType, - ) -> Result { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.call_others { - return Err(generated::types::Errno::Forbidden.into()); - } - - let supervisor = self.context.data.supervisor.clone(); - - let my_conf = self.context.data.conf.clone(); - - let calldata_encoded = calldata::encode_obj(&calldata); - - let mut my_data = self - .context - .data - .message_data - .fork(public_abi::EntryKind::Main, calldata_encoded.into()); - my_data.message.stack.push(my_data.message.contract_address); - - if state == public_abi::StorageType::Default { - state = my_conf.execution.state_mode; - } - - let mut child_storage = rt::vm::storage::Storage::new( - address, - supervisor.get_storage_limiter(), - StorageHostHolder( - supervisor.host.clone(), - ReadToken { - account: address, - mode: state, - }, - ), - ); - - let code_slot = child_storage - .check_major_and_resolve_code_slot() - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; - - let vm_data = Box::new(SingleVMData { - depth: self.context.data.depth + 1, - spawn_kind: "call_contract".to_owned(), - // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst - conf: base::Config { - needs_error_fingerprint: true, - permissions: base::Permissions { - deterministic: true, - write_storage: false, - spawn_nondet: false, - call_others: my_conf.permissions.call_others, - send_messages: false, - register_runners: my_conf.permissions.register_runners, - can_use_balance_for_message_fees: false, - }, - execution: base::Execution { - state_mode: state, - topmost_runner_id: runners::Id::Chain { - address, - on: if state == public_abi::StorageType::LatestFinal { - runners::ChainState::Finalized - } else { - runners::ChainState::Accepted - }, - slot: code_slot, - }, - }, - }, - message_data: ExtendedMessage { - message: genlayer_sdk::abi::entry::MessageData { - contract_address: address, - sender_address: my_data.message.sender_address, - origin_address: my_data.message.origin_address, - signer_address: my_data.message.signer_address, - value: num_bigint::BigInt::ZERO, - is_init: false, - datetime: my_data.message.datetime, - chain_id: my_data.message.chain_id, - stack: my_data.message.stack, - }, - entry_kind: my_data.entry_kind, - entry_data: my_data.entry_data, - entry_stage_data: default_entry_stage_data(), - }, - storage: child_storage, - supervisor: supervisor.clone(), - accumulator: VMDataAccumulator { - data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), - messages_value_decremented: primitive_types::U256::zero(), - emissions: Vec::new(), - message_fee_allocation: Vec::new(), - }, - det_subvm_hashes: Default::default(), - // A CallContract child is granted the caller's full custom set - // (ADR-012 §4); its spawn load-actions each into the child. - granted_custom: self.context.loaded.custom_pins(), - }); - - let res = spawn_sub_vm(supervisor.clone(), vm_data) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - // The child is read-only (static), so its accumulator must be - // empty — otherwise an effect was charged but discarded here. - res.vm_data - .accumulator - .check_empty() - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let hash = res.small_hash(); - self.context.data.det_subvm_hashes.update(&hash); - - self.set_vm_run_result(res.run_ok).map(|x| x.0) - } - async fn gl_call_emit_event( - &mut self, - topics: Vec, - blob: calldata::unparsed::Maybe>, - ) -> Result { - if !self.context.data.conf.permissions.deterministic { - log_warn!("EmitEvent requires deterministic mode"); - - return Err(generated::types::Errno::Forbidden.into()); - } - // Events are state-mutating log emissions, so they require the - // same write capability as storage. A read-only sub-VM (e.g. a - // `CallContract` child) must not emit events; otherwise the - // emission is charged but later discarded with the child. - if !self.context.data.conf.permissions.write_storage { - log_warn!("EmitEvent requires write_storage permission"); - - return Err(generated::types::Errno::Forbidden.into()); - } - - if topics.len() > public_abi::EVENT_MAX_TOPICS as usize { - log_warn!(cnt = topics.len(), max = public_abi::EVENT_MAX_TOPICS; "too many topics"); - return Err(generated::types::Errno::Inval.into()); - } - - let mut real_topics: Vec = - Vec::with_capacity(public_abi::EVENT_MAX_TOPICS as usize + 1); - - for t in topics.iter() { - if t.len() != 32 { - log_warn!(len = t.len(); "invalid topic length"); - - return Err(generated::types::Errno::Inval.into()); - } - - real_topics.push(t.clone()); - } - - struct CountingWriter(usize); - impl calldata::Writer for CountingWriter { - type Error = std::convert::Infallible; - - fn write_all(&mut self, data: &[u8]) -> Result<(), Self::Error> { - self.0 += data.len(); - Ok(()) - } - } - - let mut enc = calldata::Encoder::new(CountingWriter(0)); - blob.encode(&mut enc).unwrap_or_else(|e| match e {}); - let blob_size = enc.into_inner().0 as u64; - - let supervisor = self.context.data.supervisor.clone(); - let topics_count = topics.len() as u64; - - let storage_fee = supervisor - .shared_data - .data_fees_limit - .consume_event(blob_size, topics_count) - .await - .map_err(internal_trap)? - .ok_or_else(|| { - internal_trap(rt::errors::Error::vm( - abi::consts::VmError::out_of().receipt().event(), - )) - })?; - - self.context - .data - .accumulator - .emissions - .push(domain::ExecutionEmission::EmitEvent { - topics: real_topics, - blob, - storage_fee, - }); - - Ok(file_fd_none()) - } - async fn gl_call_post_message( - &mut self, - address: calldata::Address, - calldata: calldata::unparsed::Maybe, - value: primitive_types::U256, - on: gl_call::On, - use_balance: bool, - fee_params: Option, - ) -> Result { - log_debug!( - recipient = address, - on:? = on, - use_balance = use_balance; - "PostMessage dispatched" - ); - if !self.context.data.conf.permissions.deterministic { - log_debug!("PostMessage rejected: non-deterministic context (Forbidden)"); - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.send_messages { - log_debug!("PostMessage rejected: can_send_messages=false (Forbidden)"); - return Err(generated::types::Errno::Forbidden.into()); - } - - let balance_params = validate_balance_fee( - self.context - .data - .conf - .permissions - .can_use_balance_for_message_fees, - use_balance, - fee_params, - )?; - - // Peek the (possibly deferred) calldata to derive the fee call key. - let calldata_materialized = calldata.clone().materialize().ok(); - let method_name = calldata_materialized - .as_ref() - .and_then(|x| x.as_map()) - .and_then(|x| x.get("")) - .and_then(|x| x.as_str()); - let call_key = if let Some(method_name) = method_name { - abi::CallKey::for_method(method_name) - } else { - abi::CallKey::UNNAMED - }; - - if let Some(params) = balance_params { - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; - - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - let messages_value_decremented = - self.context.data.accumulator.messages_value_decremented; - - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Balance { - value, - messages_value_decremented, - my_balance, - }, - Arc::new(params.clone()), - on, - ConsumeInternalArgs { - is_deploy: false, - calldata_length, - code_length: 0, - subtree_length: 0, - }, - ) - .await?; - - let metered_fee = fees.message_fee.reported_fee(); - - // Empty subtree: use_balance nesting is fail-closed on-chain. - self.context - .data - .accumulator - .emissions - .push(domain::ExecutionEmission::PostMessage { - call_key, - address, - calldata, - value, - on, - message_fee: metered_fee, - receipt_fee: fees.receipt_fee.reported_fee(), - fee_params: params, - subtree: bytes::Bytes::new(), - use_balance: true, - }); - - self.context.data.accumulator.messages_value_decremented = messages_value_decremented - .saturating_add(value) - .saturating_add(metered_fee); - - return Ok(file_fd_none()); - } - - if !value.is_zero() { - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - - if !checked_sum_le( - value, - self.context.data.accumulator.messages_value_decremented, - my_balance, - ) { - return Err(generated::types::Errno::Inbalance.into()); - } - } - - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_internal(on, address, call_key) - .map(|params| (node, params)) - }) - else { - log_warn!( - recipient = address, - call_key:? = call_key, - on:? = on; - "no matching node for message fee allocation" - ); - - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().no_matching_node(), - host_fns::VmErrorDetail::internal(), - ))); - }; - - log_debug!( - recipient = address, - call_key:? = call_key, - on:? = on; - "PostMessage matched fee allocation node" - ); - - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; - - let fee_params = (*matched_params).clone(); - let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - )); - - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Allocation(matched_node), - matched_params, - on, - ConsumeInternalArgs { - is_deploy: false, - calldata_length, - code_length: 0, - subtree_length: subtree.len() as u64, - }, - ) - .await?; - - self.context - .data - .accumulator - .emissions - .push(domain::ExecutionEmission::PostMessage { - call_key, - address, - calldata, - value, - on, - message_fee: fees.message_fee.reported_fee(), - receipt_fee: fees.receipt_fee.reported_fee(), - fee_params, - subtree, - use_balance: false, - }); - - log_debug!( - depth = self.context.data.depth, - emissions_total = self.context.data.accumulator.emissions.len(); - "PostMessage emission pushed to accumulator" - ); - - self.context.data.accumulator.messages_value_decremented = self - .context - .data - .accumulator - .messages_value_decremented - .saturating_add(value); - - Ok(file_fd_none()) - } - async fn gl_call_deploy_contract( - &mut self, - calldata: calldata::unparsed::Maybe, - code: bytes::Bytes, - value: primitive_types::U256, - on: gl_call::On, - salt_nonce: primitive_types::U256, - use_balance: bool, - fee_params: Option, - ) -> Result { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.send_messages { - return Err(generated::types::Errno::Forbidden.into()); - } - - let balance_params = validate_balance_fee( - self.context - .data - .conf - .permissions - .can_use_balance_for_message_fees, - use_balance, - fee_params, - )?; - - if let Some(params) = balance_params { - let code_length = code.len() as u64; - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; - - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - let messages_value_decremented = - self.context.data.accumulator.messages_value_decremented; - - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Balance { - value, - messages_value_decremented, - my_balance, - }, - Arc::new(params.clone()), - on, - ConsumeInternalArgs { - is_deploy: true, - calldata_length, - code_length, - subtree_length: 0, - }, - ) - .await?; - - let metered_fee = fees.message_fee.reported_fee(); - - self.context.data.accumulator.emissions.push( - domain::ExecutionEmission::DeployContract { - calldata, - code, - value, - on, - salt_nonce, - receipt_fee: fees.receipt_fee.reported_fee(), - message_fee: metered_fee, - fee_params: params, - subtree: bytes::Bytes::new(), - use_balance: true, - }, - ); - - self.context.data.accumulator.messages_value_decremented = messages_value_decremented - .saturating_add(value) - .saturating_add(metered_fee); - - return Ok(file_fd_none()); - } - - if !value.is_zero() { - let my_balance = self - .context - .get_balance_impl(self.context.data.message_data.message.contract_address) - .await?; - - if !checked_sum_le( - value, - self.context.data.accumulator.messages_value_decremented, - my_balance, - ) { - return Err(generated::types::Errno::Inbalance.into()); - } - } - - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_internal(on, calldata::Address::zero(), abi::CallKey::DEPLOY) - .map(|params| (node, params)) - }) - else { - log_warn!( - recipient = calldata::Address::zero(), - call_key:? = abi::CallKey::DEPLOY, - on:? = on; - "no matching node for message fee allocation" - ); - - return Err(internal_trap(rt::errors::Error::vm_detailed( - abi::consts::VmError::fee().no_matching_node(), - host_fns::VmErrorDetail::internal(), - ))); - }; - - let code_length = code.len() as u64; - let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); - calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); - let calldata_length = enc.into_inner().0; - - let fee_params = (*matched_params).clone(); - let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - )); - - let fees = consume_message_fee_internal( - &self.context.data.supervisor.shared_data, - FeeFunding::Allocation(matched_node), - matched_params, - on, - ConsumeInternalArgs { - is_deploy: true, - calldata_length, - code_length, - subtree_length: subtree.len() as u64, - }, - ) - .await?; - - self.context - .data - .accumulator - .emissions - .push(domain::ExecutionEmission::DeployContract { - calldata, - code, - value, - on, - salt_nonce, - receipt_fee: fees.receipt_fee.reported_fee(), - message_fee: fees.message_fee.reported_fee(), - fee_params, - subtree, - use_balance: false, - }); - - self.context.data.accumulator.messages_value_decremented = self - .context - .data - .accumulator - .messages_value_decremented - .saturating_add(value); - - Ok(file_fd_none()) - } - async fn gl_call_web_render( - &mut self, - render_payload: gl_call::web_iface::RenderPayload, - ) -> Result { - let is_det = self.context.data.conf.permissions.deterministic; - if is_det { - return Err(generated::types::Errno::Forbidden.into()); - } - - let space_left = self - .context - .data - .supervisor - .limiter - .get(is_det) - .get_remaining_memory(); - - if space_left < abi::consts::top_limits::WEB_RENDER_MIN_SPACE { - log_warn!(space_left = space_left; "not enough memory for web render"); - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), - ))); - } - - let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; - - let web = self.context.data.supervisor.modules.web.clone(); - let task = taskify(async move { - web.send::( - genvm_modules_interfaces::web::Message::Render( - gl_call_to_mi::render_payload(render_payload), - space_left_with_overhead, - ), - ) - .await - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - async fn gl_call_web_request( - &mut self, - request_payload: gl_call::web_iface::RequestPayload, - ) -> Result { - let is_det = self.context.data.conf.permissions.deterministic; - if is_det { - return Err(generated::types::Errno::Forbidden.into()); - } - - let space_left = self - .context - .data - .supervisor - .limiter - .get(is_det) - .get_remaining_memory(); - - if space_left < abi::consts::top_limits::WEB_REQUEST_MIN_SPACE { - log_warn!(space_left = space_left; "not enough memory for web request"); - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), - ))); - } - - let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; - - let web = self.context.data.supervisor.modules.web.clone(); - let task = taskify(async move { - web.send::( - genvm_modules_interfaces::web::Message::Request( - gl_call_to_mi::request_payload(request_payload), - space_left_with_overhead, - ), - ) - .await - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - async fn gl_call_exec_prompt( - &mut self, - prompt_payload: gl_call::llm_iface::PromptPayload, - ) -> Result { - if self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - - if prompt_payload.images.len() > 2 { - return Err(generated::types::Errno::Inval.into()); - } - - let remaining_fuel_as_gen = self - .context - .data - .supervisor - .host - .lock_for(host::host_fns::Methods::RemainingFuelAsGen) - .await - .remaining_fuel_as_gen() - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let sup = self.context.data.supervisor.clone(); - - let task = taskify(async move { - let result = sup - .modules - .llm - .send::( - genvm_modules_interfaces::llm::Message::Prompt { - payload: gl_call_to_mi::prompt_payload(prompt_payload), - remaining_fuel_as_gen, - }, - ) - .await?; - - let result = match result { - Ok(r) => r, - Err(e) => { - return Ok(Err(e)); - } - }; - - sup.host - .lock_for(host::host_fns::Methods::ConsumeFuel) - .await - .consume_fuel(result.consumed_gen)?; - - if result.consumed_gen == primitive_types::U256::MAX { - return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); - } - - { - let mut acc = sup.shared_data.llm_consumption.lock().await; - *acc = acc.saturating_add(result.consumed_gen); - } - - let result = result.data; - - Ok(Ok(result)) - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - async fn gl_call_exec_prompt_template( - &mut self, - prompt_template_payload: gl_call::llm_iface::PromptTemplatePayload, - ) -> Result { - if self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - - let expect_bool = !matches!( - &prompt_template_payload, - gl_call::llm_iface::PromptTemplatePayload::EqNonComparativeLeader(_) - ); - - // Get remaining fuel from host - let remaining_fuel_as_gen = self - .context - .data - .supervisor - .host - .lock_for(host::host_fns::Methods::RemainingFuelAsGen) - .await - .remaining_fuel_as_gen() - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let sup = self.context.data.supervisor.clone(); - let task = taskify(async move { - let answer = sup - .modules - .llm - .send::( - genvm_modules_interfaces::llm::Message::PromptTemplate { - payload: gl_call_to_mi::prompt_template_payload(prompt_template_payload), - remaining_fuel_as_gen, - }, - ) - .await?; - use genvm_modules_interfaces::llm::{PromptAnswer, PromptAnswerData}; - - if let Ok(PromptAnswer { consumed_gen, .. }) = &answer { - sup.host - .lock_for(host::host_fns::Methods::ConsumeFuel) - .await - .consume_fuel(*consumed_gen)?; - if *consumed_gen == primitive_types::U256::MAX { - return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); - } - - { - let mut acc = sup.shared_data.llm_consumption.lock().await; - *acc = acc.saturating_add(*consumed_gen); - } - } - - match (expect_bool, answer) { - (_, Err(e)) => Ok(Err(e)), - ( - true, - Ok(PromptAnswer { - data: PromptAnswerData::Bool(answer), - .. - }), - ) => Ok(Ok(PromptAnswerData::Bool(answer))), - ( - false, - Ok(PromptAnswer { - data: PromptAnswerData::Text(answer), - .. - }), - ) => Ok(Ok(PromptAnswerData::Text(answer))), - (_, Ok(_)) => Err(anyhow::anyhow!("unmatched result")), - } - }) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) - } - - async fn gl_call_trace( - &mut self, - msg: gl_call::TracePayload, - ) -> Result { - match msg { - gl_call::TracePayload::Message(text) => { - // Tracing is a debug-only aid; skip the timing and logging work - // entirely when not in debug mode. - if !self - .context - .data - .supervisor - .shared_data - .debug_mode - .allows_tracing() - { - return Ok(file_fd_none()); - } - - let now = std::time::Instant::now(); - let since_prev = now.duration_since(self.context.prev_time); - self.context.prev_time = now; - - log_info!( - message = text, - elapsed:? = now.duration_since(self.context.start_time), - since_last_trace:? = since_prev; - "trace" - ); - - Ok(file_fd_none()) - } - gl_call::TracePayload::RuntimeMicroSec => { - let elapsed_micros = if self.context.data.conf.permissions.deterministic - && !self - .context - .data - .supervisor - .shared_data - .debug_mode - .allows_nondeterminism() - { - 0u64 - } else { - let elapsed = std::time::Instant::now().duration_since(self.context.start_time); - elapsed.as_micros() as u64 - }; - - let data = calldata::encode(&calldata::Value::Number(num_bigint::BigInt::from( - elapsed_micros, - ))); - self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) - } - } - } - - async fn gl_call_get_timestamp( - &mut self, - ) -> Result { - let timestamp = if self.context.data.conf.permissions.deterministic { - self.context.data.message_data.message.datetime.timestamp() - } else { - chrono::Utc::now().timestamp() - }; - - let data = calldata::encode(&calldata::Value::Number(num_bigint::BigInt::from( - timestamp, - ))); - self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) - } - - async fn register_runner( - &mut self, - code: bytes::Bytes, - ) -> Result { - if !self.context.data.conf.permissions.deterministic { - return Err(generated::types::Errno::Forbidden.into()); - } - if !self.context.data.conf.permissions.register_runners { - return Err(generated::types::Errno::Forbidden.into()); - } - - // The load action registers the content (weak registry, dedup while - // alive), charges `RUNNER_LOAD_COST + code.len()` to this VM before parsing, and - // pins it into this VM's loaded set — scoping it to this execution and its - // deterministic children only (ADR-012 §3). RegisterRunner is det-only, so - // the load folds into the det fingerprint. - let supervisor = self.context.data.supervisor.clone(); - let limiter = self.context.limiter.clone(); - let Context { loaded, data, .. } = &mut *self.context; - let id = rt::supervisor::actions::register_runner_load( - &supervisor, - &limiter, - loaded, - Some(&mut data.det_subvm_hashes), - code, - ) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let data = calldata::encode(&calldata::Value::Str(id.as_str().to_owned())); - self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) - } - - async fn map_file( - &mut self, - runner: String, - path_in_runner: String, - path_in_vfs: String, - ) -> Result { - let supervisor = self.context.data.supervisor.clone(); - let is_det = self.context.data.conf.permissions.deterministic; - // Charge the VM's own limiter (refunded at VM death, coinciding with the - // pin drop), not the long-lived root limiter. - let limiter = self.context.limiter.clone(); - let topmost_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); - - let runner = - rt::supervisor::actions::resolve_runner_id(&supervisor, &topmost_runner_id, &runner) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - // The load action pins the archive into this VM's loaded set (keeping the - // mapped files' backing bytes resident) and charges once for it. - let preview1 = &mut *self.preview1; - let Context { loaded, data, .. } = &mut *self.context; - let det_fingerprint = is_det.then_some(&mut data.det_subvm_hashes); - rt::supervisor::actions::map_runner_file( - &supervisor, - preview1, - &limiter, - loaded, - det_fingerprint, - runner, - &path_in_runner, - &path_in_vfs, - ) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - Ok(file_fd_none()) - } - - async fn run_nondet( - &mut self, - data_leader: bytes::Bytes, - data_validator: bytes::Bytes, - runner: Option, - custom_runners: Option>, - ) -> Result { - if !self.context.data.conf.permissions.spawn_nondet { - return Err(generated::types::Errno::Forbidden.into()); - } - - // Resolve the runner to execute and the child's custom-runner visibility - // in this (deterministic parent) scope, so malformed inputs fail - // deterministically at gl_call time (ADR-012 §4). - let supervisor = self.context.data.supervisor.clone(); - let parent_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); - let child_topmost_id = match &runner { - None => parent_runner_id.clone(), - Some(r) => { - rt::supervisor::actions::resolve_runner_id(&supervisor, &parent_runner_id, r) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))? - } - }; - let child_custom = rt::supervisor::actions::resolve_child_custom_runners( - &self.context.loaded, - custom_runners, - &child_topmost_id, - ) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let call_no = self - .context - .data - .supervisor - .nondet_call_no - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - - if call_no >= public_abi::top_limits::NONDET_BLOCKS { - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::out_of().nondet_blocks()).into(), - ))); - } - - let leaders_res_bytes = self - .context - .data - .supervisor - .get_leader_nondet_result(call_no); - - let leaders_res = match leaders_res_bytes { - None if self.context.data.supervisor.is_leader() => None, - None => { - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - rt::errors::Error::vm(abi::consts::VmError::absent_leader_nondet_output()) - .into(), - ))); - } - Some(data) if data.is_empty() => { - // A zero-length leader result is malformed: it carries no result - // code byte to dispatch on. It is contract-triggerable, so it must - // not panic via out-of-bounds indexing. In `sync` mode there are no - // validators to disagree, so surface a canonical VMError; otherwise - // record a non-deterministic disagreement and return that VMError to - // the validator's contract. - if !self.context.data.supervisor.shared_data.is_sync { - self.context - .data - .supervisor - .mark_nondet_disagreement(call_no); - } - - let result = rt::vm::RunOk::VMError( - abi::consts::VmError::absent_leader_nondet_output(), - None, - ); - - consume_nondet_output( - &self.context.data.supervisor.shared_data, - result.as_bytes().len() as u64, - ) - .await?; - - return self.set_vm_run_result(result).map(|x| x.0); - } - Some(data) => { - use crate::public_abi::ResultCode; - let rest = &data[1..]; - let res = match data[0] { - x if x == ResultCode::Return as u8 => { - rt::vm::RunOk::Return(calldata::unparsed::Maybe::Checked( - calldata::unparsed::Raw(bytes::Bytes::copy_from_slice(rest)), - )) - } - x if x == ResultCode::UserError as u8 => { - let val = calldata::decode(rest).map_err(|e| { - generated::types::Error::trap(crate::anyhow_to_wasmtime( - anyhow::anyhow!(e), - )) - })?; - rt::vm::RunOk::UserError(calldata::unparsed::Maybe::Materialized(val)) - } - x if x == ResultCode::VmError as u8 => { - let code = std::str::from_utf8(rest).map_err(|e| { - generated::types::Error::trap(crate::anyhow_to_wasmtime( - anyhow::anyhow!(e), - )) - })?; - rt::vm::RunOk::VMError( - public_abi::VmError(std::borrow::Cow::Owned(code.to_owned())), - None, - ) - } - x => { - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - anyhow::anyhow!("invalid leader result code: {}", x), - ))); - } - }; - Some(res) - } - }; - - let result_to_return = if self.context.data.supervisor.shared_data.is_sync { - match leaders_res { - None => { - return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( - anyhow::anyhow!("absent leader result in sync mode, call_no: {}", call_no), - ))) - } - Some(v) => v, - } - } else { - let storage_checkpoint = self.context.data.storage.clone(); - - let message_data = match &leaders_res { - None => self.context.data.message_data.fork_leader( - public_abi::EntryKind::ConsensusStage, - data_leader, - None, - ), - Some(leaders_res) => { - let dup = match leaders_res { - rt::vm::RunOk::Return(items) => rt::vm::RunOk::Return(items.clone()), - rt::vm::RunOk::UserError(msg) => rt::vm::RunOk::UserError(msg.clone()), - rt::vm::RunOk::VMError(msg, _) => rt::vm::RunOk::VMError(msg.clone(), None), - }; - self.context.data.message_data.fork_leader( - public_abi::EntryKind::ConsensusStage, - data_validator, - Some(dup), - ) - } - }; - - let fake_accum = VMDataAccumulator { - data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), - messages_value_decremented: self - .context - .data - .accumulator - .messages_value_decremented, - emissions: Vec::new(), - message_fee_allocation: Vec::new(), - }; - - let vm_data = Box::new(SingleVMData { - depth: self.context.data.depth + 1, - spawn_kind: "run_nondet".to_owned(), - // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst - conf: base::Config { - needs_error_fingerprint: false, - permissions: base::Permissions { - deterministic: false, - write_storage: false, - send_messages: false, - call_others: false, - spawn_nondet: false, - register_runners: false, - can_use_balance_for_message_fees: false, - }, - execution: base::Execution { - state_mode: public_abi::StorageType::Default, - topmost_runner_id: child_topmost_id, - }, - }, - message_data, - supervisor: supervisor.clone(), - storage: storage_checkpoint, - accumulator: fake_accum, - det_subvm_hashes: Default::default(), // won't be used - // The nondet child is granted exactly the parent-computed set; the - // pins keep the content alive until the (possibly queued) child - // spawns and load-actions them against its own limiter (ADR-012 §4). - granted_custom: child_custom, - }); - - let task_done = Arc::new(tokio::sync::Notify::new()); - let task = rt::supervisor::NonDetVMTask { - task: vm_data, - call_no, - tasks_done: task_done.clone(), - }; - - match leaders_res { - None => { - let res = task - .run_now(&self.context.data.supervisor) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - self.context - .data - .supervisor - .push_nondet_result(call_no, bytes::Bytes::from(res.as_bytes())) - .await; - - res - } - Some(leaders_res) => { - rt::supervisor::submit_nondet_vm_task(&self.context.data.supervisor, task) - .await; - - leaders_res - } - } - }; - - consume_nondet_output( - &self.context.data.supervisor.shared_data, - result_to_return.as_bytes().len() as u64, - ) - .await?; - - self.set_vm_run_result(result_to_return).map(|x| x.0) - } - - async fn sandbox( - &mut self, - data: bytes::Bytes, - runner: String, - allow_write_storage: bool, - allow_send_messages: bool, - allow_register_runners: bool, - custom_runners: Option>, - ) -> Result { - let supervisor = self.context.data.supervisor.clone(); - - let parent_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); - let topmost_runner_id = - rt::supervisor::actions::resolve_runner_id(&supervisor, &parent_runner_id, &runner) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - // Grants are validated against, and drawn from, this VM's loaded set (§4). - // No flow-back is possible by construction: the child's own loaded set (and - // any runner it registers) dies with it, and the parent's loaded set is - // never mutated here (§5). - let child_custom = rt::supervisor::actions::resolve_child_custom_runners( - &self.context.loaded, - custom_runners, - &topmost_runner_id, - ) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - let message_data = self - .context - .data - .message_data - .fork(public_abi::EntryKind::Sandbox, data); - - let zelf_conf = &self.context.data.conf; - - let storage_checkpoint = self.context.data.storage.clone(); - - let mut fake_my_data = VMDataAccumulator { - data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), - messages_value_decremented: primitive_types::U256::max_value(), - emissions: Vec::new(), - message_fee_allocation: Vec::new(), - }; - - std::mem::swap(&mut self.context.data.accumulator, &mut fake_my_data); - - let stolen_data = fake_my_data; - - let vm_data = Box::new(SingleVMData { - depth: self.context.data.depth + 1, - spawn_kind: "sandbox".to_owned(), - // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst - conf: base::Config { - needs_error_fingerprint: false, - permissions: base::Permissions { - deterministic: zelf_conf.permissions.deterministic, - write_storage: zelf_conf.permissions.write_storage & allow_write_storage, - send_messages: zelf_conf.permissions.send_messages & allow_send_messages, - call_others: false, - spawn_nondet: false, - register_runners: zelf_conf.permissions.register_runners - & allow_register_runners, - can_use_balance_for_message_fees: zelf_conf - .permissions - .can_use_balance_for_message_fees - & allow_send_messages, - }, - execution: base::Execution { - state_mode: zelf_conf.execution.state_mode, - topmost_runner_id, - }, - }, - message_data, - supervisor: supervisor.clone(), - storage: storage_checkpoint, - accumulator: stolen_data, - det_subvm_hashes: Default::default(), - granted_custom: child_custom, - }); - - let my_res = spawn_sub_vm(supervisor.clone(), vm_data) - .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; - - if self.context.data.conf.permissions.deterministic { - let hash = my_res.small_hash(); - self.context.data.det_subvm_hashes.update(&hash); - } - - self.context.data.accumulator = my_res.vm_data.accumulator; - self.context.data.storage = my_res.vm_data.storage; - - let data: Vec = my_res.run_ok.as_bytes(); - self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) - } -} - -/// Conversions from sdk-rs `gl_call` payload types to the self-contained -/// `genvm_modules_interfaces` payload types. -/// -/// These types are structurally identical but distinct: `genvm-modules-interfaces` -/// no longer depends on `sdk-rs`, so it carries its own copies. The orphan rule -/// prevents `From`/`Into` impls here (both types are foreign to this crate), so -/// free functions bridge the executor<->module boundary instead. -mod gl_call_to_mi { - use genlayer_sdk::abi::gl_call::{llm_iface, web_iface}; - use genvm_modules_interfaces::{llm as mi_llm, web as mi_web}; - - pub fn render_mode(m: web_iface::RenderMode) -> mi_web::RenderMode { - match m { - web_iface::RenderMode::Text => mi_web::RenderMode::Text, - web_iface::RenderMode::HTML => mi_web::RenderMode::HTML, - web_iface::RenderMode::Screenshot => mi_web::RenderMode::Screenshot, - } - } - - pub fn wait_after_loaded(w: web_iface::WaitAfterLoaded) -> mi_web::WaitAfterLoaded { - match w { - web_iface::WaitAfterLoaded::Seconds(s) => mi_web::WaitAfterLoaded::Seconds(s), - web_iface::WaitAfterLoaded::Millis(ms) => mi_web::WaitAfterLoaded::Millis(ms), - } - } - - pub fn render_payload(p: web_iface::RenderPayload) -> mi_web::RenderPayload { - mi_web::RenderPayload { - mode: render_mode(p.mode), - url: p.url, - wait_after_loaded: wait_after_loaded(p.wait_after_loaded), - } - } - - pub fn request_method(m: web_iface::RequestMethod) -> mi_web::RequestMethod { - match m { - web_iface::RequestMethod::GET => mi_web::RequestMethod::GET, - web_iface::RequestMethod::POST => mi_web::RequestMethod::POST, - web_iface::RequestMethod::HEAD => mi_web::RequestMethod::HEAD, - web_iface::RequestMethod::PUT => mi_web::RequestMethod::PUT, - web_iface::RequestMethod::DELETE => mi_web::RequestMethod::DELETE, - web_iface::RequestMethod::OPTIONS => mi_web::RequestMethod::OPTIONS, - web_iface::RequestMethod::PATCH => mi_web::RequestMethod::PATCH, - } - } - - pub fn request_payload(p: web_iface::RequestPayload) -> mi_web::RequestPayload { - mi_web::RequestPayload { - method: request_method(p.method), - url: p.url, - headers: p.headers, - body: p.body, - sign: p.sign, - } - } - - pub fn output_format(f: llm_iface::OutputFormat) -> mi_llm::OutputFormat { - match f { - llm_iface::OutputFormat::Text => mi_llm::OutputFormat::Text, - llm_iface::OutputFormat::JSON => mi_llm::OutputFormat::JSON, - } - } - - pub fn prompt_payload(p: llm_iface::PromptPayload) -> mi_llm::PromptPayload { - mi_llm::PromptPayload { - response_format: output_format(p.response_format), - prompt: p.prompt, - images: p.images, - } - } - - pub fn prompt_template_payload( - p: llm_iface::PromptTemplatePayload, - ) -> mi_llm::PromptTemplatePayload { - match p { - llm_iface::PromptTemplatePayload::EqComparative(x) => { - mi_llm::PromptTemplatePayload::EqComparative(mi_llm::PromptEqComparativePayload { - leader_answer: x.leader_answer, - validator_answer: x.validator_answer, - principle: x.principle, - }) - } - llm_iface::PromptTemplatePayload::EqNonComparativeValidator(x) => { - mi_llm::PromptTemplatePayload::EqNonComparativeValidator( - mi_llm::PromptEqNonComparativeValidatorPayload { - task: x.task, - criteria: x.criteria, - input: x.input, - output: x.output, - }, - ) - } - llm_iface::PromptTemplatePayload::EqNonComparativeLeader(x) => { - mi_llm::PromptTemplatePayload::EqNonComparativeLeader( - mi_llm::PromptEqNonComparativeLeaderPayload { - task: x.task, - criteria: x.criteria, - input: x.input, - }, - ) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use primitive_types::U256; - - fn valid_params() -> domain::fees::InternalMessageParams { - domain::fees::InternalMessageParams { - leader_timeunits_allocation: U256::from(5), - validator_timeunits_allocation: U256::from(5), - execution_budget_per_round: U256::from(1024), - rotations: vec![U256::from(4); 5], - max_price_gen_per_time_unit: U256::from(2), - storage_fee_max_gas_price: U256::from(20), - receipt_fee_max_gas_price: U256::from(20), - } - } - - fn errno(e: generated::types::Error) -> generated::types::Errno { - e.downcast().expect("expected a plain errno, got a trap") - } - - #[test] - fn balance_no_permission_is_forbidden() { - let err = validate_balance_fee(false, true, Some(valid_params())).unwrap_err(); - assert_eq!(errno(err), generated::types::Errno::Forbidden); - } - - #[test] - fn balance_without_params_is_inval() { - let err = validate_balance_fee(true, true, None).unwrap_err(); - assert_eq!(errno(err), generated::types::Errno::Inval); - } - - #[test] - fn params_without_use_balance_is_inval() { - let err = validate_balance_fee(true, false, Some(valid_params())).unwrap_err(); - assert_eq!(errno(err), generated::types::Errno::Inval); - } - - #[test] - fn empty_rotations_is_inval() { - let mut p = valid_params(); - p.rotations.clear(); - let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); - assert_eq!(errno(err), generated::types::Errno::Inval); - } - - #[test] - fn zero_price_caps_are_inval() { - for mutate in [ - (|p: &mut domain::fees::InternalMessageParams| { - p.max_price_gen_per_time_unit = U256::zero() - }) as fn(&mut domain::fees::InternalMessageParams), - |p| p.storage_fee_max_gas_price = U256::zero(), - |p| p.receipt_fee_max_gas_price = U256::zero(), - ] { - let mut p = valid_params(); - mutate(&mut p); - let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); - assert_eq!(errno(err), generated::types::Errno::Inval); - } - } - - #[test] - fn huge_magnitude_params_are_inval() { - // Security-review N1 repro: passes the emptiness/zero checks, but the - // 2^250 magnitudes would push messageFeeFloor past U256 and trip the - // evaluator's internal `fee cost exceeds U256 range` abort. - let p = domain::fees::InternalMessageParams { - leader_timeunits_allocation: U256::one() << 250, - validator_timeunits_allocation: U256::zero(), - execution_budget_per_round: U256::zero(), - rotations: vec![U256::zero()], - max_price_gen_per_time_unit: U256::one() << 250, - storage_fee_max_gas_price: U256::from(20), - receipt_fee_max_gas_price: U256::from(20), - }; - let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); - assert_eq!(errno(err), generated::types::Errno::Inval); - } - - #[test] - fn huge_rotations_entry_is_inval() { - let mut p = valid_params(); - p.rotations[2] = U256::one() << 250; - let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); - assert_eq!(errno(err), generated::types::Errno::Inval); - } - - #[test] - fn params_at_magnitude_bounds_pass() { - let mut p = valid_params(); - p.max_price_gen_per_time_unit = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; - p.storage_fee_max_gas_price = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; - p.receipt_fee_max_gas_price = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; - p.execution_budget_per_round = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; - p.leader_timeunits_allocation = (U256::one() << FEE_PARAM_COUNT_BITS) - 1; - p.validator_timeunits_allocation = (U256::one() << FEE_PARAM_COUNT_BITS) - 1; - p.rotations = vec![(U256::one() << FEE_PARAM_COUNT_BITS) - 1; 5]; - let got = validate_balance_fee(true, true, Some(p.clone())).unwrap(); - assert_eq!(got, Some(p)); - } - - #[test] - fn valid_balance_params_pass_through() { - let p = valid_params(); - let got = validate_balance_fee(true, true, Some(p.clone())).unwrap(); - assert_eq!(got, Some(p)); - } - - #[test] - fn no_balance_no_params_is_allocation_path() { - let got = validate_balance_fee(true, false, None).unwrap(); - assert_eq!(got, None); - } -} diff --git a/executor/src/wasi/genlayer_sdk/message.rs b/executor/src/wasi/genlayer_sdk/message.rs new file mode 100644 index 00000000..3d4e52fe --- /dev/null +++ b/executor/src/wasi/genlayer_sdk/message.rs @@ -0,0 +1,822 @@ +use super::*; +use crate::rt::errors::ResultExt as _; +use genlayer_calldata::codec::Encode; + +/// Named arguments for [`consume_message_fee_internal`]. +struct ConsumeInternalArgs { + is_deploy: bool, + calldata_length: u64, + code_length: u64, + subtree_length: u64, +} + +/// How an internal message's fee is funded. +enum FeeFunding<'a> { + /// Sender-pool allocation: fee capped by `node.budget`; consumes the + /// message-fee and receipt buckets. + Allocation(&'a mut domain::fees::MessageAllocationNode), + /// Balance-funded (`useBalance`): the metered fee is the `declaredBudget`, + /// reserved from the contract balance. On-chain such messages are excluded + /// from the sender pool, so the message-fee bucket is skipped and only the + /// receipt is charged. The balance is checked here, before the receipt is + /// consumed, so nothing is charged unless funding is guaranteed. + Balance { + value: primitive_types::U256, + messages_value_decremented: primitive_types::U256, + my_balance: primitive_types::U256, + }, +} + +async fn consume_message_fee_internal( + shared_data: &rt::SharedData, + funding: FeeFunding<'_>, + fee_params: Arc, + on: gl_call::On, + args: ConsumeInternalArgs, +) -> Result { + let balance_funded = matches!(funding, FeeFunding::Balance { .. }); + let fee_cost = shared_data + .data_fees_limit + .calculate_message_fee_internal(on, balance_funded, &fee_params) + .map_err(internal_trap)?; + let fee_total = fee_cost.reported_fee(); + + let receipt_cost = shared_data + .data_fees_limit + .calculate_message_receipt(rt::fees::MessageReceiptParams { + is_internal: true, + is_deploy: args.is_deploy, + calldata_length: args.calldata_length, + code_length: args.code_length, + subtree_length: args.subtree_length, + }) + .ctx("calculate_message_receipt") + .map_err(internal_trap)?; + + match funding { + FeeFunding::Allocation(node) => { + if fee_total > node.budget { + log_warn!( + node:cd = *node, + fee_cost:cd = fee_total, + budget: cd = node.budget; + "message fee cost exceeds node budget" + ); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee().node(), + host_fns::VmErrorDetail::internal(), + ))); + } + + if !shared_data + .data_fees_limit + .consume_message_fee(&fee_cost, &receipt_cost) + .await + { + log_warn!( + node:cd = *node, + fee_cost:cd = fee_total, + buckets:? = shared_data.data_fees_limit; + "not enough remaining fee limit to consume message fee" + ); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee().total(), + host_fns::VmErrorDetail::internal(), + ))); + } + + node.budget -= fee_total; + } + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + } => { + // `value + fee_total` can itself overflow U256. + let Some(total) = value.checked_add(fee_total) else { + return Err(generated::types::Errno::Inbalance.into()); + }; + if !checked_sum_le(total, messages_value_decremented, my_balance) { + log_warn!( + fee_cost:cd = fee_total, + value:cd = value, + my_balance:cd = my_balance; + "insufficient balance to fund balance-funded message fee" + ); + return Err(generated::types::Errno::Inbalance.into()); + } + + if !shared_data + .data_fees_limit + .consume_message_receipt_only(&receipt_cost) + .await + { + log_warn!( + receipt_cost:cd = receipt_cost.reported_fee(), + buckets:? = shared_data.data_fees_limit; + "not enough remaining receipt limit to consume message receipt" + ); + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().receipt().message(), + host_fns::VmErrorDetail::internal(), + ))); + } + } + } + + Ok(rt::fees::MessageFeeConsumption { + message_fee: fee_cost, + receipt_fee: receipt_cost, + }) +} + +/// Named arguments for [`consume_message_fee_external`]. +struct ConsumeExternalArgs { + is_deploy: bool, + calldata_length: u64, +} + +async fn consume_message_fee_external( + shared_data: &rt::SharedData, + node: &mut domain::fees::MessageAllocationNode, + params: domain::fees::ExternalMessageParams, + // External messages are always emitted on finalization; carried for signature + // symmetry with the internal path. + _on: gl_call::On, + args: ConsumeExternalArgs, +) -> Result { + let fee_cost = shared_data + .data_fees_limit + .calculate_message_fee_external(¶ms) + .map_err(internal_trap)?; + + let fee_total = fee_cost.reported_fee(); + if fee_total > node.budget { + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee().node(), + host_fns::VmErrorDetail::external(), + ))); + } + + let receipt_cost = shared_data + .data_fees_limit + .calculate_message_receipt(rt::fees::MessageReceiptParams { + is_internal: false, + is_deploy: args.is_deploy, + calldata_length: args.calldata_length, + code_length: 0, + subtree_length: 0, + }) + .map_err(internal_trap)?; + + if !shared_data + .data_fees_limit + .consume_message_fee(&fee_cost, &receipt_cost) + .await + { + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::out_of().message_fee().total(), + host_fns::VmErrorDetail::external(), + ))); + } + + node.budget -= fee_total; + + Ok(rt::fees::MessageFeeConsumption { + message_fee: fee_cost, + receipt_fee: receipt_cost, + }) +} + +/// Magnitude bounds (in significant bits; a larger field is rejected) on +/// guest-supplied fee params. Invariant the code cannot express: the worst-case +/// `messageFeeFloor` product must stay within U256, since the fee evaluator's +/// `rational_to_u256` treats overflow as an internal abort. Three guest fields +/// multiply into one floor term (`maxPrice × rotations entry × validatorTU`), +/// so with the default 18-round validator table (counts ≤ 1537 < 2^11) the +/// accepted-lifecycle worst case is +/// lifecycle(<2^4) × [price(<2^96) × rounds(<2^5) × rot(<2^33) +/// × (leaderTU + vpr × validatorTU)(<2^44) +/// + price(<2^96) × leaderRounds(<2^36)] +/// < 2^183 ≪ 2^256. +/// Economically generous: 2^96 atto-GEN ≈ 8e10 GEN for prices/budgets; 2^32 +/// for counts (time units per phase, rotations per round). +pub(super) const FEE_PARAM_PRICE_BITS: usize = 96; +pub(super) const FEE_PARAM_COUNT_BITS: usize = 32; + +/// Validates the balance-funded fee fields (`use_balance` / `fee_params`) shared +/// by `PostMessage` and `DeployContract` (GVM-281 §4.2). Returns the fee params +/// to meter against the contract balance when `use_balance` is set, or `None` +/// for the ordinary allocation-matched path. +/// +/// Guest params are structurally validated so no guest input can drive the fee +/// evaluator into an internal abort or emit a message the chain would reject at +/// reveal: `rotations` must be non-empty (the yaml floor derives +/// `appealRounds = len - 1` and would go negative otherwise), the three +/// price caps must be non-zero (the chain reverts `FeeValueMustBeNonZero(4/5/6)` +/// at reveal), and every numeric field — rotations entries included — is +/// bounded by [`FEE_PARAM_PRICE_BITS`] / [`FEE_PARAM_COUNT_BITS`] so the +/// metered floor fits in U256. The rotations-length upper bound is +/// node-config-dependent (validator table length) and is enforced in the yaml +/// fee expression instead. +pub(super) fn validate_balance_fee( + can_use_balance: bool, + use_balance: bool, + fee_params: Option, +) -> Result, generated::types::Error> { + match (use_balance, fee_params) { + (true, _) if !can_use_balance => { + log_debug!("balance-funded message rejected: permission absent (Forbidden)"); + Err(generated::types::Errno::Forbidden.into()) + } + (true, None) => { + // this is a feature for future + log_debug!("balance-funded message rejected: fee_params absent (Inval)"); + Err(generated::types::Errno::Inval.into()) + } + (true, Some(params)) => { + if params.rotations.is_empty() { + log_debug!("balance-funded message rejected: rotations empty (Inval)"); + return Err(generated::types::Errno::Inval.into()); + } + if params.max_price_gen_per_time_unit.is_zero() + || params.storage_fee_max_gas_price.is_zero() + || params.receipt_fee_max_gas_price.is_zero() + { + log_debug!("balance-funded message rejected: zero price cap (Inval)"); + return Err(generated::types::Errno::Inval.into()); + } + let too_large = params.max_price_gen_per_time_unit.bits() > FEE_PARAM_PRICE_BITS + || params.storage_fee_max_gas_price.bits() > FEE_PARAM_PRICE_BITS + || params.receipt_fee_max_gas_price.bits() > FEE_PARAM_PRICE_BITS + || params.execution_budget_per_round.bits() > FEE_PARAM_PRICE_BITS + || params.leader_timeunits_allocation.bits() > FEE_PARAM_COUNT_BITS + || params.validator_timeunits_allocation.bits() > FEE_PARAM_COUNT_BITS + || params + .rotations + .iter() + .any(|r| r.bits() > FEE_PARAM_COUNT_BITS); + if too_large { + log_debug!("balance-funded message rejected: fee param too large (Inval)"); + return Err(generated::types::Errno::Inval.into()); + } + Ok(Some(params)) + } + (false, Some(_)) => { + log_debug!("message rejected: fee_params without use_balance (Inval)"); + Err(generated::types::Errno::Inval.into()) + } + (false, None) => Ok(None), + } +} + +impl ContextVFS<'_> { + pub(super) async fn gl_call_eth_send( + &mut self, + address: calldata::Address, + calldata: bytes::Bytes, + value: primitive_types::U256, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.send_messages { + return Err(generated::types::Errno::Forbidden.into()); + } + + if !value.is_zero() { + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + + if !checked_sum_le( + value, + self.context.data.accumulator.messages_value_decremented, + my_balance, + ) { + return Err(generated::types::Errno::Inbalance.into()); + } + } + + let mut call_key = abi::CallKey([0u8; 32]); + if calldata.len() < 4 { + log_warn!(len = calldata.len(); "calldata too short for method selector, using unnamed call key"); + } else { + call_key.0[..4].copy_from_slice(&calldata[..4]); + } + + let Some((matched_node, matched_params)) = self + .context + .data + .accumulator + .message_fee_allocation + .iter_mut() + .find_map(|node| { + node.matches_external(address, call_key) + .map(|params| (node, params)) + }) + else { + log_warn!( + recipient = address, + call_key:? = call_key; + "no matching node for message fee allocation" + ); + + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::external(), + ))); + }; + + let calldata_length = calldata.len() as u64; + + let fees = consume_message_fee_external( + &self.context.data.supervisor.shared_data, + matched_node, + matched_params, + gl_call::On::Finalized, + ConsumeExternalArgs { + is_deploy: false, + calldata_length, + }, + ) + .await?; + + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::EthSend { + address, + calldata, + value, + message_fee: fees.message_fee.reported_fee(), + receipt_fee: fees.receipt_fee.reported_fee(), + fee_params: matched_params, + }); + + self.context.data.accumulator.messages_value_decremented = self + .context + .data + .accumulator + .messages_value_decremented + .saturating_add(value); + Ok(file_fd_none()) + } + pub(super) async fn gl_call_emit_event( + &mut self, + topics: Vec, + blob: calldata::unparsed::Maybe>, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + log_warn!("EmitEvent requires deterministic mode"); + + return Err(generated::types::Errno::Forbidden.into()); + } + // Events are state-mutating log emissions, so they require the + // same write capability as storage. A read-only sub-VM (e.g. a + // `CallContract` child) must not emit events; otherwise the + // emission is charged but later discarded with the child. + if !self.context.data.conf.permissions.write_storage { + log_warn!("EmitEvent requires write_storage permission"); + + return Err(generated::types::Errno::Forbidden.into()); + } + + if topics.len() > public_abi::EVENT_MAX_TOPICS as usize { + log_warn!(cnt = topics.len(), max = public_abi::EVENT_MAX_TOPICS; "too many topics"); + return Err(generated::types::Errno::Inval.into()); + } + + let mut real_topics: Vec = + Vec::with_capacity(public_abi::EVENT_MAX_TOPICS as usize + 1); + + for t in topics.iter() { + if t.len() != 32 { + log_warn!(len = t.len(); "invalid topic length"); + + return Err(generated::types::Errno::Inval.into()); + } + + real_topics.push(t.clone()); + } + + struct CountingWriter(usize); + impl calldata::Writer for CountingWriter { + type Error = std::convert::Infallible; + + fn write_all(&mut self, data: &[u8]) -> Result<(), Self::Error> { + self.0 += data.len(); + Ok(()) + } + } + + let mut enc = calldata::Encoder::new(CountingWriter(0)); + blob.encode(&mut enc).unwrap_or_else(|e| match e {}); + let blob_size = enc.into_inner().0 as u64; + + let supervisor = self.context.data.supervisor.clone(); + let topics_count = topics.len() as u64; + + let storage_fee = supervisor + .shared_data + .data_fees_limit + .consume_event(blob_size, topics_count) + .await + .map_err(internal_trap)? + .ok_or_else(|| { + internal_trap(rt::errors::Error::vm( + abi::consts::VmError::out_of().receipt().event(), + )) + })?; + + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::EmitEvent { + topics: real_topics, + blob, + storage_fee, + }); + + Ok(file_fd_none()) + } + pub(super) async fn gl_call_post_message( + &mut self, + address: calldata::Address, + calldata: calldata::unparsed::Maybe, + value: primitive_types::U256, + on: gl_call::On, + use_balance: bool, + fee_params: Option, + ) -> Result { + log_debug!( + recipient = address, + on:? = on, + use_balance = use_balance; + "PostMessage dispatched" + ); + if !self.context.data.conf.permissions.deterministic { + log_debug!("PostMessage rejected: non-deterministic context (Forbidden)"); + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.send_messages { + log_debug!("PostMessage rejected: can_send_messages=false (Forbidden)"); + return Err(generated::types::Errno::Forbidden.into()); + } + + let balance_params = validate_balance_fee( + self.context + .data + .conf + .permissions + .can_use_balance_for_message_fees, + use_balance, + fee_params, + )?; + + // Peek the (possibly deferred) calldata to derive the fee call key. + let calldata_materialized = calldata.clone().materialize().ok(); + let method_name = calldata_materialized + .as_ref() + .and_then(|x| x.as_map()) + .and_then(|x| x.get("")) + .and_then(|x| x.as_str()); + let call_key = if let Some(method_name) = method_name { + abi::CallKey::for_method(method_name) + } else { + abi::CallKey::UNNAMED + }; + + if let Some(params) = balance_params { + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; + + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + let messages_value_decremented = + self.context.data.accumulator.messages_value_decremented; + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + }, + Arc::new(params.clone()), + on, + ConsumeInternalArgs { + is_deploy: false, + calldata_length, + code_length: 0, + subtree_length: 0, + }, + ) + .await?; + + let metered_fee = fees.message_fee.reported_fee(); + + // Empty subtree: use_balance nesting is fail-closed on-chain. + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::PostMessage { + call_key, + address, + calldata, + value, + on, + message_fee: metered_fee, + receipt_fee: fees.receipt_fee.reported_fee(), + fee_params: params, + subtree: bytes::Bytes::new(), + use_balance: true, + }); + + self.context.data.accumulator.messages_value_decremented = messages_value_decremented + .saturating_add(value) + .saturating_add(metered_fee); + + return Ok(file_fd_none()); + } + + if !value.is_zero() { + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + + if !checked_sum_le( + value, + self.context.data.accumulator.messages_value_decremented, + my_balance, + ) { + return Err(generated::types::Errno::Inbalance.into()); + } + } + + let Some((matched_node, matched_params)) = self + .context + .data + .accumulator + .message_fee_allocation + .iter_mut() + .find_map(|node| { + node.matches_internal(on, address, call_key) + .map(|params| (node, params)) + }) + else { + log_warn!( + recipient = address, + call_key:? = call_key, + on:? = on; + "no matching node for message fee allocation" + ); + + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::internal(), + ))); + }; + + log_debug!( + recipient = address, + call_key:? = call_key, + on:? = on; + "PostMessage matched fee allocation node" + ); + + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; + + let fee_params = (*matched_params).clone(); + let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( + &matched_node.children, + )); + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Allocation(matched_node), + matched_params, + on, + ConsumeInternalArgs { + is_deploy: false, + calldata_length, + code_length: 0, + subtree_length: subtree.len() as u64, + }, + ) + .await?; + + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::PostMessage { + call_key, + address, + calldata, + value, + on, + message_fee: fees.message_fee.reported_fee(), + receipt_fee: fees.receipt_fee.reported_fee(), + fee_params, + subtree, + use_balance: false, + }); + + log_debug!( + depth = self.context.data.depth, + emissions_total = self.context.data.accumulator.emissions.len(); + "PostMessage emission pushed to accumulator" + ); + + self.context.data.accumulator.messages_value_decremented = self + .context + .data + .accumulator + .messages_value_decremented + .saturating_add(value); + + Ok(file_fd_none()) + } + pub(super) async fn gl_call_deploy_contract( + &mut self, + calldata: calldata::unparsed::Maybe, + code: bytes::Bytes, + value: primitive_types::U256, + on: gl_call::On, + salt_nonce: primitive_types::U256, + use_balance: bool, + fee_params: Option, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.send_messages { + return Err(generated::types::Errno::Forbidden.into()); + } + + let balance_params = validate_balance_fee( + self.context + .data + .conf + .permissions + .can_use_balance_for_message_fees, + use_balance, + fee_params, + )?; + + if let Some(params) = balance_params { + let code_length = code.len() as u64; + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; + + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + let messages_value_decremented = + self.context.data.accumulator.messages_value_decremented; + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Balance { + value, + messages_value_decremented, + my_balance, + }, + Arc::new(params.clone()), + on, + ConsumeInternalArgs { + is_deploy: true, + calldata_length, + code_length, + subtree_length: 0, + }, + ) + .await?; + + let metered_fee = fees.message_fee.reported_fee(); + + self.context.data.accumulator.emissions.push( + domain::ExecutionEmission::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + receipt_fee: fees.receipt_fee.reported_fee(), + message_fee: metered_fee, + fee_params: params, + subtree: bytes::Bytes::new(), + use_balance: true, + }, + ); + + self.context.data.accumulator.messages_value_decremented = messages_value_decremented + .saturating_add(value) + .saturating_add(metered_fee); + + return Ok(file_fd_none()); + } + + if !value.is_zero() { + let my_balance = self + .context + .get_balance_impl(self.context.data.message_data.message.contract_address) + .await?; + + if !checked_sum_le( + value, + self.context.data.accumulator.messages_value_decremented, + my_balance, + ) { + return Err(generated::types::Errno::Inbalance.into()); + } + } + + let Some((matched_node, matched_params)) = self + .context + .data + .accumulator + .message_fee_allocation + .iter_mut() + .find_map(|node| { + node.matches_internal(on, calldata::Address::zero(), abi::CallKey::DEPLOY) + .map(|params| (node, params)) + }) + else { + log_warn!( + recipient = calldata::Address::zero(), + call_key:? = abi::CallKey::DEPLOY, + on:? = on; + "no matching node for message fee allocation" + ); + + return Err(internal_trap(rt::errors::Error::vm_detailed( + abi::consts::VmError::fee().no_matching_node(), + host_fns::VmErrorDetail::internal(), + ))); + }; + + let code_length = code.len() as u64; + let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); + calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); + let calldata_length = enc.into_inner().0; + + let fee_params = (*matched_params).clone(); + let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( + &matched_node.children, + )); + + let fees = consume_message_fee_internal( + &self.context.data.supervisor.shared_data, + FeeFunding::Allocation(matched_node), + matched_params, + on, + ConsumeInternalArgs { + is_deploy: true, + calldata_length, + code_length, + subtree_length: subtree.len() as u64, + }, + ) + .await?; + + self.context + .data + .accumulator + .emissions + .push(domain::ExecutionEmission::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + receipt_fee: fees.receipt_fee.reported_fee(), + message_fee: fees.message_fee.reported_fee(), + fee_params, + subtree, + use_balance: false, + }); + + self.context.data.accumulator.messages_value_decremented = self + .context + .data + .accumulator + .messages_value_decremented + .saturating_add(value); + + Ok(file_fd_none()) + } +} diff --git a/executor/src/wasi/genlayer_sdk/mod.rs b/executor/src/wasi/genlayer_sdk/mod.rs new file mode 100644 index 00000000..62bad4f2 --- /dev/null +++ b/executor/src/wasi/genlayer_sdk/mod.rs @@ -0,0 +1,1262 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use genvm_common::sync::DArc; +use genvm_common::*; + +use genvm_modules_interfaces::GenericValue; +use wiggle::GuestError; + +use crate::host::{self, SlotID}; +use crate::{anyhow_to_wasmtime, calldata, public_abi, rt, wasi}; + +pub use genlayer_sdk::abi::entry::ExtendedMessage; +use genlayer_sdk::abi::{self, gl_call}; + +use super::{base, vfs}; + +mod message; +mod run; +#[cfg(test)] +mod tests; + +fn default_entry_stage_data() -> calldata::Value { + calldata::Value::Null +} + +fn internal_trap(error: rt::errors::Error) -> generated::types::Error { + generated::types::Error::trap(anyhow_to_wasmtime(error.into())) +} + +/// Extension methods for ExtendedMessage specific to the executor +pub trait ExtendedMessageExt { + fn fork_leader( + &self, + entry_kind: public_abi::EntryKind, + entry_data: bytes::Bytes, + entry_leader_data: Option, + ) -> ExtendedMessage; + + fn fork(&self, entry_kind: public_abi::EntryKind, entry_data: bytes::Bytes) -> ExtendedMessage; +} + +impl ExtendedMessageExt for ExtendedMessage { + fn fork_leader( + &self, + entry_kind: public_abi::EntryKind, + entry_data: bytes::Bytes, + entry_leader_data: Option, + ) -> ExtendedMessage { + use genlayer_sdk::abi::entry::MessageData; + + let entry_leader_data = match entry_leader_data { + None => default_entry_stage_data(), + Some(entry_leader_data) => calldata::Value::Map(BTreeMap::from([( + "leaders_result".into(), + calldata::Value::Bytes(entry_leader_data.as_bytes()), + )])), + }; + + ExtendedMessage { + message: MessageData { + contract_address: self.message.contract_address, + sender_address: self.message.sender_address, + origin_address: self.message.origin_address, + signer_address: self.message.signer_address, + stack: self.message.stack.clone(), + chain_id: self.message.chain_id.clone(), + value: self.message.value.clone(), + is_init: false, + datetime: self.message.datetime, + }, + entry_kind, + entry_data, + entry_stage_data: entry_leader_data, + } + } + + fn fork(&self, entry_kind: public_abi::EntryKind, entry_data: bytes::Bytes) -> ExtendedMessage { + self.fork_leader(entry_kind, entry_data, None) + } +} + +#[derive(Clone)] +pub struct ReadToken { + pub mode: public_abi::StorageType, + pub account: calldata::Address, +} + +pub struct StorageHostLock<'a>(tokio::sync::MutexGuard<'a, host::Host>, ReadToken); + +impl rt::vm::storage::HostStorage for StorageHostLock<'_> { + fn storage_read(&mut self, slot_id: SlotID, index: u32, buf: &mut [u8]) -> anyhow::Result<()> { + self.0 + .storage_read(self.1.mode, self.1.account, slot_id, index, buf) + } +} + +#[derive(Clone)] +pub struct StorageHostHolder(pub Arc, pub ReadToken); + +impl rt::vm::storage::HostStorageLocking for StorageHostHolder { + type ReturnType<'a> = StorageHostLock<'a>; + + async fn lock(&self) -> Self::ReturnType<'_> { + StorageHostLock( + self.0.lock_for(host::host_fns::Methods::StorageRead).await, + self.1.clone(), + ) + } +} + +pub struct VMDataAccumulator { + pub data_fees_limit: DArc, + pub messages_value_decremented: primitive_types::U256, + pub emissions: Vec, + pub message_fee_allocation: Vec, +} + +impl VMDataAccumulator { + /// Asserts the accumulator carries no surfaced effects (events, + /// message-fee allocations). Call it after running a sub-VM whose + /// accumulator is discarded (e.g. a `CallContract` child) to guarantee no + /// effect was charged but silently dropped. Returns a fatal error otherwise. + pub fn check_empty(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.emissions.is_empty() && self.message_fee_allocation.is_empty(), + "internal error: discarded sub-VM accumulator is not empty ({} emission(s), {} message-fee allocation(s))", + self.emissions.len(), + self.message_fee_allocation.len(), + ); + Ok(()) + } +} + +pub struct SingleVMData { + pub conf: base::Config, + pub depth: u32, + pub spawn_kind: String, + pub message_data: ExtendedMessage, + pub supervisor: Arc, + pub storage: rt::vm::storage::Storage, + pub accumulator: VMDataAccumulator, + pub det_subvm_hashes: sha3::Sha3_256, + /// `custom:` runner pins granted to this VM by its parent, carried from the + /// spawning gl_call (so the content survives the parent's death for a queued + /// nondet VM). The child's spawn performs a load action for each, charging its + /// own limiter, before its main runner loads (ADR-012 §1/§4). Empty for the + /// root VM. Drained at spawn. + pub granted_custom: Vec, +} + +pub struct Context { + pub data: SingleVMData, + + /// This VM's **loaded set**: the charge-dedup set and pin holder for every + /// runner it has loaded (ADR-012 §1). A sibling of `data` (not inside its + /// accumulator), so it does not round-trip through sandbox children and is + /// dropped when this VM's store is torn down — coinciding with the VM + /// limiter's refund of the load charges. + pub loaded: crate::runners::cache::LoadedSet, + + /// A handle to this VM's own memory limiter (a clone of its store limiter, so + /// it refunds at VM death). Runtime load actions (`RegisterRunner`, `MapFile`) + /// charge it directly, since a gl_call handler cannot otherwise reach the + /// store limiter. + pub limiter: rt::memlimiter::Limiter, + + pub start_time: std::time::Instant, + pub prev_time: std::time::Instant, +} + +pub struct ContextVFS<'a> { + pub(super) vfs: &'a mut vfs::VFS, + pub(super) preview1: &'a mut super::preview1::Context, + pub(super) context: &'a mut Context, +} + +async fn spawn_sub_vm( + supervisor: Arc, + vm_data: Box, +) -> anyhow::Result { + tokio::task::spawn(async move { rt::spawn_apply_run(&supervisor, vm_data).await }) + .await + .map_err(|e| anyhow::anyhow!("sub-VM task failed to join: {e}"))? +} + +#[allow(clippy::too_many_arguments)] +pub(crate) mod generated { + wiggle::from_witx!({ + witx: ["$CARGO_MANIFEST_DIR/src/wasi/witx/genlayer_sdk.witx"], + errors: { errno => trappable Error }, + wasmtime: false, + tracing: false, + + async: { + genlayer_sdk::{ + gl_call, + storage_read, storage_write, + get_balance, get_self_balance, + } + }, + }); + + wiggle::wasmtime_integration!({ + witx: ["$CARGO_MANIFEST_DIR/src/wasi/witx/genlayer_sdk.witx"], + errors: { errno => trappable Error }, + target: self, + tracing: false, + + async: { + genlayer_sdk::{ + gl_call, + storage_read, storage_write, + get_balance, get_self_balance, + } + }, + }); +} + +impl From for generated::types::Fd { + fn from(fd: wasi::vfs::Fd) -> Self { + fd.as_u32().into() + } +} + +impl From for wasi::vfs::Fd { + fn from(fd: generated::types::Fd) -> Self { + wasi::vfs::Fd::new(fd.into()) + } +} + +fn read_addr_from_mem( + mem: &mut wiggle::GuestMemory<'_>, + addr: wiggle::GuestPtr, +) -> Result { + let cow = mem.as_cow( + addr.as_array( + calldata::ADDRESS_SIZE + .try_into() + .expect("ADDRESS_SIZE exceeds target type"), + ), + )?; + let mut ret = calldata::Address::zero(); + ret.ref_mut().copy_from_slice(&cow); + Ok(ret) +} + +impl SlotID { + fn read_from_mem( + mem: &mut wiggle::GuestMemory<'_>, + addr: wiggle::GuestPtr, + ) -> Result { + let cow = mem.as_cow( + addr.as_array( + SlotID::len() + .try_into() + .expect("SlotID::len exceeds target type"), + ), + )?; + let mut ret = SlotID::zero(); + for (x, y) in ret.0.iter_mut().zip(cow.iter()) { + *x = *y; + } + Ok(ret) + } +} + +fn read_owned_vec( + mem: &mut wiggle::GuestMemory<'_>, + ptr: wiggle::GuestPtr<[u8]>, +) -> Result, generated::types::Error> { + Ok(mem.as_cow(ptr)?.into_owned()) +} + +impl Context { + pub fn new(data: Box, limiter: rt::memlimiter::Limiter) -> Self { + let now = std::time::Instant::now(); + + // Every VM's config passes through here. Invariant: only a `Default`-state + // VM reads through its local storage cache (read-your-writes); a VM with a + // non-`Default` state_mode reads bypass the cache, so it must not be able to + // write (otherwise its writes would hit the cache but never be read back). + debug_assert!( + data.conf.execution.state_mode == public_abi::StorageType::Default + || !data.conf.permissions.write_storage, + "a VM with state_mode != Default must not have can_write_storage" + ); + + Self { + data: *data, + loaded: Default::default(), + limiter, + start_time: now, + prev_time: now, + } + } +} + +impl wiggle::GuestErrorType for generated::types::Errno { + fn success() -> Self { + Self::Success + } +} + +pub trait AddToLinkerFn { + fn call<'a>(&self, arg: &'a mut T) -> ContextVFS<'a>; +} + +pub(super) fn add_to_linker_sync( + linker: &mut wasmtime::Linker, + f: F, +) -> anyhow::Result<()> +where + F: AddToLinkerFn + Copy + Send + Sync + 'static, +{ + #[derive(Clone, Copy)] + struct Fwd(F); + + impl generated::AddGenlayerSdkToLinkerFn for Fwd + where + F: AddToLinkerFn + Copy + Send + Sync + 'static, + { + fn call(&self, arg: &mut T) -> impl generated::genlayer_sdk::GenlayerSdk { + self.0.call(arg) + } + } + generated::add_genlayer_sdk_to_linker(linker, Fwd(f))?; + Ok(()) +} + +#[derive(Debug)] +pub struct ContractReturn(pub calldata::unparsed::Maybe); + +impl std::error::Error for ContractReturn {} + +impl std::fmt::Display for ContractReturn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Returned {:?}", self.0) + } +} + +impl From for generated::types::Error { + fn from(err: GuestError) -> Self { + use wiggle::GuestError::*; + match err { + InvalidFlagValue { .. } => generated::types::Errno::Inval.into(), + InvalidEnumValue { .. } => generated::types::Errno::Inval.into(), + // As per + // https://github.com/WebAssembly/wasi/blob/main/legacy/tools/witx-docs.md#pointers + // + // > If a misaligned pointer is passed to a function, the function + // > shall trap. + // > + // > If an out-of-bounds pointer is passed to a function and the + // > function needs to dereference it, the function shall trap. + // + // so this turns OOB and misalignment errors into traps. + PtrOverflow | PtrOutOfBounds { .. } | PtrNotAligned { .. } => { + generated::types::Error::trap(crate::anyhow_to_wasmtime(err.into())) + } + InvalidUtf8 { .. } => generated::types::Errno::Ilseq.into(), + TryFromIntError { .. } => generated::types::Errno::Overflow.into(), + SliceLengthsDiffer => generated::types::Errno::Fault.into(), + InFunc { err, .. } => generated::types::Error::from(*err), + MemoryNotExported => generated::types::Errno::Fault.into(), + } + } +} + +impl From for generated::types::Error { + fn from(_err: std::num::TryFromIntError) -> Self { + generated::types::Errno::Overflow.into() + } +} + +impl From for generated::types::Error { + fn from(err: serde_json::Error) -> Self { + log_info!(error:err = err; "deserialization failed, returning inval"); + + generated::types::Errno::Inval.into() + } +} + +impl ContextVFS<'_> { + fn place_content( + &mut self, + content: vfs::FileContents, + ) -> Result { + self.vfs + .place_content(content) + .map(generated::types::Fd::from) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e))) + } + + fn set_vm_run_result( + &mut self, + data: rt::vm::RunOk, + ) -> Result<(generated::types::Fd, usize), generated::types::Error> { + let data = match data { + rt::vm::RunOk::VMError(e, cause) => { + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::vm_cause(e, cause).into(), + ))) + } + data => data, + }; + let data: Vec = data.as_bytes(); + let len = data.len(); + self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) + .map(|fd| (fd, len)) + } +} + +async fn taskify( + fut: impl std::future::Future>> + + Send + + 'static, +) -> anyhow::Result> +where + T: calldata::codec::Encode, Error = std::convert::Infallible> + Send, +{ + match fut.await? { + Ok(r) => { + let r = calldata::to_value(&r); + let data = calldata::Value::Map(BTreeMap::from([("ok".to_owned(), r)])); + + Ok(Box::from(calldata::encode(&data))) + } + Err(e) => { + let e = calldata::to_value(&e); + let data = calldata::Value::Map(BTreeMap::from([("error".to_owned(), e)])); + + Ok(Box::from(calldata::encode(&data))) + } + } +} + +const NO_FILE: u32 = u32::MAX; + +#[inline] +fn file_fd_none() -> generated::types::Fd { + generated::types::Fd::from(NO_FILE) +} + +/// Returns `true` iff `a + b <= c`, computed without wrapping. +/// +/// `a + b` is done with checked arithmetic: on overflow the sum cannot fit in a +/// `U256` and is therefore strictly greater than any real `c`, so the result is +/// `false`. This avoids the wraparound that would let an oversized `a` (e.g. +/// `2^256 - 1`) appear to satisfy a balance check. +#[inline] +fn checked_sum_le( + a: primitive_types::U256, + b: primitive_types::U256, + c: primitive_types::U256, +) -> bool { + if b > c { + return false; + } + let c_minus_b = c - b; + a <= c_minus_b +} + +#[allow(unused_variables)] +impl generated::genlayer_sdk::GenlayerSdk for ContextVFS<'_> { + async fn gl_call( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + request: wiggle::GuestPtr, + request_len: u32, + ) -> Result { + let request = request.as_array(request_len); + let request = read_owned_vec(mem, request)?; + + let request = match calldata::decode(&request) { + Err(e) => { + log_info!(error:err = &e; "calldata parse failed"); + + return Err(generated::types::Errno::Inval.into()); + } + Ok(v) => v, + }; + + log_trace!(request:cd = request; "gl_call"); + + let request: gl_call::Message = match calldata::from_value(request) { + Ok(v) => v, + Err(e) => { + log_info!(error:err = e; "calldata deserialization failed"); + + return Err(generated::types::Errno::Inval.into()); + } + }; + + match request { + gl_call::Message::EthSend { + address, + calldata, + value, + } => self.gl_call_eth_send(address, calldata, value).await, + gl_call::Message::EthCall { address, calldata } => { + self.gl_call_eth_call(address, calldata).await + } + gl_call::Message::CallContract { + address, + calldata, + state, + } => self.gl_call_contract(address, calldata, state).await, + gl_call::Message::EmitEvent { topics, blob } => { + self.gl_call_emit_event(topics, blob).await + } + gl_call::Message::PostMessage { + address, + calldata, + value, + on, + use_balance, + fee_params, + } => { + self.gl_call_post_message(address, calldata, value, on, use_balance, fee_params) + .await + } + gl_call::Message::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + use_balance, + fee_params, + } => { + self.gl_call_deploy_contract( + calldata, + code, + value, + on, + salt_nonce, + use_balance, + fee_params, + ) + .await + } + gl_call::Message::WebRender(render_payload) => { + self.gl_call_web_render(render_payload).await + } + gl_call::Message::WebRequest(request_payload) => { + self.gl_call_web_request(request_payload).await + } + gl_call::Message::ExecPrompt(prompt_payload) => { + self.gl_call_exec_prompt(prompt_payload).await + } + gl_call::Message::ExecPromptTemplate(prompt_template_payload) => { + self.gl_call_exec_prompt_template(prompt_template_payload) + .await + } + gl_call::Message::UserError(msg) => Err(generated::types::Error::trap( + crate::anyhow_to_wasmtime(rt::errors::Error::user(msg).into()), + )), + gl_call::Message::Return(value) => Err(generated::types::Error::trap( + crate::anyhow_to_wasmtime(ContractReturn(value).into()), + )), + gl_call::Message::RunNondet { + data_leader, + data_validator, + runner, + custom_runners, + } => { + self.run_nondet(data_leader, data_validator, runner, custom_runners) + .await + } + gl_call::Message::Sandbox { + data, + runner, + allow_write_storage, + allow_send_messages, + allow_register_runners, + custom_runners, + } => { + self.sandbox( + data, + runner, + allow_write_storage, + allow_send_messages, + allow_register_runners, + custom_runners, + ) + .await + } + gl_call::Message::RegisterRunner { code } => self.register_runner(code).await, + gl_call::Message::MapFile { + runner, + path_in_runner, + path_in_vfs, + } => self.map_file(runner, path_in_runner, path_in_vfs).await, + gl_call::Message::Trace(message) => self.gl_call_trace(message).await, + gl_call::Message::Yield => Ok(file_fd_none()), + gl_call::Message::GetTimestamp => self.gl_call_get_timestamp().await, + } + } + + async fn storage_read( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + slot: wiggle::GuestPtr, + index: u32, + buf: wiggle::GuestPtr, + buf_len: u32, + ) -> Result<(), generated::types::Error> { + let buf = buf.as_array(buf_len); + + if index.checked_add(buf_len).is_none() { + return Err(generated::types::Errno::Inval.into()); + } + + mem.bounds_check(buf)?; + + let account = self.context.data.message_data.message.contract_address; + + let slot = SlotID::read_from_mem(mem, slot)?; + let mem_size = buf_len as usize; + + let mut vec_buf = Vec::new(); + let (should_copy, vec) = if let Some(buf) = mem.as_slice_mut(buf)? { + (false, buf) + } else { + vec_buf.resize(mem_size, 0); + (true, vec_buf.as_mut_slice()) + }; + + if self.context.data.conf.execution.state_mode == public_abi::StorageType::Default { + self.context + .data + .storage + .read(slot, index, vec) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; + } else { + self.context + .data + .supervisor + .host + .lock_for(host::host_fns::Methods::StorageRead) + .await + .storage_read( + self.context.data.conf.execution.state_mode, + account, + slot, + index, + vec, + ) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + } + + if should_copy { + mem.copy_from_slice(&vec_buf, buf)?; + } + + Ok(()) + } + + async fn storage_write( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + slot: wiggle::GuestPtr, + index: u32, + buf: wiggle::GuestPtr, + buf_len: u32, + ) -> Result<(), generated::types::Error> { + let buf = buf.as_array(buf_len); + + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.write_storage { + return Err(generated::types::Errno::Forbidden.into()); + } + + if index.checked_add(buf_len).is_none() { + return Err(generated::types::Errno::Inval.into()); + } + + mem.bounds_check(buf)?; + + let slot = SlotID::read_from_mem(mem, slot)?; + + if self.context.data.supervisor.locked_slots.contains(slot) { + return Err(generated::types::Errno::Forbidden.into()); + } + + let ptr = mem.as_cow(buf)?; + + self.context + .data + .storage + .write(slot, index, &ptr) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into()))) + } + + async fn get_balance( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + account: wiggle::GuestPtr, + result: wiggle::GuestPtr, + ) -> Result<(), generated::types::Error> { + let address = read_addr_from_mem(mem, account)?; + + self.context + .get_balance_impl_wasi(mem, address, result, false) + .await + } + + async fn get_self_balance( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + result: wiggle::GuestPtr, + ) -> Result<(), generated::types::Error> { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + + self.context + .get_balance_impl_wasi( + mem, + self.context.data.message_data.message.contract_address, + result, + true, + ) + .await + } +} + +impl Context { + async fn get_balance_impl_wasi( + &mut self, + mem: &mut wiggle::GuestMemory<'_>, + address: calldata::Address, + result: wiggle::GuestPtr, + is_self: bool, + ) -> Result<(), generated::types::Error> { + let mut res = self.get_balance_impl(address).await?; + + if is_self && self.data.conf.is_main() { + let messages_decremented = self.data.accumulator.messages_value_decremented; + + res -= messages_decremented; + } + + let res = res.to_little_endian(); + mem.copy_from_slice(&res, result.as_array(32))?; + + Ok(()) + } + + pub async fn get_balance_impl( + &mut self, + address: calldata::Address, + ) -> Result { + if let Some(res) = self.data.supervisor.balances.get(&address) { + return Ok(*res); + } + + let res = self + .data + .supervisor + .host + .lock_for(host::host_fns::Methods::GetBalance) + .await + .get_balance(address) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + let _ = self.data.supervisor.balances.insert(address, res); + + Ok(res) + } + + pub fn log(&self) -> calldata::Value { + let msg = calldata::to_value(&self.data.message_data); + let conf = calldata::to_value(&self.data.conf); + + calldata::Value::Map(BTreeMap::from([ + ("config".to_owned(), conf), + ("message".to_owned(), msg), + ])) + } +} + +impl ContextVFS<'_> { + async fn gl_call_web_render( + &mut self, + render_payload: gl_call::web_iface::RenderPayload, + ) -> Result { + let is_det = self.context.data.conf.permissions.deterministic; + if is_det { + return Err(generated::types::Errno::Forbidden.into()); + } + + let space_left = self + .context + .data + .supervisor + .limiter + .get(is_det) + .get_remaining_memory(); + + if space_left < abi::consts::top_limits::WEB_RENDER_MIN_SPACE { + log_warn!(space_left = space_left; "not enough memory for web render"); + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ))); + } + + let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; + + let web = self.context.data.supervisor.modules.web.clone(); + let task = taskify(async move { + web.send::( + genvm_modules_interfaces::web::Message::Render( + gl_call_to_mi::render_payload(render_payload), + space_left_with_overhead, + ), + ) + .await + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) + } + async fn gl_call_web_request( + &mut self, + request_payload: gl_call::web_iface::RequestPayload, + ) -> Result { + let is_det = self.context.data.conf.permissions.deterministic; + if is_det { + return Err(generated::types::Errno::Forbidden.into()); + } + + let space_left = self + .context + .data + .supervisor + .limiter + .get(is_det) + .get_remaining_memory(); + + if space_left < abi::consts::top_limits::WEB_REQUEST_MIN_SPACE { + log_warn!(space_left = space_left; "not enough memory for web request"); + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::vm(abi::consts::VmError::out_of().memory().val()).into(), + ))); + } + + let space_left_with_overhead = (space_left as u64 * 3 / 4) as u32; + + let web = self.context.data.supervisor.modules.web.clone(); + let task = taskify(async move { + web.send::( + genvm_modules_interfaces::web::Message::Request( + gl_call_to_mi::request_payload(request_payload), + space_left_with_overhead, + ), + ) + .await + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) + } + async fn gl_call_exec_prompt( + &mut self, + prompt_payload: gl_call::llm_iface::PromptPayload, + ) -> Result { + if self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + + if prompt_payload.images.len() > 2 { + return Err(generated::types::Errno::Inval.into()); + } + + let remaining_fuel_as_gen = self + .context + .data + .supervisor + .host + .lock_for(host::host_fns::Methods::RemainingFuelAsGen) + .await + .remaining_fuel_as_gen() + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + let sup = self.context.data.supervisor.clone(); + + let task = taskify(async move { + let result = sup + .modules + .llm + .send::( + genvm_modules_interfaces::llm::Message::Prompt { + payload: gl_call_to_mi::prompt_payload(prompt_payload), + remaining_fuel_as_gen, + }, + ) + .await?; + + let result = match result { + Ok(r) => r, + Err(e) => { + return Ok(Err(e)); + } + }; + + sup.host + .lock_for(host::host_fns::Methods::ConsumeFuel) + .await + .consume_fuel(result.consumed_gen)?; + + if result.consumed_gen == primitive_types::U256::MAX { + return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); + } + + { + let mut acc = sup.shared_data.llm_consumption.lock().await; + *acc = acc.saturating_add(result.consumed_gen); + } + + let result = result.data; + + Ok(Ok(result)) + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) + } + async fn gl_call_exec_prompt_template( + &mut self, + prompt_template_payload: gl_call::llm_iface::PromptTemplatePayload, + ) -> Result { + if self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + + let expect_bool = !matches!( + &prompt_template_payload, + gl_call::llm_iface::PromptTemplatePayload::EqNonComparativeLeader(_) + ); + + // Get remaining fuel from host + let remaining_fuel_as_gen = self + .context + .data + .supervisor + .host + .lock_for(host::host_fns::Methods::RemainingFuelAsGen) + .await + .remaining_fuel_as_gen() + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + let sup = self.context.data.supervisor.clone(); + let task = taskify(async move { + let answer = sup + .modules + .llm + .send::( + genvm_modules_interfaces::llm::Message::PromptTemplate { + payload: gl_call_to_mi::prompt_template_payload(prompt_template_payload), + remaining_fuel_as_gen, + }, + ) + .await?; + use genvm_modules_interfaces::llm::{PromptAnswer, PromptAnswerData}; + + if let Ok(PromptAnswer { consumed_gen, .. }) = &answer { + sup.host + .lock_for(host::host_fns::Methods::ConsumeFuel) + .await + .consume_fuel(*consumed_gen)?; + if *consumed_gen == primitive_types::U256::MAX { + return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); + } + + { + let mut acc = sup.shared_data.llm_consumption.lock().await; + *acc = acc.saturating_add(*consumed_gen); + } + } + + match (expect_bool, answer) { + (_, Err(e)) => Ok(Err(e)), + ( + true, + Ok(PromptAnswer { + data: PromptAnswerData::Bool(answer), + .. + }), + ) => Ok(Ok(PromptAnswerData::Bool(answer))), + ( + false, + Ok(PromptAnswer { + data: PromptAnswerData::Text(answer), + .. + }), + ) => Ok(Ok(PromptAnswerData::Text(answer))), + (_, Ok(_)) => Err(anyhow::anyhow!("unmatched result")), + } + }) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + self.place_content(vfs::FileContents::from(bytes::Bytes::from(task))) + } + + async fn gl_call_trace( + &mut self, + msg: gl_call::TracePayload, + ) -> Result { + match msg { + gl_call::TracePayload::Message(text) => { + // Tracing is a debug-only aid; skip the timing and logging work + // entirely when not in debug mode. + if !self + .context + .data + .supervisor + .shared_data + .debug_mode + .allows_tracing() + { + return Ok(file_fd_none()); + } + + let now = std::time::Instant::now(); + let since_prev = now.duration_since(self.context.prev_time); + self.context.prev_time = now; + + log_info!( + message = text, + elapsed:? = now.duration_since(self.context.start_time), + since_last_trace:? = since_prev; + "trace" + ); + + Ok(file_fd_none()) + } + gl_call::TracePayload::RuntimeMicroSec => { + let elapsed_micros = if self.context.data.conf.permissions.deterministic + && !self + .context + .data + .supervisor + .shared_data + .debug_mode + .allows_nondeterminism() + { + 0u64 + } else { + let elapsed = std::time::Instant::now().duration_since(self.context.start_time); + elapsed.as_micros() as u64 + }; + + let data = calldata::encode(&calldata::Value::Number(num_bigint::BigInt::from( + elapsed_micros, + ))); + self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) + } + } + } + + async fn gl_call_get_timestamp( + &mut self, + ) -> Result { + let timestamp = if self.context.data.conf.permissions.deterministic { + self.context.data.message_data.message.datetime.timestamp() + } else { + chrono::Utc::now().timestamp() + }; + + let data = calldata::encode(&calldata::Value::Number(num_bigint::BigInt::from( + timestamp, + ))); + self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) + } + + async fn register_runner( + &mut self, + code: bytes::Bytes, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.register_runners { + return Err(generated::types::Errno::Forbidden.into()); + } + + // The load action registers the content (weak registry, dedup while + // alive), charges `RUNNER_LOAD_COST + code.len()` to this VM before parsing, and + // pins it into this VM's loaded set — scoping it to this execution and its + // deterministic children only (ADR-012 §3). RegisterRunner is det-only, so + // the load folds into the det fingerprint. + let supervisor = self.context.data.supervisor.clone(); + let limiter = self.context.limiter.clone(); + let Context { loaded, data, .. } = &mut *self.context; + let id = rt::supervisor::actions::register_runner_load( + &supervisor, + &limiter, + loaded, + Some(&mut data.det_subvm_hashes), + code, + ) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + let data = calldata::encode(&calldata::Value::Str(id.as_str().to_owned())); + self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) + } + + async fn map_file( + &mut self, + runner: String, + path_in_runner: String, + path_in_vfs: String, + ) -> Result { + let supervisor = self.context.data.supervisor.clone(); + let is_det = self.context.data.conf.permissions.deterministic; + // Charge the VM's own limiter (refunded at VM death, coinciding with the + // pin drop), not the long-lived root limiter. + let limiter = self.context.limiter.clone(); + let topmost_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); + + let runner = + rt::supervisor::actions::resolve_runner_id(&supervisor, &topmost_runner_id, &runner) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + // The load action pins the archive into this VM's loaded set (keeping the + // mapped files' backing bytes resident) and charges once for it. + let preview1 = &mut *self.preview1; + let Context { loaded, data, .. } = &mut *self.context; + let det_fingerprint = is_det.then_some(&mut data.det_subvm_hashes); + rt::supervisor::actions::map_runner_file( + &supervisor, + preview1, + &limiter, + loaded, + det_fingerprint, + runner, + &path_in_runner, + &path_in_vfs, + ) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + Ok(file_fd_none()) + } +} + +/// Conversions from sdk-rs `gl_call` payload types to the self-contained +/// `genvm_modules_interfaces` payload types. +/// +/// These types are structurally identical but distinct: `genvm-modules-interfaces` +/// no longer depends on `sdk-rs`, so it carries its own copies. The orphan rule +/// prevents `From`/`Into` impls here (both types are foreign to this crate), so +/// free functions bridge the executor<->module boundary instead. +mod gl_call_to_mi { + use genlayer_sdk::abi::gl_call::{llm_iface, web_iface}; + use genvm_modules_interfaces::{llm as mi_llm, web as mi_web}; + + pub fn render_mode(m: web_iface::RenderMode) -> mi_web::RenderMode { + match m { + web_iface::RenderMode::Text => mi_web::RenderMode::Text, + web_iface::RenderMode::HTML => mi_web::RenderMode::HTML, + web_iface::RenderMode::Screenshot => mi_web::RenderMode::Screenshot, + } + } + + pub fn wait_after_loaded(w: web_iface::WaitAfterLoaded) -> mi_web::WaitAfterLoaded { + match w { + web_iface::WaitAfterLoaded::Seconds(s) => mi_web::WaitAfterLoaded::Seconds(s), + web_iface::WaitAfterLoaded::Millis(ms) => mi_web::WaitAfterLoaded::Millis(ms), + } + } + + pub fn render_payload(p: web_iface::RenderPayload) -> mi_web::RenderPayload { + mi_web::RenderPayload { + mode: render_mode(p.mode), + url: p.url, + wait_after_loaded: wait_after_loaded(p.wait_after_loaded), + } + } + + pub fn request_method(m: web_iface::RequestMethod) -> mi_web::RequestMethod { + match m { + web_iface::RequestMethod::GET => mi_web::RequestMethod::GET, + web_iface::RequestMethod::POST => mi_web::RequestMethod::POST, + web_iface::RequestMethod::HEAD => mi_web::RequestMethod::HEAD, + web_iface::RequestMethod::PUT => mi_web::RequestMethod::PUT, + web_iface::RequestMethod::DELETE => mi_web::RequestMethod::DELETE, + web_iface::RequestMethod::OPTIONS => mi_web::RequestMethod::OPTIONS, + web_iface::RequestMethod::PATCH => mi_web::RequestMethod::PATCH, + } + } + + pub fn request_payload(p: web_iface::RequestPayload) -> mi_web::RequestPayload { + mi_web::RequestPayload { + method: request_method(p.method), + url: p.url, + headers: p.headers, + body: p.body, + sign: p.sign, + } + } + + pub fn output_format(f: llm_iface::OutputFormat) -> mi_llm::OutputFormat { + match f { + llm_iface::OutputFormat::Text => mi_llm::OutputFormat::Text, + llm_iface::OutputFormat::JSON => mi_llm::OutputFormat::JSON, + } + } + + pub fn prompt_payload(p: llm_iface::PromptPayload) -> mi_llm::PromptPayload { + mi_llm::PromptPayload { + response_format: output_format(p.response_format), + prompt: p.prompt, + images: p.images, + } + } + + pub fn prompt_template_payload( + p: llm_iface::PromptTemplatePayload, + ) -> mi_llm::PromptTemplatePayload { + match p { + llm_iface::PromptTemplatePayload::EqComparative(x) => { + mi_llm::PromptTemplatePayload::EqComparative(mi_llm::PromptEqComparativePayload { + leader_answer: x.leader_answer, + validator_answer: x.validator_answer, + principle: x.principle, + }) + } + llm_iface::PromptTemplatePayload::EqNonComparativeValidator(x) => { + mi_llm::PromptTemplatePayload::EqNonComparativeValidator( + mi_llm::PromptEqNonComparativeValidatorPayload { + task: x.task, + criteria: x.criteria, + input: x.input, + output: x.output, + }, + ) + } + llm_iface::PromptTemplatePayload::EqNonComparativeLeader(x) => { + mi_llm::PromptTemplatePayload::EqNonComparativeLeader( + mi_llm::PromptEqNonComparativeLeaderPayload { + task: x.task, + criteria: x.criteria, + input: x.input, + }, + ) + } + } + } +} diff --git a/executor/src/wasi/genlayer_sdk/run.rs b/executor/src/wasi/genlayer_sdk/run.rs new file mode 100644 index 00000000..8641375f --- /dev/null +++ b/executor/src/wasi/genlayer_sdk/run.rs @@ -0,0 +1,499 @@ +use super::*; +use crate::runners; +use sha3::digest::Update; +use std::sync::Arc; + +async fn consume_nondet_output( + shared_data: &rt::SharedData, + output_length: u64, +) -> Result<(), generated::types::Error> { + if !shared_data + .data_fees_limit + .consume_nondet_output(output_length) + .await + .map_err(internal_trap)? + { + return Err(internal_trap(rt::errors::Error::vm( + abi::consts::VmError::out_of().receipt().nondet_output(), + ))); + } + Ok(()) +} + +impl ContextVFS<'_> { + pub(super) async fn gl_call_eth_call( + &mut self, + address: calldata::Address, + calldata: bytes::Bytes, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.call_others { + return Err(generated::types::Errno::Forbidden.into()); + } + + let supervisor = self.context.data.supervisor.clone(); + let data = supervisor + .host + .lock_for(host::host_fns::Methods::EthCall) + .await + .eth_call(address, &calldata) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) + } + pub(super) async fn gl_call_contract( + &mut self, + address: calldata::Address, + calldata: calldata::unparsed::Maybe, + mut state: public_abi::StorageType, + ) -> Result { + if !self.context.data.conf.permissions.deterministic { + return Err(generated::types::Errno::Forbidden.into()); + } + if !self.context.data.conf.permissions.call_others { + return Err(generated::types::Errno::Forbidden.into()); + } + + let supervisor = self.context.data.supervisor.clone(); + + let my_conf = self.context.data.conf.clone(); + + let calldata_encoded = calldata::encode_obj(&calldata); + + let mut my_data = self + .context + .data + .message_data + .fork(public_abi::EntryKind::Main, calldata_encoded.into()); + my_data.message.stack.push(my_data.message.contract_address); + + if state == public_abi::StorageType::Default { + state = my_conf.execution.state_mode; + } + + let mut child_storage = rt::vm::storage::Storage::new( + address, + supervisor.get_storage_limiter(), + StorageHostHolder( + supervisor.host.clone(), + ReadToken { + account: address, + mode: state, + }, + ), + ); + + let code_slot = child_storage + .check_major_and_resolve_code_slot() + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; + + let vm_data = Box::new(SingleVMData { + depth: self.context.data.depth + 1, + spawn_kind: "call_contract".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst + conf: base::Config { + needs_error_fingerprint: true, + permissions: base::Permissions { + deterministic: true, + write_storage: false, + spawn_nondet: false, + call_others: my_conf.permissions.call_others, + send_messages: false, + register_runners: my_conf.permissions.register_runners, + can_use_balance_for_message_fees: false, + }, + execution: base::Execution { + state_mode: state, + topmost_runner_id: runners::Id::Chain { + address, + on: if state == public_abi::StorageType::LatestFinal { + runners::ChainState::Finalized + } else { + runners::ChainState::Accepted + }, + slot: code_slot, + }, + }, + }, + message_data: ExtendedMessage { + message: genlayer_sdk::abi::entry::MessageData { + contract_address: address, + sender_address: my_data.message.sender_address, + origin_address: my_data.message.origin_address, + signer_address: my_data.message.signer_address, + value: num_bigint::BigInt::ZERO, + is_init: false, + datetime: my_data.message.datetime, + chain_id: my_data.message.chain_id, + stack: my_data.message.stack, + }, + entry_kind: my_data.entry_kind, + entry_data: my_data.entry_data, + entry_stage_data: default_entry_stage_data(), + }, + storage: child_storage, + supervisor: supervisor.clone(), + accumulator: VMDataAccumulator { + data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), + messages_value_decremented: primitive_types::U256::zero(), + emissions: Vec::new(), + message_fee_allocation: Vec::new(), + }, + det_subvm_hashes: Default::default(), + // A CallContract child is granted the caller's full custom set + // (ADR-012 §4); its spawn load-actions each into the child. + granted_custom: self.context.loaded.custom_pins(), + }); + + let res = spawn_sub_vm(supervisor.clone(), vm_data) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + // The child is read-only (static), so its accumulator must be + // empty — otherwise an effect was charged but discarded here. + res.vm_data + .accumulator + .check_empty() + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + let hash = res.small_hash(); + self.context.data.det_subvm_hashes.update(&hash); + + self.set_vm_run_result(res.run_ok).map(|x| x.0) + } + pub(super) async fn run_nondet( + &mut self, + data_leader: bytes::Bytes, + data_validator: bytes::Bytes, + runner: Option, + custom_runners: Option>, + ) -> Result { + if !self.context.data.conf.permissions.spawn_nondet { + return Err(generated::types::Errno::Forbidden.into()); + } + + // Resolve the runner to execute and the child's custom-runner visibility + // in this (deterministic parent) scope, so malformed inputs fail + // deterministically at gl_call time (ADR-012 §4). + let supervisor = self.context.data.supervisor.clone(); + let parent_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); + let child_topmost_id = match &runner { + None => parent_runner_id.clone(), + Some(r) => { + rt::supervisor::actions::resolve_runner_id(&supervisor, &parent_runner_id, r) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))? + } + }; + let child_custom = rt::supervisor::actions::resolve_child_custom_runners( + &self.context.loaded, + custom_runners, + &child_topmost_id, + ) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + let call_no = self + .context + .data + .supervisor + .nondet_call_no + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + if call_no >= public_abi::top_limits::NONDET_BLOCKS { + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::vm(abi::consts::VmError::out_of().nondet_blocks()).into(), + ))); + } + + let leaders_res_bytes = self + .context + .data + .supervisor + .get_leader_nondet_result(call_no); + + let leaders_res = match leaders_res_bytes { + None if self.context.data.supervisor.is_leader() => None, + None => { + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::vm(abi::consts::VmError::absent_leader_nondet_output()) + .into(), + ))); + } + Some(data) if data.is_empty() => { + // A zero-length leader result is malformed: it carries no result + // code byte to dispatch on. It is contract-triggerable, so it must + // not panic via out-of-bounds indexing. In `sync` mode there are no + // validators to disagree, so surface a canonical VMError; otherwise + // record a non-deterministic disagreement and return that VMError to + // the validator's contract. + if !self.context.data.supervisor.shared_data.is_sync { + self.context + .data + .supervisor + .mark_nondet_disagreement(call_no); + } + + let result = rt::vm::RunOk::VMError( + abi::consts::VmError::absent_leader_nondet_output(), + None, + ); + + consume_nondet_output( + &self.context.data.supervisor.shared_data, + result.as_bytes().len() as u64, + ) + .await?; + + return self.set_vm_run_result(result).map(|x| x.0); + } + Some(data) => { + use crate::public_abi::ResultCode; + let rest = &data[1..]; + let res = match data[0] { + x if x == ResultCode::Return as u8 => { + rt::vm::RunOk::Return(calldata::unparsed::Maybe::Checked( + calldata::unparsed::Raw(bytes::Bytes::copy_from_slice(rest)), + )) + } + x if x == ResultCode::UserError as u8 => { + let val = calldata::decode(rest).map_err(|e| { + generated::types::Error::trap(crate::anyhow_to_wasmtime( + anyhow::anyhow!(e), + )) + })?; + rt::vm::RunOk::UserError(calldata::unparsed::Maybe::Materialized(val)) + } + x if x == ResultCode::VmError as u8 => { + let code = std::str::from_utf8(rest).map_err(|e| { + generated::types::Error::trap(crate::anyhow_to_wasmtime( + anyhow::anyhow!(e), + )) + })?; + rt::vm::RunOk::VMError( + public_abi::VmError(std::borrow::Cow::Owned(code.to_owned())), + None, + ) + } + x => { + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + anyhow::anyhow!("invalid leader result code: {}", x), + ))); + } + }; + Some(res) + } + }; + + let result_to_return = if self.context.data.supervisor.shared_data.is_sync { + match leaders_res { + None => { + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + anyhow::anyhow!("absent leader result in sync mode, call_no: {}", call_no), + ))) + } + Some(v) => v, + } + } else { + let storage_checkpoint = self.context.data.storage.clone(); + + let message_data = match &leaders_res { + None => self.context.data.message_data.fork_leader( + public_abi::EntryKind::ConsensusStage, + data_leader, + None, + ), + Some(leaders_res) => { + let dup = match leaders_res { + rt::vm::RunOk::Return(items) => rt::vm::RunOk::Return(items.clone()), + rt::vm::RunOk::UserError(msg) => rt::vm::RunOk::UserError(msg.clone()), + rt::vm::RunOk::VMError(msg, _) => rt::vm::RunOk::VMError(msg.clone(), None), + }; + self.context.data.message_data.fork_leader( + public_abi::EntryKind::ConsensusStage, + data_validator, + Some(dup), + ) + } + }; + + let fake_accum = VMDataAccumulator { + data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), + messages_value_decremented: self + .context + .data + .accumulator + .messages_value_decremented, + emissions: Vec::new(), + message_fee_allocation: Vec::new(), + }; + + let vm_data = Box::new(SingleVMData { + depth: self.context.data.depth + 1, + spawn_kind: "run_nondet".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst + conf: base::Config { + needs_error_fingerprint: false, + permissions: base::Permissions { + deterministic: false, + write_storage: false, + send_messages: false, + call_others: false, + spawn_nondet: false, + register_runners: false, + can_use_balance_for_message_fees: false, + }, + execution: base::Execution { + state_mode: public_abi::StorageType::Default, + topmost_runner_id: child_topmost_id, + }, + }, + message_data, + supervisor: supervisor.clone(), + storage: storage_checkpoint, + accumulator: fake_accum, + det_subvm_hashes: Default::default(), // won't be used + // The nondet child is granted exactly the parent-computed set; the + // pins keep the content alive until the (possibly queued) child + // spawns and load-actions them against its own limiter (ADR-012 §4). + granted_custom: child_custom, + }); + + let task_done = Arc::new(tokio::sync::Notify::new()); + let task = rt::supervisor::NonDetVMTask { + task: vm_data, + call_no, + tasks_done: task_done.clone(), + }; + + match leaders_res { + None => { + let res = task + .run_now(&self.context.data.supervisor) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + self.context + .data + .supervisor + .push_nondet_result(call_no, bytes::Bytes::from(res.as_bytes())) + .await; + + res + } + Some(leaders_res) => { + rt::supervisor::submit_nondet_vm_task(&self.context.data.supervisor, task) + .await; + + leaders_res + } + } + }; + + consume_nondet_output( + &self.context.data.supervisor.shared_data, + result_to_return.as_bytes().len() as u64, + ) + .await?; + + self.set_vm_run_result(result_to_return).map(|x| x.0) + } + + pub(super) async fn sandbox( + &mut self, + data: bytes::Bytes, + runner: String, + allow_write_storage: bool, + allow_send_messages: bool, + allow_register_runners: bool, + custom_runners: Option>, + ) -> Result { + let supervisor = self.context.data.supervisor.clone(); + + let parent_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); + let topmost_runner_id = + rt::supervisor::actions::resolve_runner_id(&supervisor, &parent_runner_id, &runner) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + // Grants are validated against, and drawn from, this VM's loaded set (§4). + // No flow-back is possible by construction: the child's own loaded set (and + // any runner it registers) dies with it, and the parent's loaded set is + // never mutated here (§5). + let child_custom = rt::supervisor::actions::resolve_child_custom_runners( + &self.context.loaded, + custom_runners, + &topmost_runner_id, + ) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + let message_data = self + .context + .data + .message_data + .fork(public_abi::EntryKind::Sandbox, data); + + let zelf_conf = &self.context.data.conf; + + let storage_checkpoint = self.context.data.storage.clone(); + + let mut fake_my_data = VMDataAccumulator { + data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), + messages_value_decremented: primitive_types::U256::max_value(), + emissions: Vec::new(), + message_fee_allocation: Vec::new(), + }; + + std::mem::swap(&mut self.context.data.accumulator, &mut fake_my_data); + + let stolen_data = fake_my_data; + + let vm_data = Box::new(SingleVMData { + depth: self.context.data.depth + 1, + spawn_kind: "sandbox".to_owned(), + // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst + conf: base::Config { + needs_error_fingerprint: false, + permissions: base::Permissions { + deterministic: zelf_conf.permissions.deterministic, + write_storage: zelf_conf.permissions.write_storage & allow_write_storage, + send_messages: zelf_conf.permissions.send_messages & allow_send_messages, + call_others: false, + spawn_nondet: false, + register_runners: zelf_conf.permissions.register_runners + & allow_register_runners, + can_use_balance_for_message_fees: zelf_conf + .permissions + .can_use_balance_for_message_fees + & allow_send_messages, + }, + execution: base::Execution { + state_mode: zelf_conf.execution.state_mode, + topmost_runner_id, + }, + }, + message_data, + supervisor: supervisor.clone(), + storage: storage_checkpoint, + accumulator: stolen_data, + det_subvm_hashes: Default::default(), + granted_custom: child_custom, + }); + + let my_res = spawn_sub_vm(supervisor.clone(), vm_data) + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + + if self.context.data.conf.permissions.deterministic { + let hash = my_res.small_hash(); + self.context.data.det_subvm_hashes.update(&hash); + } + + self.context.data.accumulator = my_res.vm_data.accumulator; + self.context.data.storage = my_res.vm_data.storage; + + let data: Vec = my_res.run_ok.as_bytes(); + self.place_content(vfs::FileContents::from(bytes::Bytes::from(data))) + } +} diff --git a/executor/src/wasi/genlayer_sdk/tests.rs b/executor/src/wasi/genlayer_sdk/tests.rs new file mode 100644 index 00000000..b3f98873 --- /dev/null +++ b/executor/src/wasi/genlayer_sdk/tests.rs @@ -0,0 +1,113 @@ +use super::message::{validate_balance_fee, FEE_PARAM_COUNT_BITS, FEE_PARAM_PRICE_BITS}; +use super::*; +use primitive_types::U256; + +fn valid_params() -> domain::fees::InternalMessageParams { + domain::fees::InternalMessageParams { + leader_timeunits_allocation: U256::from(5), + validator_timeunits_allocation: U256::from(5), + execution_budget_per_round: U256::from(1024), + rotations: vec![U256::from(4); 5], + max_price_gen_per_time_unit: U256::from(2), + storage_fee_max_gas_price: U256::from(20), + receipt_fee_max_gas_price: U256::from(20), + } +} + +fn errno(e: generated::types::Error) -> generated::types::Errno { + e.downcast().expect("expected a plain errno, got a trap") +} + +#[test] +fn balance_no_permission_is_forbidden() { + let err = validate_balance_fee(false, true, Some(valid_params())).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Forbidden); +} + +#[test] +fn balance_without_params_is_inval() { + let err = validate_balance_fee(true, true, None).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); +} + +#[test] +fn params_without_use_balance_is_inval() { + let err = validate_balance_fee(true, false, Some(valid_params())).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); +} + +#[test] +fn empty_rotations_is_inval() { + let mut p = valid_params(); + p.rotations.clear(); + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); +} + +#[test] +fn zero_price_caps_are_inval() { + for mutate in [ + (|p: &mut domain::fees::InternalMessageParams| p.max_price_gen_per_time_unit = U256::zero()) + as fn(&mut domain::fees::InternalMessageParams), + |p| p.storage_fee_max_gas_price = U256::zero(), + |p| p.receipt_fee_max_gas_price = U256::zero(), + ] { + let mut p = valid_params(); + mutate(&mut p); + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); + } +} + +#[test] +fn huge_magnitude_params_are_inval() { + // Security-review N1 repro: passes the emptiness/zero checks, but the + // 2^250 magnitudes would push messageFeeFloor past U256 and trip the + // evaluator's internal `fee cost exceeds U256 range` abort. + let p = domain::fees::InternalMessageParams { + leader_timeunits_allocation: U256::one() << 250, + validator_timeunits_allocation: U256::zero(), + execution_budget_per_round: U256::zero(), + rotations: vec![U256::zero()], + max_price_gen_per_time_unit: U256::one() << 250, + storage_fee_max_gas_price: U256::from(20), + receipt_fee_max_gas_price: U256::from(20), + }; + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); +} + +#[test] +fn huge_rotations_entry_is_inval() { + let mut p = valid_params(); + p.rotations[2] = U256::one() << 250; + let err = validate_balance_fee(true, true, Some(p)).unwrap_err(); + assert_eq!(errno(err), generated::types::Errno::Inval); +} + +#[test] +fn params_at_magnitude_bounds_pass() { + let mut p = valid_params(); + p.max_price_gen_per_time_unit = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.storage_fee_max_gas_price = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.receipt_fee_max_gas_price = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.execution_budget_per_round = (U256::one() << FEE_PARAM_PRICE_BITS) - 1; + p.leader_timeunits_allocation = (U256::one() << FEE_PARAM_COUNT_BITS) - 1; + p.validator_timeunits_allocation = (U256::one() << FEE_PARAM_COUNT_BITS) - 1; + p.rotations = vec![(U256::one() << FEE_PARAM_COUNT_BITS) - 1; 5]; + let got = validate_balance_fee(true, true, Some(p.clone())).unwrap(); + assert_eq!(got, Some(p)); +} + +#[test] +fn valid_balance_params_pass_through() { + let p = valid_params(); + let got = validate_balance_fee(true, true, Some(p.clone())).unwrap(); + assert_eq!(got, Some(p)); +} + +#[test] +fn no_balance_no_params_is_allocation_path() { + let got = validate_balance_fee(true, false, None).unwrap(); + assert_eq!(got, None); +} From 251b0574d70f7de7a0e941be8a83b29ffa3462e8 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 17:22:57 +0900 Subject: [PATCH 18/25] =?UTF-8?q?chore:=20flush=20pending=20public=20abi?= =?UTF-8?q?=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/codegen/data/public-abi-pending.json | 15 ----------- executor/codegen/data/public-abi.json | 8 ++++-- .../crates/common/src/public_abi_pending.rs | 6 ----- executor/crates/sdk-rs/src/abi/consts.rs | 4 +++ executor/src/rt/supervisor/actions.rs | 27 ++++++++++--------- executor/src/rt/supervisor/mod.rs | 17 ++++++++---- .../src/genlayer/vm/public_abi.py | 4 +++ 7 files changed, 40 insertions(+), 41 deletions(-) diff --git a/executor/codegen/data/public-abi-pending.json b/executor/codegen/data/public-abi-pending.json index da8c0d08..0d4f101c 100644 --- a/executor/codegen/data/public-abi-pending.json +++ b/executor/codegen/data/public-abi-pending.json @@ -1,17 +1,2 @@ [ - { - "type": "const", - "name": "runner_load_cost", - "repr": "u32", - "value": 4096 - }, - { - "type": "consts", - "name": "wasm_stack_limits", - "repr": "u32", - "values": { - "call_depth": 1024, - "value_slots": 65535 - } - } ] diff --git a/executor/codegen/data/public-abi.json b/executor/codegen/data/public-abi.json index 6bdf9c56..26b4221d 100644 --- a/executor/codegen/data/public-abi.json +++ b/executor/codegen/data/public-abi.json @@ -45,7 +45,9 @@ "values": { "table_entry": 64, "file_mapping": 256, - "fd_allocation": 96 + "fd_allocation": 96, + "runner_load_cost": 4096, + "vm_spawn_cost": 134217728 } }, { @@ -73,7 +75,9 @@ "vm_recursion": 512, "web_request_min_space": 65536, "web_render_min_space": 134217728, - "max_fds": 1024 + "max_fds": 1024, + "wasm_call_depth": 1024, + "wasm_stack_value_slots": 65535 } }, { diff --git a/executor/crates/common/src/public_abi_pending.rs b/executor/crates/common/src/public_abi_pending.rs index 077e6911..5bc570a2 100644 --- a/executor/crates/common/src/public_abi_pending.rs +++ b/executor/crates/common/src/public_abi_pending.rs @@ -1,9 +1,3 @@ // This file is auto-generated. Do not edit! #![allow(dead_code, clippy::redundant_static_lifetimes)] - -pub const RUNNER_LOAD_COST: u32 = 4096; -pub mod wasm_stack_limits { - pub const CALL_DEPTH: u32 = 1024; - pub const VALUE_SLOTS: u32 = 65535; -} diff --git a/executor/crates/sdk-rs/src/abi/consts.rs b/executor/crates/sdk-rs/src/abi/consts.rs index 9f6c38af..d14dd5c8 100644 --- a/executor/crates/sdk-rs/src/abi/consts.rs +++ b/executor/crates/sdk-rs/src/abi/consts.rs @@ -193,6 +193,8 @@ pub mod memory_limiter_consts { pub const TABLE_ENTRY: u32 = 64; pub const FILE_MAPPING: u32 = 256; pub const FD_ALLOCATION: u32 = 96; + pub const RUNNER_LOAD_COST: u32 = 4096; + pub const VM_SPAWN_COST: u32 = 134217728; } pub mod root_offsets { @@ -213,6 +215,8 @@ pub mod top_limits { pub const WEB_REQUEST_MIN_SPACE: u32 = 65536; pub const WEB_RENDER_MIN_SPACE: u32 = 134217728; pub const MAX_FDS: u32 = 1024; + pub const WASM_CALL_DEPTH: u32 = 1024; + pub const WASM_STACK_VALUE_SLOTS: u32 = 65535; } #[derive( diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index 72dcf60d..5a9fec28 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -342,7 +342,7 @@ fn fold_det_fingerprint( fn log_runner_load(id: symbol_table::GlobalSymbol, size: u32, status: &'static str) { log_info!( runner = id.as_str(), - runner_load_cost = public_abi_pending::RUNNER_LOAD_COST, + runner_load_cost = public_abi::memory_limiter_consts::RUNNER_LOAD_COST, size = size, status = status; "runner load" @@ -378,7 +378,7 @@ fn out_of_memory() -> anyhow::Error { fn charge_load(limiter: &rt::memlimiter::Limiter, size: usize) -> anyhow::Result<()> { let ok = u32::try_from(size) .ok() - .and_then(|size| public_abi_pending::RUNNER_LOAD_COST.checked_add(size)) + .and_then(|size| public_abi::memory_limiter_consts::RUNNER_LOAD_COST.checked_add(size)) .is_some_and(|amount| limiter.consume(amount)); if !ok { return Err(out_of_memory()); @@ -1376,7 +1376,8 @@ mod tests { #[test] fn charge_load_consumes_runner_load_cost_plus_size() { - let limiter = limiter_with_budget(public_abi_pending::RUNNER_LOAD_COST + 100); + let limiter = + limiter_with_budget(public_abi::memory_limiter_consts::RUNNER_LOAD_COST + 100); charge_load(&limiter, 100).unwrap(); assert_eq!( limiter.get_remaining_memory(), @@ -1387,7 +1388,7 @@ mod tests { #[test] fn charge_load_oom_charges_nothing() { - let budget = public_abi_pending::RUNNER_LOAD_COST + 99; + let budget = public_abi::memory_limiter_consts::RUNNER_LOAD_COST + 99; let limiter = limiter_with_budget(budget); let err = charge_load(&limiter, 100).unwrap_err(); assert!( @@ -1418,7 +1419,7 @@ mod tests { #[test] fn inherit_load_charges_once_then_is_free() { // Grant pins have total_size 1 (see `pin`). - let budget = 2 * (public_abi_pending::RUNNER_LOAD_COST + 1); + let budget = 2 * (public_abi::memory_limiter_consts::RUNNER_LOAD_COST + 1); let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); let granted = pin(custom_id(1)); @@ -1427,7 +1428,7 @@ mod tests { let after_first = limiter.get_remaining_memory(); assert_eq!( budget - after_first, - public_abi_pending::RUNNER_LOAD_COST + 1 + public_abi::memory_limiter_consts::RUNNER_LOAD_COST + 1 ); assert!(loaded.contains(custom_id(1)), "grant must be pinned"); @@ -1443,7 +1444,7 @@ mod tests { #[test] fn inherit_load_oom_leaves_loaded_set_unchanged() { // One short of RUNNER_LOAD_COST + total_size(=1). - let budget = public_abi_pending::RUNNER_LOAD_COST; + let budget = public_abi::memory_limiter_consts::RUNNER_LOAD_COST; let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1510,7 +1511,7 @@ mod tests { async fn register_charges_runner_load_cost_plus_code_len_and_pins() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let budget = 2 * (public_abi_pending::RUNNER_LOAD_COST + code.len() as u32); + let budget = 2 * (public_abi::memory_limiter_consts::RUNNER_LOAD_COST + code.len() as u32); let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1521,7 +1522,7 @@ mod tests { assert_eq!(id, custom_id_of(&code)); assert_eq!( budget - limiter.get_remaining_memory(), - public_abi_pending::RUNNER_LOAD_COST + code.len() as u32 + public_abi::memory_limiter_consts::RUNNER_LOAD_COST + code.len() as u32 ); assert!(loaded.contains(id), "registered runner must be resolvable"); } @@ -1556,7 +1557,7 @@ mod tests { async fn register_oom_charges_and_registers_nothing() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let budget = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32 - 1; + let budget = public_abi::memory_limiter_consts::RUNNER_LOAD_COST + code.len() as u32 - 1; let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1580,7 +1581,7 @@ mod tests { let registry = runners::cache::WeakCache::new(); // Not a zip, not wasm, not UTF-8 text: parse fails on the bytes alone. let code = bytes::Bytes::from_static(b"\xff\xfe\xfd"); - let budget = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32; + let budget = public_abi::memory_limiter_consts::RUNNER_LOAD_COST + code.len() as u32; let limiter = limiter_with_budget(budget); let mut loaded = runners::cache::LoadedSet::default(); @@ -1632,7 +1633,7 @@ mod tests { ); // Child spawn: inherit load actions charge the child's own limiter. - let cost = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32; + let cost = public_abi::memory_limiter_consts::RUNNER_LOAD_COST + code.len() as u32; let child_limiter = limiter_with_budget(cost); let mut child = runners::cache::LoadedSet::default(); for grant in grants { @@ -1650,7 +1651,7 @@ mod tests { async fn register_dead_content_reparses_and_recharges_identically() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let cost = public_abi_pending::RUNNER_LOAD_COST + code.len() as u32; + let cost = public_abi::memory_limiter_consts::RUNNER_LOAD_COST + code.len() as u32; let limiter = limiter_with_budget(2 * cost); let mut loaded = runners::cache::LoadedSet::default(); diff --git a/executor/src/rt/supervisor/mod.rs b/executor/src/rt/supervisor/mod.rs index 2e2190b1..e29dd44a 100644 --- a/executor/src/rt/supervisor/mod.rs +++ b/executor/src/rt/supervisor/mod.rs @@ -7,7 +7,7 @@ use anyhow::Context as _; use genvm_common::*; use crate::{ - config, host, public_abi, public_abi_pending, + config, host, public_abi, rt::{self, memlimiter, DetNondet}, runners, wasi, }; @@ -139,8 +139,8 @@ impl Drop for Supervisor { const fn get_native_stack_size() -> u32 { let native_stack_size = (6 as u32) << 20; - let approximation = public_abi_pending::wasm_stack_limits::VALUE_SLOTS * 8 * 4 - + public_abi_pending::wasm_stack_limits::CALL_DEPTH * 64; + let approximation = public_abi::top_limits::WASM_STACK_VALUE_SLOTS * 8 * 4 + + public_abi::top_limits::WASM_CALL_DEPTH * 64; if native_stack_size < approximation { panic!("native stack size is smaller than the configured call depth limit"); @@ -162,8 +162,8 @@ pub fn create_engines( .async_stack_size(8 << 20) .max_wasm_stack(get_native_stack_size() as usize) .wasm_stack_limits( - public_abi_pending::wasm_stack_limits::CALL_DEPTH, - public_abi_pending::wasm_stack_limits::VALUE_SLOTS, + public_abi::top_limits::WASM_CALL_DEPTH, + public_abi::top_limits::WASM_STACK_VALUE_SLOTS, ); base_conf @@ -427,6 +427,13 @@ pub async fn spawn( }); } + if !limiter.consume(crate::public_abi::memory_limiter_consts::VM_SPAWN_COST) { + return Err(SpawnError { + error: errors::Error::vm(crate::public_abi::VmError::out_of().memory().val()).into(), + state: Box::new(SpawnErrorState::Unspawned(vm)), + }); + } + zelf.action_recorder .record_or_log( "vm_spawn", diff --git a/runners/genlayer-py-std/src/genlayer/vm/public_abi.py b/runners/genlayer-py-std/src/genlayer/vm/public_abi.py index a1e172fd..cc3445bb 100644 --- a/runners/genlayer-py-std/src/genlayer/vm/public_abi.py +++ b/runners/genlayer-py-std/src/genlayer/vm/public_abi.py @@ -34,6 +34,8 @@ class _MemoryLimiterConsts(typing.NamedTuple): TABLE_ENTRY: int = 64 FILE_MAPPING: int = 256 FD_ALLOCATION: int = 96 + RUNNER_LOAD_COST: int = 4096 + VM_SPAWN_COST: int = 134217728 memory_limiter_consts: typing.Final = _MemoryLimiterConsts() @@ -58,6 +60,8 @@ class _TopLimits(typing.NamedTuple): WEB_REQUEST_MIN_SPACE: int = 65536 WEB_RENDER_MIN_SPACE: int = 134217728 MAX_FDS: int = 1024 + WASM_CALL_DEPTH: int = 1024 + WASM_STACK_VALUE_SLOTS: int = 65535 top_limits: typing.Final = _TopLimits() From 65308e34668ff0e7db73c37a07a9509b102263bd Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 17:25:48 +0900 Subject: [PATCH 19/25] =?UTF-8?q?chore:=20update=20runner=20hashes=20?= =?UTF-8?q?=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- runners/support/versions/current.nix | 6 +++--- runners/support/versions/dev-mode.nix | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/runners/support/versions/current.nix b/runners/support/versions/current.nix index 8c4a8ed6..c8eb53f4 100644 --- a/runners/support/versions/current.nix +++ b/runners/support/versions/current.nix @@ -49,7 +49,7 @@ let }; genlayer-std = { - hash = "test"; + hash = "sha256-bDeSXJscwqYPfDdumJWlhnWuuRl1IJYbXWLqTBXEWR4="; depends = [ top ]; @@ -83,7 +83,7 @@ let wrappers = { __prefix = ""; py-genlayer = { - hash = "sha256-5yB84s5W613I0Us6onRHHU4mLUH45hhJh6Ny75dA0L0="; + hash = "sha256-StE5eaoXmd9cjlGgTz7V83sWlFC61FwtDeHnicS0qmw="; depends = [ cpython pyLibs.cloudpickle @@ -91,7 +91,7 @@ let ]; }; py-genlayer-multi = { - hash = "sha256-hcyUots2aiVJcOyanJWpcmG10YTd4AyxW7NwYukKTEo="; + hash = "sha256-pC+nNJLcP8VyrT5secov5y2EDUfnYxb1mSemA8v6SxM="; depends = [ cpython pyLibs.cloudpickle diff --git a/runners/support/versions/dev-mode.nix b/runners/support/versions/dev-mode.nix index 27ba77dd..c508d536 100644 --- a/runners/support/versions/dev-mode.nix +++ b/runners/support/versions/dev-mode.nix @@ -1 +1 @@ -true +false From db5f2cef99346763ad673a4702d7cb4a01c64980 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 18:23:45 +0900 Subject: [PATCH 20/25] =?UTF-8?q?chore:=20simplify=20memlimiter=20?= =?UTF-8?q?=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/src/exe/run.rs | 9 +-- executor/src/lib.rs | 29 ++++++--- executor/src/rt/memlimiter.rs | 93 ++++++--------------------- executor/src/rt/mod.rs | 7 +- executor/src/rt/supervisor/actions.rs | 12 ++-- executor/src/rt/supervisor/mod.rs | 64 +++++++----------- executor/src/runners/cache.rs | 5 +- executor/src/wasi/genlayer_sdk/mod.rs | 31 +++------ executor/src/wasi/genlayer_sdk/run.rs | 3 + 9 files changed, 84 insertions(+), 169 deletions(-) diff --git a/executor/src/exe/run.rs b/executor/src/exe/run.rs index c5771876..6ff89fbe 100644 --- a/executor/src/exe/run.rs +++ b/executor/src/exe/run.rs @@ -258,7 +258,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { let rt = runtime.enter(); - let supervisor = genvm::create_supervisor( + let context = genvm::create_supervisor( &config, hosts, genvm::CreateSupervisorNamedArgs { @@ -273,15 +273,12 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { message, ) .with_context(|| format!("creating supervisor for genvm_id {genvm_id}"))?; + let supervisor = context.supervisor.clone(); std::mem::drop(rt); let res = runtime - .block_on(genvm::run_with( - execution_data, - supervisor.clone(), - &args.permissions, - )) + .block_on(genvm::run_with(execution_data, context, &args.permissions)) .with_context(|| "running genvm"); if let Err(err) = &res { diff --git a/executor/src/lib.rs b/executor/src/lib.rs index 27fc0ecf..636be44c 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -70,6 +70,11 @@ pub struct CreateSupervisorNamedArgs { pub record_actions: Vec, } +pub struct ExecutionContext { + pub supervisor: Arc, + det_limiter: rt::memlimiter::Limiter, +} + pub fn create_supervisor( config: &config::Config, mut hosts: Vec, @@ -77,7 +82,7 @@ pub fn create_supervisor( host_data: genvm_modules_interfaces::HostData, shared_data: sync::DArc, message: &domain::MessageData, -) -> Result> { +) -> Result { let metrics = shared_data.gep(|x| &x.metrics); let role = if named.leader_nondet_results.is_none() { @@ -113,7 +118,8 @@ pub fn create_supervisor( )), }; - let limiter_det = rt::memlimiter::Limiter::new("det"); + // has locked slots + let limiter_det = rt::memlimiter::Limiter::new(); let storage_host_idx = if (host::host_fns::Methods::StorageRead as usize) < named.method_hosts.len() { @@ -135,25 +141,25 @@ pub fn create_supervisor( let ctor = rt::supervisor::Ctor { shared_data, modules, - limiter: rt::DetNondet { - det: limiter_det, - non_det: rt::memlimiter::Limiter::new("nondet"), - }, locked_slots, leader_nondet_results: named.leader_nondet_results, multi_host, record_actions: named.record_actions, }; - rt::supervisor::Supervisor::start(config, ctor) + Ok(ExecutionContext { + supervisor: rt::supervisor::Supervisor::start(config, ctor)?, + det_limiter: limiter_det, + }) } pub async fn run_with_impl( entry_data: domain::ExecutionData, - supervisor: Arc, + context: &ExecutionContext, permissions: &str, deploy_pin: &mut Option, ) -> anyhow::Result { + let supervisor = context.supervisor.clone(); let storage_pages_limit = supervisor.get_storage_limiter(); let mut topmost_storage = rt::vm::storage::Storage::new( @@ -233,6 +239,7 @@ pub async fn run_with_impl( let data_fees_limit = supervisor.shared_data.gep(|x| &x.data_fees_limit); let essential_data = Box::new(wasi::genlayer_sdk::SingleVMData { + limiter: context.det_limiter.derived(), depth: 0, spawn_kind: "initial".to_owned(), // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst @@ -297,7 +304,7 @@ pub async fn run_with_impl( pub async fn run_with( entry_data: domain::ExecutionData, - supervisor: Arc, + context: ExecutionContext, permissions: &str, ) -> anyhow::Result { // Deploy-time weak-cache bridge (ADR-012): the prepopulated deploy-runner @@ -307,7 +314,9 @@ pub async fn run_with( // earlier would make that load's outcome depend on which queue processed it. let mut deploy_pin: Option = None; - let res = run_with_impl(entry_data, supervisor.clone(), permissions, &mut deploy_pin).await; + let supervisor = context.supervisor.clone(); + + let res = run_with_impl(entry_data, &context, permissions, &mut deploy_pin).await; log_debug!("deterministic execution done"); diff --git a/executor/src/rt/memlimiter.rs b/executor/src/rt/memlimiter.rs index 3f6af0c3..0915e3d2 100644 --- a/executor/src/rt/memlimiter.rs +++ b/executor/src/rt/memlimiter.rs @@ -4,70 +4,43 @@ use std::sync::{atomic::AtomicU32, Arc}; use crate::rt; -struct LimiterInnerData { - remaining_memory: Arc, - least_remaining_memory: Arc, -} - struct LimiterInner { - data: Arc, - id: &'static str, - consumed_memory: AtomicU32, + remaining_memory: AtomicU32, } #[derive(Clone)] pub struct Limiter(Arc); -impl Drop for LimiterInner { - fn drop(&mut self) { - let consumed = self - .consumed_memory - .load(std::sync::atomic::Ordering::SeqCst); - - log_debug!(id = self.id, consumed = consumed; "limiter drop"); - - self.release_no_consumed(consumed); - } -} - impl LimiterInner { fn release_no_consumed(&self, delta: u32) { #[allow(dead_code)] let previous = self - .data .remaining_memory .fetch_add(delta, std::sync::atomic::Ordering::SeqCst); assert!(previous.checked_add(delta).is_some()); } } -impl Limiter { - pub fn get_least_remaining_memory(&self) -> u32 { - self.0 - .data - .least_remaining_memory - .load(std::sync::atomic::Ordering::SeqCst) +impl Default for Limiter { + fn default() -> Self { + Self::new() } +} - pub fn new(id: &'static str) -> Self { +impl Limiter { + pub fn new() -> Self { Self(Arc::new(LimiterInner { - id, - consumed_memory: AtomicU32::new(0), - data: Arc::new(LimiterInnerData { - remaining_memory: Arc::new(AtomicU32::new(u32::MAX)), - least_remaining_memory: Arc::new(AtomicU32::new(u32::MAX)), - }), + remaining_memory: AtomicU32::new(u32::MAX), })) } pub fn derived(&self) -> Self { Self(Arc::new(LimiterInner { - id: self.0.id, - consumed_memory: AtomicU32::new(0), - data: Arc::new(LimiterInnerData { - remaining_memory: self.0.data.remaining_memory.clone(), - least_remaining_memory: self.0.data.least_remaining_memory.clone(), - }), + remaining_memory: AtomicU32::new( + self.0 + .remaining_memory + .load(std::sync::atomic::Ordering::SeqCst), + ), })) } @@ -92,67 +65,37 @@ impl Limiter { pub fn get_remaining_memory(&self) -> u32 { self.0 - .data .remaining_memory .load(std::sync::atomic::Ordering::SeqCst) } pub fn release(&self, delta: u32) { self.0.release_no_consumed(delta); - let prev_consumed = self - .0 - .consumed_memory - .fetch_sub(delta, std::sync::atomic::Ordering::SeqCst); - // A limiter only ever releases something it consumed, so `consumed_memory` - // never underflows. Asserting on the atomic's return value (the value - // right before the subtraction) is race-free under concurrent - // consume/release on the same limiter. - debug_assert!( - prev_consumed >= delta, - "limiter {} release underflow: released {delta} with only {prev_consumed} consumed", - self.0.id - ); } pub fn consume(&self, delta: u32) -> bool { let mut remaining = self .0 - .data .remaining_memory .load(std::sync::atomic::Ordering::SeqCst); - log_debug!(delta = delta, remaining_at_op_start = remaining, id = self.0.id; "consume"); + log_trace!(delta = delta, remaining_at_op_start = remaining; "consume"); loop { if delta > remaining { return false; } - match self.0.data.remaining_memory.compare_exchange( + match self.0.remaining_memory.compare_exchange( remaining, remaining - delta, std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst, ) { - Ok(_) => { - let least_for_test = remaining - delta; - self.0 - .data - .least_remaining_memory - .fetch_min(least_for_test, std::sync::atomic::Ordering::SeqCst); - let prev_consumed = self - .0 - .consumed_memory - .fetch_add(delta, std::sync::atomic::Ordering::SeqCst); - // A single limiter's outstanding charge never exceeds the - // shared budget: `remaining_memory` stays >= 0 (the CAS above - // rejects delta > remaining), so the sum of every limiter's - // `consumed_memory` is <= u32::MAX and no single limiter can - // overflow. Return-value form keeps this race-free. + Ok(prev_remaining) => { debug_assert!( - prev_consumed.checked_add(delta).is_some(), - "limiter {} consumed_memory overflow: {prev_consumed} + {delta}", - self.0.id + prev_remaining.checked_sub(delta).is_some(), + "limiter remaining_memory overflow: {prev_remaining} - {delta}", ); break; } diff --git a/executor/src/rt/mod.rs b/executor/src/rt/mod.rs index 28f4fff1..7e37e524 100644 --- a/executor/src/rt/mod.rs +++ b/executor/src/rt/mod.rs @@ -97,12 +97,7 @@ async fn spawn_apply_run_inner( supervisor: &Arc, vm: Box, ) -> std::result::Result { - let limiter = supervisor - .limiter - .get(vm.conf.permissions.deterministic) - .derived(); - - let vm = supervisor::spawn(supervisor, vm, limiter).await?; + let vm = supervisor::spawn(supervisor, vm).await?; let vm = supervisor::apply_contract_actions(supervisor, vm).await?; diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index 5a9fec28..5e9b64e7 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -1364,7 +1364,7 @@ mod tests { // ── load-action charging ──────────────────────────────────────────── fn limiter_with_budget(budget: u32) -> rt::memlimiter::Limiter { - let limiter = rt::memlimiter::Limiter::new("actions-test"); + let limiter = rt::memlimiter::Limiter::new(); assert!(limiter.consume(u32::MAX - budget)); limiter } @@ -1405,7 +1405,7 @@ mod tests { #[test] fn charge_load_size_overflow_is_oom() { // RUNNER_LOAD_COST + u32::MAX overflows; must map to OOM, not wrap. - let limiter = rt::memlimiter::Limiter::new("actions-test"); + let limiter = rt::memlimiter::Limiter::new(); let err = charge_load(&limiter, u32::MAX as usize).unwrap_err(); assert!( err.to_string().contains("out_of memory"), @@ -1462,7 +1462,7 @@ mod tests { #[test] fn det_fingerprint_folds_charged_loads_in_execution_order() { let load = |ids: &[u8]| { - let limiter = rt::memlimiter::Limiter::new("actions-test"); + let limiter = rt::memlimiter::Limiter::new(); let mut loaded = runners::cache::LoadedSet::default(); let mut fp = sha3::Sha3_256::default(); for &n in ids { @@ -1482,7 +1482,7 @@ mod tests { #[test] fn det_fingerprint_ignores_cached_loads() { - let limiter = rt::memlimiter::Limiter::new("actions-test"); + let limiter = rt::memlimiter::Limiter::new(); let mut loaded = runners::cache::LoadedSet::default(); let mut fp = sha3::Sha3_256::default(); @@ -1531,7 +1531,7 @@ mod tests { async fn register_same_code_in_same_vm_is_free() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let limiter = rt::memlimiter::Limiter::new("actions-test"); + let limiter = rt::memlimiter::Limiter::new(); let mut loaded = runners::cache::LoadedSet::default(); let id = register_runner_load_into(®istry, &limiter, &mut loaded, None, code.clone()) @@ -1615,7 +1615,7 @@ mod tests { async fn granted_pins_keep_content_alive_after_parent_death() { let registry = runners::cache::WeakCache::new(); let code = valid_code(); - let parent_limiter = rt::memlimiter::Limiter::new("actions-test-parent"); + let parent_limiter = rt::memlimiter::Limiter::new(); let mut parent = runners::cache::LoadedSet::default(); let id = register_runner_load_into(®istry, &parent_limiter, &mut parent, None, code.clone()) diff --git a/executor/src/rt/supervisor/mod.rs b/executor/src/rt/supervisor/mod.rs index e29dd44a..6a6a338b 100644 --- a/executor/src/rt/supervisor/mod.rs +++ b/executor/src/rt/supervisor/mod.rs @@ -8,7 +8,7 @@ use genvm_common::*; use crate::{ config, host, public_abi, - rt::{self, memlimiter, DetNondet}, + rt::{self, DetNondet}, runners, wasi, }; @@ -28,7 +28,7 @@ pub struct NonDetVMTask { impl NonDetVMTask { pub async fn run_now(self, sup: &Arc) -> anyhow::Result { - run_single_nondet(sup, self, sup.limiter.get(false).derived()).await + run_single_nondet(sup, self).await } } @@ -54,7 +54,6 @@ pub struct Ctor { pub modules: crate::modules::All, - pub limiter: rt::DetNondet, pub locked_slots: host::LockedSlotsSet, pub leader_nondet_results: Option>, pub multi_host: host::MultiHost, @@ -103,7 +102,6 @@ impl ActionRecorder { pub struct Supervisor { pub shared_data: sync::DArc, pub modules: crate::modules::All, - pub limiter: rt::DetNondet, pub locked_slots: host::LockedSlotsSet, pub nondet_call_no: AtomicU32, @@ -220,17 +218,11 @@ pub async fn await_nondet_vms(zelf: &Arc) -> anyhow::Result, vm: Box, - limiter: rt::memlimiter::Limiter, ) -> std::result::Result, rt::SpawnError> { if vm.depth >= public_abi::top_limits::VM_RECURSION { return Err(rt::SpawnError { @@ -427,10 +412,14 @@ pub async fn spawn( }); } - if !limiter.consume(crate::public_abi::memory_limiter_consts::VM_SPAWN_COST) { - return Err(SpawnError { - error: errors::Error::vm(crate::public_abi::VmError::out_of().memory().val()).into(), - state: Box::new(SpawnErrorState::Unspawned(vm)), + if !vm + .limiter + .consume(crate::public_abi::memory_limiter_consts::VM_SPAWN_COST) + { + return Err(rt::SpawnError { + error: rt::errors::Error::vm(crate::public_abi::VmError::out_of().memory().val()) + .into(), + state: Box::new(rt::SpawnErrorState::Unspawned(vm)), }); } @@ -452,10 +441,11 @@ pub async fn spawn( let engine = zelf.engines.get(vm.conf.permissions.deterministic); + let limiter = vm.limiter.clone(); let mut store = wasmtime::Store::new( engine, rt::vm::WasmtimeStoreData { - limits: limiter.clone(), + limits: vm.limiter.clone(), genlayer_ctx: wasi::Context::new(vm, limiter).map_err(|(a, b)| rt::SpawnError { error: anyhow::Error::from(a), state: Box::new(rt::SpawnErrorState::Unspawned(b)), @@ -639,13 +629,11 @@ async fn apply_contract_actions_inner( async fn run_single_nondet( zelf: &std::sync::Arc, task: NonDetVMTask, - limiter: memlimiter::Limiter, ) -> anyhow::Result { let zelf = zelf.clone(); - let res = - tokio::task::spawn(async move { run_single_nondet_inner(&zelf, task, limiter).await }) - .await - .map_err(|e| anyhow::anyhow!("nondet VM task failed to join: {e}"))?; + let res = tokio::task::spawn(async move { run_single_nondet_inner(&zelf, task).await }) + .await + .map_err(|e| anyhow::anyhow!("nondet VM task failed to join: {e}"))?; match res { Ok(v) => Ok(v.run_ok), @@ -656,19 +644,13 @@ async fn run_single_nondet( async fn run_single_nondet_inner( zelf: &std::sync::Arc, task: NonDetVMTask, - limiter: memlimiter::Limiter, ) -> anyhow::Result { - let vm = spawn(zelf, task.task, limiter).await.map_err(|e| e.error)?; - let vm = apply_contract_actions(zelf, vm) - .await - .map_err(|e| e.error)?; - vm.run().await.map_err(|e| e.error) + rt::spawn_apply_run(zelf, task.task).await } async fn nondet_vm_processor( zelf: std::sync::Arc, read_permit: tokio::sync::OwnedRwLockReadGuard<()>, - limiter: memlimiter::Limiter, ) { let mut count = 0; loop { @@ -700,7 +682,7 @@ async fn nondet_vm_processor( let call_no = task.call_no; let (task, tok) = task.deconstruct(); - let res = run_single_nondet(&zelf, task, limiter.derived()).await; + let res = run_single_nondet(&zelf, task).await; let do_disagree = match res { Ok(rt::vm::RunOk::Return(v)) => { diff --git a/executor/src/runners/cache.rs b/executor/src/runners/cache.rs index 892a31ff..92c377ee 100644 --- a/executor/src/runners/cache.rs +++ b/executor/src/runners/cache.rs @@ -13,9 +13,8 @@ pub type Cell = Arc>; /// A strong reference (pin) to loaded runner content. While at least one pin is /// held the content stays resident; when the last pin drops the weak map's entry /// becomes dead and is purged on next access, freeing the bytes. Pins live in a -/// VM's [`LoadedSet`] (its store data) and thus die with the VM, so the limiter -/// refund at VM death coincides with the memory actually becoming freeable -/// (ADR-012). +/// VM's [`LoadedSet`] (its store data) and thus die with the VM, when the bytes +/// become freeable (ADR-012). #[derive(Clone)] pub struct ArchivePin(Cell); diff --git a/executor/src/wasi/genlayer_sdk/mod.rs b/executor/src/wasi/genlayer_sdk/mod.rs index 62bad4f2..1e4617eb 100644 --- a/executor/src/wasi/genlayer_sdk/mod.rs +++ b/executor/src/wasi/genlayer_sdk/mod.rs @@ -134,6 +134,7 @@ impl VMDataAccumulator { pub struct SingleVMData { pub conf: base::Config, + pub limiter: rt::memlimiter::Limiter, pub depth: u32, pub spawn_kind: String, pub message_data: ExtendedMessage, @@ -155,14 +156,12 @@ pub struct Context { /// This VM's **loaded set**: the charge-dedup set and pin holder for every /// runner it has loaded (ADR-012 §1). A sibling of `data` (not inside its /// accumulator), so it does not round-trip through sandbox children and is - /// dropped when this VM's store is torn down — coinciding with the VM - /// limiter's refund of the load charges. + /// dropped when this VM's store is torn down. pub loaded: crate::runners::cache::LoadedSet, - /// A handle to this VM's own memory limiter (a clone of its store limiter, so - /// it refunds at VM death). Runtime load actions (`RegisterRunner`, `MapFile`) - /// charge it directly, since a gl_call handler cannot otherwise reach the - /// store limiter. + /// A handle to this VM's own memory limiter. Runtime load actions + /// (`RegisterRunner`, `MapFile`) charge it directly, since a gl_call handler + /// cannot otherwise reach the store limiter. pub limiter: rt::memlimiter::Limiter, pub start_time: std::time::Instant, @@ -795,13 +794,7 @@ impl ContextVFS<'_> { return Err(generated::types::Errno::Forbidden.into()); } - let space_left = self - .context - .data - .supervisor - .limiter - .get(is_det) - .get_remaining_memory(); + let space_left = self.context.limiter.get_remaining_memory(); if space_left < abi::consts::top_limits::WEB_RENDER_MIN_SPACE { log_warn!(space_left = space_left; "not enough memory for web render"); @@ -836,13 +829,7 @@ impl ContextVFS<'_> { return Err(generated::types::Errno::Forbidden.into()); } - let space_left = self - .context - .data - .supervisor - .limiter - .get(is_det) - .get_remaining_memory(); + let space_left = self.context.limiter.get_remaining_memory(); if space_left < abi::consts::top_limits::WEB_REQUEST_MIN_SPACE { log_warn!(space_left = space_left; "not enough memory for web request"); @@ -1124,8 +1111,8 @@ impl ContextVFS<'_> { ) -> Result { let supervisor = self.context.data.supervisor.clone(); let is_det = self.context.data.conf.permissions.deterministic; - // Charge the VM's own limiter (refunded at VM death, coinciding with the - // pin drop), not the long-lived root limiter. + // Charge the VM's own limiter, not the long-lived root limiter. The pin + // drops with this VM's loaded set when the store is torn down. let limiter = self.context.limiter.clone(); let topmost_runner_id = self.context.data.conf.execution.topmost_runner_id.clone(); diff --git a/executor/src/wasi/genlayer_sdk/run.rs b/executor/src/wasi/genlayer_sdk/run.rs index 8641375f..f37dda9b 100644 --- a/executor/src/wasi/genlayer_sdk/run.rs +++ b/executor/src/wasi/genlayer_sdk/run.rs @@ -90,6 +90,7 @@ impl ContextVFS<'_> { .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; let vm_data = Box::new(SingleVMData { + limiter: self.context.limiter.derived(), depth: self.context.data.depth + 1, spawn_kind: "call_contract".to_owned(), // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst @@ -330,6 +331,7 @@ impl ContextVFS<'_> { }; let vm_data = Box::new(SingleVMData { + limiter: Default::default(), depth: self.context.data.depth + 1, spawn_kind: "run_nondet".to_owned(), // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst @@ -450,6 +452,7 @@ impl ContextVFS<'_> { let stolen_data = fake_my_data; let vm_data = Box::new(SingleVMData { + limiter: self.context.limiter.derived(), depth: self.context.data.depth + 1, spawn_kind: "sandbox".to_owned(), // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst From a7d97df0e27927a4a2b12673718cf9d45872b522 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 7 Jul 2026 21:56:04 +0900 Subject: [PATCH 21/25] =?UTF-8?q?chore:=20migrate=20to=20manager=20provide?= =?UTF-8?q?d=20structs=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/Cargo.lock | 5 +- executor/crates/common/src/domain/fees.rs | 108 ------- executor/crates/common/src/domain/fees/abi.rs | 182 ------------ executor/crates/common/src/lib.rs | 2 - executor/crates/common/src/logger/mod.rs | 4 +- executor/crates/common/src/stats.rs | 2 +- executor/crates/common/src/sync.rs | 8 +- executor/crates/common/tests/fees_abi.rs | 2 +- executor/crates/modules-interfaces/Cargo.toml | 6 + executor/crates/sdk-rs/src/abi/entry.rs | 4 +- executor/crates/sdk-rs/src/abi/fees.rs | 19 +- executor/crates/sdk-rs/src/abi/gl_call.rs | 2 +- executor/crates/sdk-rs/src/abi/mod.rs | 2 +- executor/crates/sdk-rs/src/storage/core.rs | 16 +- executor/crates/sdk-rs/src/storage/mod.rs | 2 +- executor/{crates/common => }/src/domain.rs | 43 +-- executor/src/exe/run.rs | 11 +- executor/src/host/message.rs | 4 +- executor/src/host/mod.rs | 275 +++++++++++++++--- executor/src/lib.rs | 27 +- executor/src/modules.rs | 2 +- executor/src/rt/fees.rs | 8 +- executor/src/rt/mod.rs | 2 +- executor/src/rt/vm/mod.rs | 2 +- executor/src/rt/vm/storage.rs | 6 + executor/src/wasi/genlayer_sdk/message.rs | 93 ++++-- executor/src/wasi/genlayer_sdk/mod.rs | 4 +- executor/src/wasi/genlayer_sdk/tests.rs | 11 +- executor/src/wasi/preview1.rs | 2 +- .../message/call_self_view_stack.0.stdout | 1 + .../message/call_self_view_stack.0_0.stdout | 26 ++ .../message/call_self_view_stack.jsonnet | 12 + .../message/call_self_view_stack.py | 16 + .../hash_random/hash_random.0.stdout | 2 +- .../set_order/set_order.0.stdout | 2 +- .../wasi/wasi_random/wasi_random.0.stdout | 2 +- .../wasi/wasi_random/wasi_random.jsonnet | 2 +- 37 files changed, 462 insertions(+), 455 deletions(-) delete mode 100644 executor/crates/common/src/domain/fees.rs delete mode 100644 executor/crates/common/src/domain/fees/abi.rs rename executor/{crates/common => }/src/domain.rs (53%) create mode 100644 tests/integration/message/call_self_view_stack.0.stdout create mode 100644 tests/integration/message/call_self_view_stack.0_0.stdout create mode 100644 tests/integration/message/call_self_view_stack.jsonnet create mode 100644 tests/integration/message/call_self_view_stack.py diff --git a/executor/Cargo.lock b/executor/Cargo.lock index 40e1f161..5e68f036 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -308,9 +308,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1081,6 +1081,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes", + "chrono", "genlayer_calldata", "num-bigint", "num-traits", diff --git a/executor/crates/common/src/domain/fees.rs b/executor/crates/common/src/domain/fees.rs deleted file mode 100644 index dbda93ce..00000000 --- a/executor/crates/common/src/domain/fees.rs +++ /dev/null @@ -1,108 +0,0 @@ -use genlayer_sdk::abi::gl_call::On; -use primitive_types::U256; - -mod abi; - -/// Balance-funded internal messages carry these params from the guest, so the -/// type is guest-facing and lives in sdk-rs; re-export it here to keep the -/// executor-side `genvm_common::domain::fees::InternalMessageParams` path stable. -pub use genlayer_sdk::abi::fees::InternalMessageParams; - -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - serde::Serialize, - serde::Deserialize, - genlayer_calldata::Encode, - genlayer_calldata::Decode, -)] -pub struct ExternalMessageParams { - pub gas_limit: U256, - pub max_gas_price: U256, -} - -#[derive( - Debug, - Clone, - serde::Serialize, - serde::Deserialize, - genlayer_calldata::Encode, - genlayer_calldata::Decode, -)] -pub enum MessageAllocationNodeParams { - Internal(std::sync::Arc), - External(ExternalMessageParams), -} - -/// One node of the message-fee allocation tree. -#[derive( - Debug, - Clone, - serde::Serialize, - serde::Deserialize, - genlayer_calldata::Encode, - genlayer_calldata::Decode, -)] -pub struct MessageAllocationNode { - /// Target contract address; `None` means wildcard (any recipient). - pub recipient: Option, - /// `None` = wildcard: all call keys for this recipient - /// (chain sentinel: `CALL_KEY_WILDCARD` = `bytes32(0)`). - pub call_key: Option, - /// Max budget for matching messages. - pub budget: U256, - pub on: On, - /// Same structure as TX-level params. - pub fee_params: MessageAllocationNodeParams, - pub children: Vec, -} - -impl MessageAllocationNode { - /// ABI-encodes the nested allocation tree as the chain's flat - /// `MessageAllocationNode[]` representation (matching `abi.encode(nodes)`), - /// flattening children to parent-pointer form via pre-order traversal so that - /// every parent precedes its children and their `parentIndex` is well-defined. - pub fn abi_encode(roots: &[MessageAllocationNode]) -> Vec { - abi::encode(roots) - } - - pub fn matches_internal( - &self, - on: On, - recipient: genlayer_sdk::calldata::Address, - call_key: genlayer_sdk::abi::CallKey, - ) -> Option> { - let MessageAllocationNodeParams::Internal(params) = &self.fee_params else { - return None; - }; - if on != self.on { - None - } else if self.recipient.as_ref().is_some_and(|r| *r != recipient) { - None - } else if self.call_key.as_ref().is_some_and(|ck| *ck != call_key) { - None - } else { - Some(params.clone()) - } - } - - pub fn matches_external( - &self, - recipient: genlayer_sdk::calldata::Address, - call_key: genlayer_sdk::abi::CallKey, - ) -> Option { - let MessageAllocationNodeParams::External(params) = &self.fee_params else { - return None; - }; - if self.recipient.as_ref().is_some_and(|r| *r != recipient) { - None - } else if self.call_key.as_ref().is_some_and(|ck| *ck != call_key) { - None - } else { - Some(*params) - } - } -} diff --git a/executor/crates/common/src/domain/fees/abi.rs b/executor/crates/common/src/domain/fees/abi.rs deleted file mode 100644 index 4af80e42..00000000 --- a/executor/crates/common/src/domain/fees/abi.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! EVM ABI encoding of the message-fee allocation tree into the chain's flat -//! parent-pointer `MessageAllocationNode[]` representation. - -use genlayer_sdk::abi::gl_call::On; -use primitive_types::U256; - -use super::{ - ExternalMessageParams, InternalMessageParams, MessageAllocationNode, - MessageAllocationNodeParams, -}; - -/// Parent pointer used by top-level (root-layer) allocation nodes in the flat -/// chain representation: `type(uint256).max`. Any other value is a 0-based index -/// into the submitted array. -const NODE_ROOT_SENTINEL: U256 = U256::MAX; - -/// Solidity `enum MessageType { External, Internal }`. -const MESSAGE_TYPE_EXTERNAL: u64 = 0; -const MESSAGE_TYPE_INTERNAL: u64 = 1; - -/// Number of head words in the Solidity `MessageAllocationNode` tuple -/// (messageType, onAcceptance, parentIndex, recipient, callKey, budget, feeParams). -const NODE_HEAD_WORDS: usize = 7; - -/// Number of head words in the Solidity `InternalMessageParams` tuple -/// (leader, validator, appealRounds, executionBudgetPerRound, rotations, -/// maxPriceGenPerTimeUnit, storageFeeMaxGasPrice, receiptFeeMaxGasPrice). -/// `rotations` is the only dynamic field; the three price caps (v0.6-dev, -/// CON-549) are static and follow it in the head region. -const INTERNAL_PARAMS_HEAD_WORDS: usize = 8; - -fn push_u256(buf: &mut Vec, value: U256) { - buf.extend_from_slice(&value.to_big_endian()); -} - -fn push_word(buf: &mut Vec, value: u64) { - push_u256(buf, U256::from(value)); -} - -fn push_bool(buf: &mut Vec, value: bool) { - push_word(buf, value as u64); -} - -/// Left-pads a 20-byte address into a 32-byte ABI word. -fn push_address(buf: &mut Vec, addr: Option<&genlayer_sdk::calldata::Address>) { - buf.extend_from_slice(&[0u8; 12]); - match addr { - Some(addr) => buf.extend_from_slice(&addr.raw()), - None => buf.extend_from_slice(&[0u8; 20]), - } -} - -/// `callKey` is already a 32-byte word; `None` is the `CALL_KEY_WILDCARD` = `bytes32(0)`. -fn push_call_key(buf: &mut Vec, call_key: Option<&genlayer_sdk::abi::CallKey>) { - match call_key { - Some(ck) => buf.extend_from_slice(&ck.0), - None => buf.extend_from_slice(&[0u8; 32]), - } -} - -/// ABI-encodes the nested allocation tree as the chain's flat -/// `MessageAllocationNode[]` representation (matching `abi.encode(nodes)`). -pub(super) fn encode(roots: &[MessageAllocationNode]) -> Vec { - // Pre-order flatten: (node, parentIndex). Parents always precede children, - // so the parent's array index is already assigned when a child is visited. - let mut flat: Vec<(&MessageAllocationNode, U256)> = Vec::new(); - fn flatten<'a>( - nodes: &'a [MessageAllocationNode], - parent: U256, - out: &mut Vec<(&'a MessageAllocationNode, U256)>, - ) { - for node in nodes { - let my_index = U256::from(out.len() as u64); - out.push((node, parent)); - flatten(&node.children, my_index, out); - } - } - flatten(roots, NODE_ROOT_SENTINEL, &mut flat); - - // Encode each element as its own self-contained dynamic tuple. - let elements: Vec> = flat - .iter() - .map(|(node, parent_index)| encode_node(node, *parent_index)) - .collect(); - - // `abi.encode(MessageAllocationNode[])`: leading offset (0x20) to the array. - let mut buf = Vec::new(); - push_word(&mut buf, 0x20); - - // Dynamic array of dynamic elements: length, then per-element head offsets - // (relative to the start of the head region, i.e. right after the length), - // then the element tails. - push_word(&mut buf, elements.len() as u64); - let mut offset = elements.len() * 32; - for element in &elements { - push_word(&mut buf, offset as u64); - offset += element.len(); - } - for element in &elements { - buf.extend_from_slice(element); - } - - buf -} - -/// Encodes a single node as the Solidity `MessageAllocationNode` dynamic -/// tuple (offsets are relative to the start of this tuple). -fn encode_node(node: &MessageAllocationNode, parent_index: U256) -> Vec { - let (message_type, on_acceptance, fee_params) = match &node.fee_params { - MessageAllocationNodeParams::Internal(params) => ( - MESSAGE_TYPE_INTERNAL, - matches!(node.on, On::Accepted), - encode_internal_params(params), - ), - // External messages have no acceptance/finalize lifecycle. - MessageAllocationNodeParams::External(params) => { - (MESSAGE_TYPE_EXTERNAL, false, encode_external_params(params)) - } - }; - - let mut buf = Vec::new(); - push_word(&mut buf, message_type); - push_bool(&mut buf, on_acceptance); - push_u256(&mut buf, parent_index); - push_address(&mut buf, node.recipient.as_ref()); - push_call_key(&mut buf, node.call_key.as_ref()); - push_u256(&mut buf, node.budget); - // `feeParams` is the only dynamic field; its offset follows the head words. - push_word(&mut buf, (NODE_HEAD_WORDS * 32) as u64); - // Tail: `bytes feeParams` = length + 32-aligned data. - push_word(&mut buf, fee_params.len() as u64); - buf.extend_from_slice(&fee_params); - let pad = fee_params.len().next_multiple_of(32) - fee_params.len(); - buf.extend(std::iter::repeat_n(0u8, pad)); - - buf -} - -/// `abi.encode(InternalMessageParams)` — a dynamic tuple (contains -/// `uint256[] rotations`), so it is prefixed with the offset word. `appealRounds` -/// is reconstructed as `len(rotations) - 1` (the chain-derived value). -/// -/// Field order matches the chain's `InternalMessageFeeParams` (v0.6-dev): -/// leader, validator, appealRounds, executionBudgetPerRound, rotations, -/// maxPriceGenPerTimeUnit, storageFeeMaxGasPrice, receiptFeeMaxGasPrice. The -/// three price caps are static and sit in the head *after* the `rotations` -/// offset word, so the rotations tail starts past all 8 head words. -fn encode_internal_params(params: &InternalMessageParams) -> Vec { - let appeal_rounds = U256::from(params.rotations.len().saturating_sub(1) as u64); - - let mut buf = Vec::new(); - // `abi.encode` of a single dynamic struct prefixes the tuple with its offset. - push_word(&mut buf, 0x20); - push_u256(&mut buf, params.leader_timeunits_allocation); - push_u256(&mut buf, params.validator_timeunits_allocation); - push_u256(&mut buf, appeal_rounds); - push_u256(&mut buf, params.execution_budget_per_round); - // `rotations` is the only dynamic field; its offset follows all head words. - push_word(&mut buf, (INTERNAL_PARAMS_HEAD_WORDS * 32) as u64); - // Static price caps occupy head words 6-8 (between the rotations offset and - // the rotations tail), mirroring the chain struct field order. - push_u256(&mut buf, params.max_price_gen_per_time_unit); - push_u256(&mut buf, params.storage_fee_max_gas_price); - push_u256(&mut buf, params.receipt_fee_max_gas_price); - // Tail: `rotations` length + data, after the 8 head words. - push_word(&mut buf, params.rotations.len() as u64); - for rotation in ¶ms.rotations { - push_u256(&mut buf, *rotation); - } - - buf -} - -/// `abi.encode(ExternalMessageParams)` — a fully static tuple, so it is just -/// the two inline words with no leading offset. -fn encode_external_params(params: &ExternalMessageParams) -> Vec { - let mut buf = Vec::new(); - push_u256(&mut buf, params.gas_limit); - push_u256(&mut buf, params.max_gas_price); - - buf -} diff --git a/executor/crates/common/src/lib.rs b/executor/crates/common/src/lib.rs index 9005299f..8880f654 100644 --- a/executor/crates/common/src/lib.rs +++ b/executor/crates/common/src/lib.rs @@ -23,8 +23,6 @@ pub mod host_fns; pub mod public_abi_pending; pub mod util; -pub mod domain; - #[cfg(not(debug_assertions))] fn default_log_level() -> logger::Level { logger::Level::Info diff --git a/executor/crates/common/src/logger/mod.rs b/executor/crates/common/src/logger/mod.rs index 962cf150..7af0af4c 100644 --- a/executor/crates/common/src/logger/mod.rs +++ b/executor/crates/common/src/logger/mod.rs @@ -5,7 +5,7 @@ use serde::Serialize; use std::{io::Write, str::FromStr}; -use crate::calldata; +use genlayer_calldata as calldata; #[derive(Debug, Clone, Copy, clap::ValueEnum, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[clap(rename_all = "kebab_case")] @@ -236,7 +236,7 @@ macro_rules! __make_capture { (cd = $value:expr) => { $crate::logger::Capture::Cd(&|v, conf| { - let val = $crate::calldata::to_value(&$value); + let val = genlayer_calldata::to_value(&$value); serde::Serialize::serialize(&val, $crate::logger::Visitor(v, 0, *conf)) }) }; diff --git a/executor/crates/common/src/stats.rs b/executor/crates/common/src/stats.rs index dcf43045..afc140fc 100644 --- a/executor/crates/common/src/stats.rs +++ b/executor/crates/common/src/stats.rs @@ -114,7 +114,7 @@ pub mod metric { } } - use crate::calldata; + use genlayer_calldata as calldata; impl calldata::codec::Encode for TokenMetrics { type Error = W::Error; diff --git a/executor/crates/common/src/sync.rs b/executor/crates/common/src/sync.rs index 2159e512..b9f77771 100644 --- a/executor/crates/common/src/sync.rs +++ b/executor/crates/common/src/sync.rs @@ -371,12 +371,12 @@ mod darc { pub use darc::{DArc, DArcStruct}; -impl + 'static> - crate::calldata::codec::Encode for DArc +impl + 'static> + genlayer_calldata::codec::Encode for DArc { - type Error = >::Error; + type Error = >::Error; - fn encode(&self, enc: &mut crate::calldata::Encoder) -> Result<(), Self::Error> { + fn encode(&self, enc: &mut genlayer_calldata::Encoder) -> Result<(), Self::Error> { (**self).encode(enc) } } diff --git a/executor/crates/common/tests/fees_abi.rs b/executor/crates/common/tests/fees_abi.rs index 60186e06..ada8ecb0 100644 --- a/executor/crates/common/tests/fees_abi.rs +++ b/executor/crates/common/tests/fees_abi.rs @@ -1,5 +1,5 @@ use genlayer_sdk::abi::gl_call::On; -use genvm_common::domain::fees::{ +use genvm_modules_interfaces::domain::{ ExternalMessageParams, InternalMessageParams, MessageAllocationNode, MessageAllocationNodeParams, }; diff --git a/executor/crates/modules-interfaces/Cargo.toml b/executor/crates/modules-interfaces/Cargo.toml index fa764370..acf2fb5b 100644 --- a/executor/crates/modules-interfaces/Cargo.toml +++ b/executor/crates/modules-interfaces/Cargo.toml @@ -6,9 +6,15 @@ edition = "2021" [lib] path = "../../../../../crates/modules-interfaces/src/lib.rs" +[features] +arbitrary = ["dep:arbitrary"] +default = [] + [dependencies] anyhow = "1.0.97" +arbitrary = { version = "1.0", optional = true } bytes = { version = "1.11.0", features = ["serde"] } +chrono = "0.4.45" genlayer_calldata = { path = "../calldata" } num-bigint = "0.4" num-traits = "0.2" diff --git a/executor/crates/sdk-rs/src/abi/entry.rs b/executor/crates/sdk-rs/src/abi/entry.rs index b2d2b6a6..45cf42af 100644 --- a/executor/crates/sdk-rs/src/abi/entry.rs +++ b/executor/crates/sdk-rs/src/abi/entry.rs @@ -613,7 +613,7 @@ pub mod wasi_only { pub fn user_error_immediately(msg: String) -> ! { let last_gl_call_data = calldata::Value::Map(std::collections::BTreeMap::from([( "UserError".to_owned(), - crate::calldata::Value::Str(msg), + genlayer_calldata::Value::Str(msg), )])); let last_gl_call_data = calldata::encode(&last_gl_call_data); @@ -689,7 +689,7 @@ pub mod wasi_only { )])), Err(e) => calldata::Value::Map(std::collections::BTreeMap::from([( "UserError".to_owned(), - crate::calldata::Value::Str(e.to_string()), + genlayer_calldata::Value::Str(e.to_string()), )])), }; diff --git a/executor/crates/sdk-rs/src/abi/fees.rs b/executor/crates/sdk-rs/src/abi/fees.rs index e7c91997..72d6b157 100644 --- a/executor/crates/sdk-rs/src/abi/fees.rs +++ b/executor/crates/sdk-rs/src/abi/fees.rs @@ -3,11 +3,26 @@ //! [`InternalMessageParams`] lives here (rather than in `genvm_common`) so that //! [`gl_call`](super::gl_call) can reference it: a guest emitting a //! balance-funded internal message ships these params, and GenVM meters the fee -//! from them. `genvm_common::domain::fees` re-exports the type for the -//! executor-side call sites, so those paths are unchanged. +//! from them. use primitive_types::U256; +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + genlayer_calldata::Encode, + genlayer_calldata::Decode, +)] +pub struct ExternalMessageParams { + pub gas_limit: U256, + pub max_gas_price: U256, +} + #[derive( Debug, Clone, diff --git a/executor/crates/sdk-rs/src/abi/gl_call.rs b/executor/crates/sdk-rs/src/abi/gl_call.rs index 34b2c99a..32c654b3 100644 --- a/executor/crates/sdk-rs/src/abi/gl_call.rs +++ b/executor/crates/sdk-rs/src/abi/gl_call.rs @@ -7,7 +7,7 @@ use bytes::Bytes; use genlayer_calldata::{Decode, Encode}; use serde::{Deserialize, Serialize}; -use crate::calldata; +use genlayer_calldata as calldata; use super::consts as public_abi; use super::fees; diff --git a/executor/crates/sdk-rs/src/abi/mod.rs b/executor/crates/sdk-rs/src/abi/mod.rs index 66676022..0bd2e3e0 100644 --- a/executor/crates/sdk-rs/src/abi/mod.rs +++ b/executor/crates/sdk-rs/src/abi/mod.rs @@ -8,7 +8,7 @@ //! - [`gl_call`]: Message types for gl_call operations //! - [`wasi`]: WASI bindings for storage, balance, and gl_call -use crate::calldata; +use genlayer_calldata as calldata; #[cfg(feature = "arbitrary")] pub(crate) mod arb; diff --git a/executor/crates/sdk-rs/src/storage/core.rs b/executor/crates/sdk-rs/src/storage/core.rs index 29cea1f2..9edd5a5c 100644 --- a/executor/crates/sdk-rs/src/storage/core.rs +++ b/executor/crates/sdk-rs/src/storage/core.rs @@ -381,18 +381,18 @@ pub struct StorageAddress { } impl StorageAddress { - pub fn get(&self) -> crate::calldata::Address { + pub fn get(&self) -> genlayer_calldata::Address { let mut buf = [0u8; 20]; self.slot.read(self.offset, &mut buf); - crate::calldata::Address::from(buf) + genlayer_calldata::Address::from(buf) } - pub fn set(&self, val: crate::calldata::Address) { + pub fn set(&self, val: genlayer_calldata::Address) { self.slot.write(self.offset, &val.raw()); } } -impl StorageType for crate::calldata::Address { +impl StorageType for genlayer_calldata::Address { const SIZE: u32 = 20; type Handle = StorageAddress; fn handle_at(slot: Slot, offset: u32) -> Self::Handle { @@ -400,12 +400,12 @@ impl StorageType for crate::calldata::Address { } } -impl StorageValue for crate::calldata::Address { - type Value = crate::calldata::Address; - fn storage_get(handle: &StorageAddress) -> crate::calldata::Address { +impl StorageValue for genlayer_calldata::Address { + type Value = genlayer_calldata::Address; + fn storage_get(handle: &StorageAddress) -> genlayer_calldata::Address { handle.get() } - fn storage_set(handle: &StorageAddress, val: &crate::calldata::Address) { + fn storage_set(handle: &StorageAddress, val: &genlayer_calldata::Address) { handle.set(*val) } } diff --git a/executor/crates/sdk-rs/src/storage/mod.rs b/executor/crates/sdk-rs/src/storage/mod.rs index e534b6e8..75c533fb 100644 --- a/executor/crates/sdk-rs/src/storage/mod.rs +++ b/executor/crates/sdk-rs/src/storage/mod.rs @@ -9,7 +9,7 @@ pub use self::array::*; pub use self::core::*; pub use self::tree_map::*; -use crate::calldata::Address; +use genlayer_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: diff --git a/executor/crates/common/src/domain.rs b/executor/src/domain.rs similarity index 53% rename from executor/crates/common/src/domain.rs rename to executor/src/domain.rs index b5ed4b8a..f3c18c92 100644 --- a/executor/crates/common/src/domain.rs +++ b/executor/src/domain.rs @@ -1,14 +1,6 @@ use bytes::Bytes; use primitive_types::U256; -pub use genlayer_sdk::abi::entry::MessageData; - -pub mod fees; - -fn default_record_actions() -> Vec { - Vec::new() -} - #[allow(clippy::enum_variant_names)] #[derive(Debug, Clone, PartialEq, Eq, genlayer_calldata::Encode)] #[calldata(tag = "type")] @@ -20,7 +12,7 @@ pub enum ExecutionEmission { message_fee: U256, receipt_fee: U256, - fee_params: fees::ExternalMessageParams, + fee_params: genlayer_sdk::abi::fees::ExternalMessageParams, }, PostMessage { call_key: genlayer_sdk::abi::CallKey, @@ -31,7 +23,7 @@ pub enum ExecutionEmission { message_fee: U256, receipt_fee: U256, - fee_params: fees::InternalMessageParams, + fee_params: genlayer_sdk::abi::fees::InternalMessageParams, subtree: bytes::Bytes, /// Chain `useBalance`: the fee is drawn from the emitting contract's /// balance rather than the sender's prefunded message-fee pool. @@ -46,7 +38,7 @@ pub enum ExecutionEmission { message_fee: U256, receipt_fee: U256, - fee_params: fees::InternalMessageParams, + fee_params: genlayer_sdk::abi::fees::InternalMessageParams, subtree: bytes::Bytes, /// Chain `useBalance`; see `ExecutionEmission::PostMessage::use_balance`. use_balance: bool, @@ -59,32 +51,3 @@ pub enum ExecutionEmission { storage_fee: U256, }, } - -#[derive( - Debug, - Clone, - serde::Serialize, - serde::Deserialize, - genlayer_calldata::Encode, - genlayer_calldata::Decode, -)] -pub struct ExecutionData { - pub calldata: Bytes, - pub message: MessageData, - pub host_data: String, - pub code: Option, - pub leader_nondet_results: Option>, - /// Maps each host method (by index) to a host id. When empty, all methods use host 0. - pub method_hosts: Vec, - pub bucket_totals: Vec, - /// Host-provided `node` fee constants (moved off `host_data`). - pub gas_data: std::collections::BTreeMap, - /// Message-fee allocation tree passed alongside the execution. - pub message_fee_allocation: Vec, - /// Initial time-unit budget for this execution. - pub initial_time_units_allocation: u32, - /// Auditable supervisor action kinds to return in the execution result. - #[serde(default)] - #[calldata(default = default_record_actions)] - pub record_actions: Vec, -} diff --git a/executor/src/exe/run.rs b/executor/src/exe/run.rs index 6ff89fbe..e7cc5d82 100644 --- a/executor/src/exe/run.rs +++ b/executor/src/exe/run.rs @@ -83,17 +83,18 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { .with_context(|| format!("reading execution data from {}", args.execution_data))? }; - let execution_data = calldata::decode_obj::(&execution_data_bytes) - .with_context(|| "decoding execution data")?; - let mut execution_data = execution_data; - execution_data + let mut execution_data_iface = + calldata::decode_obj::(&execution_data_bytes) + .with_context(|| "decoding execution data")?; + execution_data_iface .record_actions .extend(args.record_actions.iter().cloned()); - for action in &execution_data.record_actions { + for action in &execution_data_iface.record_actions { if !matches!(action.as_str(), "runner_load" | "vm_spawn") { anyhow::bail!("Invalid action recorder kind {action}"); } } + let execution_data = execution_data_iface; let message = &execution_data.message; let host_data = rt::parse_host_data(&execution_data)?; diff --git a/executor/src/host/message.rs b/executor/src/host/message.rs index db1ef821..33c6b18e 100644 --- a/executor/src/host/message.rs +++ b/executor/src/host/message.rs @@ -40,12 +40,12 @@ impl AccountAddress { #[repr(C)] pub struct SlotID(#[serde_as(as = "Base64")] pub [u8; 32]); -impl genvm_common::calldata::codec::Encode for SlotID { +impl genlayer_sdk::calldata::codec::Encode for SlotID { type Error = W::Error; fn encode( &self, - enc: &mut genvm_common::calldata::Encoder, + enc: &mut genlayer_sdk::calldata::Encoder, ) -> std::result::Result<(), Self::Error> { enc.push_bytes(&self.0) } diff --git a/executor/src/host/mod.rs b/executor/src/host/mod.rs index 64fd32ee..46dac25a 100644 --- a/executor/src/host/mod.rs +++ b/executor/src/host/mod.rs @@ -7,15 +7,15 @@ 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 genlayer_sdk::calldata::Address; +use genlayer_sdk::calldata::ADDRESS_SIZE; use core::str; use std::os::fd::FromRawFd; use anyhow::{Context, Result}; -use crate::{calldata, rt}; +use crate::{calldata, domain, rt}; pub use message::SlotID; pub trait Sock: std::io::Read + std::io::Write + Send + Sync {} @@ -116,7 +116,7 @@ fn handle_host_error(sock: &mut dyn Sock, context: &str) -> Result<()> { pub fn encode_result(res: &Result) -> Result> { match res { Ok(d) => { - let mut encoded = Vec::from([d.kind as u8]); + let mut encoded = Vec::from([d.reported.kind as u8]); let as_value = calldata::to_value(d); calldata::encode_to(&mut calldata::Encoder::new(&mut encoded), &as_value)?; Ok(encoded) @@ -150,43 +150,45 @@ pub fn all_useful_work_done() { std::process::exit(0); } -#[derive(Debug, Clone, genlayer_calldata::Encode)] +#[derive(Debug, Clone)] pub struct FullResult { - pub execution_hash: bytes::Bytes, - - pub kind: public_abi::ResultCode, - pub data: calldata::unparsed::Maybe, - pub backtrace: Option, - pub wasm_store_hashes: rt::errors::WasmStoreHashes, - pub storage_changes: Vec, + pub reported: genvm_modules_interfaces::ReportedResult, + pub recorded_actions: Vec, +} - pub emissions: Vec, +impl std::ops::Deref for FullResult { + type Target = genvm_modules_interfaces::ReportedResult; - pub nondet_disagreement: Option, - pub nondet_results: Vec, + fn deref(&self) -> &Self::Target { + &self.reported + } +} - pub data_fees_remaining: Vec, - pub data_fees_consumed: rt::fees::BucketsConsumed, +impl calldata::codec::Encode for FullResult { + type Error = W::Error; - pub llm_consumption: primitive_types::U256, - pub recorded_actions: Vec, + fn encode(&self, enc: &mut calldata::Encoder) -> std::result::Result<(), Self::Error> { + calldata::codec::Encode::encode(&self.reported, enc) + } } impl FullResult { pub fn new_internal_error(msg: String) -> Self { Self { - execution_hash: bytes::Bytes::new(), - kind: public_abi::ResultCode::InternalError, - data: calldata::Value::Str(msg).into(), - backtrace: None, - wasm_store_hashes: rt::errors::WasmStoreHashes::default(), - storage_changes: Vec::new(), - emissions: Vec::new(), - nondet_disagreement: None, - nondet_results: Vec::new(), - data_fees_remaining: Vec::new(), - data_fees_consumed: rt::fees::BucketsConsumed::default(), - llm_consumption: primitive_types::U256::zero(), + reported: genvm_modules_interfaces::ReportedResult { + execution_hash: bytes::Bytes::new(), + kind: genvm_modules_interfaces::ResultCode::InternalError, + data: calldata::Value::Str(msg).into(), + backtrace: None, + wasm_store_hashes: genvm_modules_interfaces::WasmStoreHashes::default(), + storage_changes: Vec::new(), + emissions: Vec::new(), + nondet_disagreement: None, + nondet_results: Vec::new(), + data_fees_remaining: Vec::new(), + data_fees_consumed: genvm_modules_interfaces::BucketsConsumed::default(), + llm_consumption: primitive_types::U256::zero(), + }, recorded_actions: Vec::new(), } } @@ -268,24 +270,209 @@ impl FullResult { let execution_hash = bytes::Bytes::from(sha3::Digest::finalize(hasher.0).to_vec()); Self { - execution_hash, - - data: rt_result.data, - backtrace: rt_result.backtrace, - wasm_store_hashes: rt_result.wasm_store_hashes, - kind: rt_result.kind, - storage_changes: rt_result.storage_changes, - emissions: rt_result.emissions, - nondet_results, - nondet_disagreement, - data_fees_remaining, - data_fees_consumed, - llm_consumption, + reported: genvm_modules_interfaces::ReportedResult { + execution_hash, + + data: rt_result.data, + backtrace: rt_result.backtrace.map(convert_backtrace), + wasm_store_hashes: convert_wasm_store_hashes(rt_result.wasm_store_hashes), + kind: convert_result_code(rt_result.kind), + storage_changes: rt_result + .storage_changes + .iter() + .map(convert_storage_delta) + .collect(), + emissions: rt_result + .emissions + .into_iter() + .map(convert_emission) + .collect(), + nondet_results, + nondet_disagreement, + data_fees_remaining, + data_fees_consumed: convert_buckets_consumed(data_fees_consumed), + llm_consumption, + }, recorded_actions, } } } +fn convert_result_code(code: public_abi::ResultCode) -> genvm_modules_interfaces::ResultCode { + match code { + public_abi::ResultCode::Return => genvm_modules_interfaces::ResultCode::Return, + public_abi::ResultCode::UserError => genvm_modules_interfaces::ResultCode::UserError, + public_abi::ResultCode::VmError => genvm_modules_interfaces::ResultCode::VmError, + public_abi::ResultCode::InternalError => { + genvm_modules_interfaces::ResultCode::InternalError + } + } +} + +fn convert_backtrace(backtrace: rt::errors::Backtrace) -> genvm_modules_interfaces::Backtrace { + genvm_modules_interfaces::Backtrace { + frames: backtrace + .frames + .into_iter() + .map(|frame| genvm_modules_interfaces::Frame { + module_name: frame.module_name, + func: frame.func, + }) + .collect(), + } +} + +fn convert_wasm_store_hashes( + hashes: rt::errors::WasmStoreHashes, +) -> genvm_modules_interfaces::WasmStoreHashes { + genvm_modules_interfaces::WasmStoreHashes( + hashes + .0 + .into_iter() + .map(|(module, fingerprint)| { + ( + module, + genvm_modules_interfaces::ModuleFingerprint { + memories: fingerprint + .memories + .into_iter() + .map(|memory| memory.0) + .collect(), + }, + ) + }) + .collect(), + ) +} + +fn convert_storage_delta(delta: &rt::vm::storage::Delta) -> genvm_modules_interfaces::StorageDelta { + let (key, value) = delta.cloned_parts(); + genvm_modules_interfaces::StorageDelta::new(key, value) +} + +fn convert_buckets_consumed( + consumed: rt::fees::BucketsConsumed, +) -> genvm_modules_interfaces::BucketsConsumed { + genvm_modules_interfaces::BucketsConsumed { + storage: consumed.storage, + message_receipt: consumed.message_receipt, + nondet_output: consumed.nondet_output, + message_fee: consumed.message_fee, + event: consumed.event, + } +} + +fn convert_on(on: genlayer_sdk::abi::gl_call::On) -> genvm_modules_interfaces::On { + match on { + genlayer_sdk::abi::gl_call::On::Finalized => genvm_modules_interfaces::On::Finalized, + genlayer_sdk::abi::gl_call::On::Accepted => genvm_modules_interfaces::On::Accepted, + } +} + +fn convert_call_key(call_key: genlayer_sdk::abi::CallKey) -> genvm_modules_interfaces::CallKey { + genvm_modules_interfaces::CallKey(call_key.0) +} + +fn convert_internal_message_params( + params: genlayer_sdk::abi::fees::InternalMessageParams, +) -> genvm_modules_interfaces::fees::InternalMessageParams { + genvm_modules_interfaces::fees::InternalMessageParams { + leader_timeunits_allocation: params.leader_timeunits_allocation, + validator_timeunits_allocation: params.validator_timeunits_allocation, + execution_budget_per_round: params.execution_budget_per_round, + rotations: params.rotations, + max_price_gen_per_time_unit: params.max_price_gen_per_time_unit, + storage_fee_max_gas_price: params.storage_fee_max_gas_price, + receipt_fee_max_gas_price: params.receipt_fee_max_gas_price, + } +} + +fn convert_external_message_params( + params: genlayer_sdk::abi::fees::ExternalMessageParams, +) -> genvm_modules_interfaces::fees::ExternalMessageParams { + genvm_modules_interfaces::fees::ExternalMessageParams { + gas_limit: params.gas_limit, + max_gas_price: params.max_gas_price, + } +} + +fn convert_emission( + emission: domain::ExecutionEmission, +) -> genvm_modules_interfaces::ExecutionEmission { + match emission { + domain::ExecutionEmission::EthSend { + address, + calldata, + value, + message_fee, + receipt_fee, + fee_params, + } => genvm_modules_interfaces::ExecutionEmission::EthSend { + address, + calldata, + value, + message_fee, + receipt_fee, + fee_params: convert_external_message_params(fee_params), + }, + domain::ExecutionEmission::PostMessage { + call_key, + address, + calldata, + value, + on, + message_fee, + receipt_fee, + fee_params, + subtree, + use_balance, + } => genvm_modules_interfaces::ExecutionEmission::PostMessage { + call_key: convert_call_key(call_key), + address, + calldata, + value, + on: convert_on(on), + message_fee, + receipt_fee, + fee_params: convert_internal_message_params(fee_params), + subtree, + use_balance, + }, + domain::ExecutionEmission::DeployContract { + calldata, + code, + value, + on, + salt_nonce, + message_fee, + receipt_fee, + fee_params, + subtree, + use_balance, + } => genvm_modules_interfaces::ExecutionEmission::DeployContract { + calldata, + code, + value, + on: convert_on(on), + salt_nonce, + message_fee, + receipt_fee, + fee_params: convert_internal_message_params(fee_params), + subtree, + use_balance, + }, + domain::ExecutionEmission::EmitEvent { + topics, + blob, + storage_fee, + } => genvm_modules_interfaces::ExecutionEmission::EmitEvent { + topics, + blob, + storage_fee, + }, + } +} + impl Host { fn lock_sock(&mut self) -> sync::Lock<&mut dyn Sock, stats::tracker::Time> { sync::Lock::new( diff --git a/executor/src/lib.rs b/executor/src/lib.rs index 636be44c..d6aa3719 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -1,5 +1,6 @@ pub mod caching; pub mod config; +pub mod domain; pub mod host; pub mod modules; pub mod rt; @@ -8,7 +9,7 @@ pub mod wasi; pub use genlayer_sdk::abi::consts as public_abi; -pub use genvm_common::calldata; +pub use genlayer_sdk::calldata; use genvm_common::*; pub use host::{Host, SlotID}; @@ -81,7 +82,7 @@ pub fn create_supervisor( named: CreateSupervisorNamedArgs, host_data: genvm_modules_interfaces::HostData, shared_data: sync::DArc, - message: &domain::MessageData, + message: &genvm_modules_interfaces::MessageData, ) -> Result { let metrics = shared_data.gep(|x| &x.metrics); @@ -153,8 +154,24 @@ pub fn create_supervisor( }) } +fn convert_message_data( + message: genvm_modules_interfaces::MessageData, +) -> genlayer_sdk::abi::entry::MessageData { + genlayer_sdk::abi::entry::MessageData { + contract_address: message.contract_address, + sender_address: message.sender_address, + origin_address: message.origin_address, + signer_address: message.signer_address, + stack: Vec::new(), + chain_id: message.chain_id, + value: message.value, + is_init: message.is_init, + datetime: message.datetime, + } +} + pub async fn run_with_impl( - entry_data: domain::ExecutionData, + entry_data: genvm_modules_interfaces::ExecutionData, context: &ExecutionContext, permissions: &str, deploy_pin: &mut Option, @@ -260,7 +277,7 @@ pub async fn run_with_impl( }, }, message_data: ExtendedMessage { - message: entry_data.message, + message: convert_message_data(entry_data.message), entry_kind: public_abi::EntryKind::Main, entry_data: entry_data.calldata, entry_stage_data: calldata::Value::Null, @@ -303,7 +320,7 @@ pub async fn run_with_impl( } pub async fn run_with( - entry_data: domain::ExecutionData, + entry_data: genvm_modules_interfaces::ExecutionData, context: ExecutionContext, permissions: &str, ) -> anyhow::Result { diff --git a/executor/src/modules.rs b/executor/src/modules.rs index aaff5db6..c15d33db 100644 --- a/executor/src/modules.rs +++ b/executor/src/modules.rs @@ -3,7 +3,7 @@ use genvm_common::*; use std::sync::Arc; use anyhow::Context as _; -use genvm_common::calldata; +use genlayer_sdk::calldata; use genvm_modules_interfaces::GenericValue; use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/executor/src/rt/fees.rs b/executor/src/rt/fees.rs index 93cb9f35..2052dcf2 100644 --- a/executor/src/rt/fees.rs +++ b/executor/src/rt/fees.rs @@ -39,7 +39,7 @@ fn attrs_object(vars: &[(&str, genvm_common::expr::Value)]) -> genvm_common::exp /// Builds the `feeParams` object consumed by the `messageFeeFloor` prelude fn. pub fn fee_params_value_internal( - p: &genvm_common::domain::fees::InternalMessageParams, + p: &genlayer_sdk::abi::fees::InternalMessageParams, ) -> genvm_common::expr::Value { use genvm_common::expr::Value; let mut m = std::collections::BTreeMap::new(); @@ -77,7 +77,7 @@ pub fn fee_params_value_internal( } pub fn fee_params_value_external( - p: &genvm_common::domain::fees::ExternalMessageParams, + p: &genlayer_sdk::abi::fees::ExternalMessageParams, ) -> genvm_common::expr::Value { use genvm_common::expr::Value; let mut m = std::collections::BTreeMap::new(); @@ -510,7 +510,7 @@ impl DataLimit { &self, on: abi::gl_call::On, balance_funded: bool, - matched_fee_params: &genvm_common::domain::fees::InternalMessageParams, + matched_fee_params: &genlayer_sdk::abi::fees::InternalMessageParams, ) -> rt::errors::Result { self.calculate_bucket( &self.message_fee, @@ -591,7 +591,7 @@ impl DataLimit { pub fn calculate_message_fee_external( &self, - matched_fee_params: &genvm_common::domain::fees::ExternalMessageParams, + matched_fee_params: &genlayer_sdk::abi::fees::ExternalMessageParams, ) -> rt::errors::Result { self.calculate_bucket( &self.message_fee, diff --git a/executor/src/rt/mod.rs b/executor/src/rt/mod.rs index 7e37e524..638d80e5 100644 --- a/executor/src/rt/mod.rs +++ b/executor/src/rt/mod.rs @@ -57,7 +57,7 @@ pub struct SharedData { } pub fn parse_host_data( - zelf: &genvm_common::domain::ExecutionData, + zelf: &genvm_modules_interfaces::ExecutionData, ) -> anyhow::Result { serde_json::from_str(&zelf.host_data) .with_context(|| "parsing host_data from execution context") diff --git a/executor/src/rt/vm/mod.rs b/executor/src/rt/vm/mod.rs index 552d04fe..8a658e2f 100644 --- a/executor/src/rt/vm/mod.rs +++ b/executor/src/rt/vm/mod.rs @@ -1,4 +1,4 @@ -use crate::{public_abi, rt, wasi}; +use crate::{domain, public_abi, rt, wasi}; use genlayer_calldata::codec::Encode; use genlayer_sdk::abi; diff --git a/executor/src/rt/vm/storage.rs b/executor/src/rt/vm/storage.rs index 61a8c357..de80e2af 100644 --- a/executor/src/rt/vm/storage.rs +++ b/executor/src/rt/vm/storage.rs @@ -36,6 +36,12 @@ pub struct Delta( #[serde(with = "serde_bytes")] Vec, ); +impl Delta { + pub(crate) fn cloned_parts(&self) -> ([u8; 36], Vec) { + (self.0, self.1.clone()) + } +} + impl calldata::codec::Encode for Delta { type Error = W::Error; diff --git a/executor/src/wasi/genlayer_sdk/message.rs b/executor/src/wasi/genlayer_sdk/message.rs index 3d4e52fe..54b5ba6a 100644 --- a/executor/src/wasi/genlayer_sdk/message.rs +++ b/executor/src/wasi/genlayer_sdk/message.rs @@ -14,7 +14,7 @@ struct ConsumeInternalArgs { enum FeeFunding<'a> { /// Sender-pool allocation: fee capped by `node.budget`; consumes the /// message-fee and receipt buckets. - Allocation(&'a mut domain::fees::MessageAllocationNode), + Allocation(&'a mut genvm_modules_interfaces::fees::MessageAllocationNode), /// Balance-funded (`useBalance`): the metered fee is the `declaredBudget`, /// reserved from the contract balance. On-chain such messages are excluded /// from the sender pool, so the message-fee bucket is skipped and only the @@ -27,10 +27,44 @@ enum FeeFunding<'a> { }, } +fn convert_on_to_modules(on: gl_call::On) -> genvm_modules_interfaces::On { + match on { + gl_call::On::Finalized => genvm_modules_interfaces::On::Finalized, + gl_call::On::Accepted => genvm_modules_interfaces::On::Accepted, + } +} + +fn convert_call_key_to_modules(call_key: abi::CallKey) -> genvm_modules_interfaces::CallKey { + genvm_modules_interfaces::CallKey(call_key.0) +} + +fn convert_internal_message_params_to_sdk( + params: &genvm_modules_interfaces::fees::InternalMessageParams, +) -> abi::fees::InternalMessageParams { + abi::fees::InternalMessageParams { + leader_timeunits_allocation: params.leader_timeunits_allocation, + validator_timeunits_allocation: params.validator_timeunits_allocation, + execution_budget_per_round: params.execution_budget_per_round, + rotations: params.rotations.clone(), + max_price_gen_per_time_unit: params.max_price_gen_per_time_unit, + storage_fee_max_gas_price: params.storage_fee_max_gas_price, + receipt_fee_max_gas_price: params.receipt_fee_max_gas_price, + } +} + +fn convert_external_message_params_to_sdk( + params: genvm_modules_interfaces::fees::ExternalMessageParams, +) -> abi::fees::ExternalMessageParams { + abi::fees::ExternalMessageParams { + gas_limit: params.gas_limit, + max_gas_price: params.max_gas_price, + } +} + async fn consume_message_fee_internal( shared_data: &rt::SharedData, funding: FeeFunding<'_>, - fee_params: Arc, + fee_params: Arc, on: gl_call::On, args: ConsumeInternalArgs, ) -> Result { @@ -138,8 +172,8 @@ struct ConsumeExternalArgs { async fn consume_message_fee_external( shared_data: &rt::SharedData, - node: &mut domain::fees::MessageAllocationNode, - params: domain::fees::ExternalMessageParams, + node: &mut genvm_modules_interfaces::fees::MessageAllocationNode, + params: abi::fees::ExternalMessageParams, // External messages are always emitted on finalization; carried for signature // symmetry with the internal path. _on: gl_call::On, @@ -222,8 +256,8 @@ pub(super) const FEE_PARAM_COUNT_BITS: usize = 32; pub(super) fn validate_balance_fee( can_use_balance: bool, use_balance: bool, - fee_params: Option, -) -> Result, generated::types::Error> { + fee_params: Option, +) -> Result, generated::types::Error> { match (use_balance, fee_params) { (true, _) if !can_use_balance => { log_debug!("balance-funded message rejected: permission absent (Forbidden)"); @@ -313,7 +347,7 @@ impl ContextVFS<'_> { .message_fee_allocation .iter_mut() .find_map(|node| { - node.matches_external(address, call_key) + node.matches_external(address, convert_call_key_to_modules(call_key)) .map(|params| (node, params)) }) else { @@ -330,6 +364,7 @@ impl ContextVFS<'_> { }; let calldata_length = calldata.len() as u64; + let matched_params = convert_external_message_params_to_sdk(matched_params); let fees = consume_message_fee_external( &self.context.data.supervisor.shared_data, @@ -450,7 +485,7 @@ impl ContextVFS<'_> { value: primitive_types::U256, on: gl_call::On, use_balance: bool, - fee_params: Option, + fee_params: Option, ) -> Result { log_debug!( recipient = address, @@ -569,8 +604,12 @@ impl ContextVFS<'_> { .message_fee_allocation .iter_mut() .find_map(|node| { - node.matches_internal(on, address, call_key) - .map(|params| (node, params)) + node.matches_internal( + convert_on_to_modules(on), + address, + convert_call_key_to_modules(call_key), + ) + .map(|params| (node, params)) }) else { log_warn!( @@ -597,15 +636,17 @@ impl ContextVFS<'_> { calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); let calldata_length = enc.into_inner().0; - let fee_params = (*matched_params).clone(); - let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - )); + let fee_params = convert_internal_message_params_to_sdk(matched_params.as_ref()); + let subtree = bytes::Bytes::from( + genvm_modules_interfaces::fees::MessageAllocationNode::abi_encode( + &matched_node.children, + ), + ); let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, FeeFunding::Allocation(matched_node), - matched_params, + Arc::new(fee_params.clone()), on, ConsumeInternalArgs { is_deploy: false, @@ -656,7 +697,7 @@ impl ContextVFS<'_> { on: gl_call::On, salt_nonce: primitive_types::U256, use_balance: bool, - fee_params: Option, + fee_params: Option, ) -> Result { if !self.context.data.conf.permissions.deterministic { return Err(generated::types::Errno::Forbidden.into()); @@ -752,8 +793,12 @@ impl ContextVFS<'_> { .message_fee_allocation .iter_mut() .find_map(|node| { - node.matches_internal(on, calldata::Address::zero(), abi::CallKey::DEPLOY) - .map(|params| (node, params)) + node.matches_internal( + convert_on_to_modules(on), + calldata::Address::zero(), + convert_call_key_to_modules(abi::CallKey::DEPLOY), + ) + .map(|params| (node, params)) }) else { log_warn!( @@ -774,15 +819,17 @@ impl ContextVFS<'_> { calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); let calldata_length = enc.into_inner().0; - let fee_params = (*matched_params).clone(); - let subtree = bytes::Bytes::from(domain::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - )); + let fee_params = convert_internal_message_params_to_sdk(matched_params.as_ref()); + let subtree = bytes::Bytes::from( + genvm_modules_interfaces::fees::MessageAllocationNode::abi_encode( + &matched_node.children, + ), + ); let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, FeeFunding::Allocation(matched_node), - matched_params, + Arc::new(fee_params.clone()), on, ConsumeInternalArgs { is_deploy: true, diff --git a/executor/src/wasi/genlayer_sdk/mod.rs b/executor/src/wasi/genlayer_sdk/mod.rs index 1e4617eb..384916a8 100644 --- a/executor/src/wasi/genlayer_sdk/mod.rs +++ b/executor/src/wasi/genlayer_sdk/mod.rs @@ -8,7 +8,7 @@ use genvm_modules_interfaces::GenericValue; use wiggle::GuestError; use crate::host::{self, SlotID}; -use crate::{anyhow_to_wasmtime, calldata, public_abi, rt, wasi}; +use crate::{anyhow_to_wasmtime, calldata, domain, public_abi, rt, wasi}; pub use genlayer_sdk::abi::entry::ExtendedMessage; use genlayer_sdk::abi::{self, gl_call}; @@ -113,7 +113,7 @@ pub struct VMDataAccumulator { pub data_fees_limit: DArc, pub messages_value_decremented: primitive_types::U256, pub emissions: Vec, - pub message_fee_allocation: Vec, + pub message_fee_allocation: Vec, } impl VMDataAccumulator { diff --git a/executor/src/wasi/genlayer_sdk/tests.rs b/executor/src/wasi/genlayer_sdk/tests.rs index b3f98873..9ab907c3 100644 --- a/executor/src/wasi/genlayer_sdk/tests.rs +++ b/executor/src/wasi/genlayer_sdk/tests.rs @@ -2,8 +2,8 @@ use super::message::{validate_balance_fee, FEE_PARAM_COUNT_BITS, FEE_PARAM_PRICE use super::*; use primitive_types::U256; -fn valid_params() -> domain::fees::InternalMessageParams { - domain::fees::InternalMessageParams { +fn valid_params() -> genvm_modules_interfaces::fees::InternalMessageParams { + genvm_modules_interfaces::fees::InternalMessageParams { leader_timeunits_allocation: U256::from(5), validator_timeunits_allocation: U256::from(5), execution_budget_per_round: U256::from(1024), @@ -47,8 +47,9 @@ fn empty_rotations_is_inval() { #[test] fn zero_price_caps_are_inval() { for mutate in [ - (|p: &mut domain::fees::InternalMessageParams| p.max_price_gen_per_time_unit = U256::zero()) - as fn(&mut domain::fees::InternalMessageParams), + (|p: &mut genvm_modules_interfaces::fees::InternalMessageParams| { + p.max_price_gen_per_time_unit = U256::zero() + }) as fn(&mut genvm_modules_interfaces::fees::InternalMessageParams), |p| p.storage_fee_max_gas_price = U256::zero(), |p| p.receipt_fee_max_gas_price = U256::zero(), ] { @@ -64,7 +65,7 @@ fn huge_magnitude_params_are_inval() { // Security-review N1 repro: passes the emptiness/zero checks, but the // 2^250 magnitudes would push messageFeeFloor past U256 and trip the // evaluator's internal `fee cost exceeds U256 range` abort. - let p = domain::fees::InternalMessageParams { + let p = genvm_modules_interfaces::fees::InternalMessageParams { leader_timeunits_allocation: U256::one() << 250, validator_timeunits_allocation: U256::zero(), execution_budget_per_round: U256::zero(), diff --git a/executor/src/wasi/preview1.rs b/executor/src/wasi/preview1.rs index 6f2e5d9a..1845be62 100644 --- a/executor/src/wasi/preview1.rs +++ b/executor/src/wasi/preview1.rs @@ -1187,7 +1187,7 @@ impl generated::wasi_snapshot_preview1::WasiSnapshotPreview1 for ContextVFS<'_> // exit-carrier error through `unwrap_vm_errors`: a zero exit is a normal // empty return, any other code is a VM error. let trap: anyhow::Error = if code == 0 { - crate::wasi::genlayer_sdk::ContractReturn(genvm_common::calldata::Value::Null.into()) + crate::wasi::genlayer_sdk::ContractReturn(genlayer_sdk::calldata::Value::Null.into()) .into() } else { rt::errors::Error::vm(abi::consts::VmError::exit_code().val_i32(code)).into() diff --git a/tests/integration/message/call_self_view_stack.0.stdout b/tests/integration/message/call_self_view_stack.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/message/call_self_view_stack.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/call_self_view_stack.0_0.stdout b/tests/integration/message/call_self_view_stack.0_0.stdout new file mode 100644 index 00000000..2efe87f1 --- /dev/null +++ b/tests/integration/message/call_self_view_stack.0_0.stdout @@ -0,0 +1,26 @@ +[] +[Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +[Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000"), Address("0x0100000000000000000000000000000000000000")] +executed with `Return(8)` diff --git a/tests/integration/message/call_self_view_stack.jsonnet b/tests/integration/message/call_self_view_stack.jsonnet new file mode 100644 index 00000000..02422a91 --- /dev/null +++ b/tests/integration/message/call_self_view_stack.jsonnet @@ -0,0 +1,12 @@ +local simple_deploy = import 'templates/simple_deploy.jsonnet'; +local msg = import 'templates/message.json'; +local util = import 'templates/util.jsonnet'; +{tags: util.features([['message', 'view'], ['deploy']], 'stable'), + entry: util.addPaths([simple_deploy.run('${jsonnetDir}/${fileBaseName}.py') { + next: [{ + vars: {}, + code: null, + message: msg, + calldata: std.manifestJsonEx({ '': 'fib', args: [6] }, ' '), + }], + }])} diff --git a/tests/integration/message/call_self_view_stack.py b/tests/integration/message/call_self_view_stack.py new file mode 100644 index 00000000..7f69db2c --- /dev/null +++ b/tests/integration/message/call_self_view_stack.py @@ -0,0 +1,16 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl + + +class Contract(gl.contract.Contract): + def __init__(self): + pass + + @gl.public.view + def fib(self, a): + print(gl.message.stack) + if a <= 1: + return a + else: + zelf = gl.contract.get_at(gl.Address(self.address)) + return zelf.view().fib(a - 1) + zelf.view().fib(a - 2) diff --git a/tests/integration/nasty-determinism/hash_random/hash_random.0.stdout b/tests/integration/nasty-determinism/hash_random/hash_random.0.stdout index 247582a6..cf0db931 100644 --- a/tests/integration/nasty-determinism/hash_random/hash_random.0.stdout +++ b/tests/integration/nasty-determinism/hash_random/hash_random.0.stdout @@ -1 +1 @@ -executed with `UserError(["PYTHONHASHSEED=NOT_SET","hash_hello=1674927396","hash_world=583264498","hash_test=205873894","hash_=0","hash_a=-2124203860","hash_determinism=194732860","hash_bytes_b'hello'=1674927396","hash_bytes_b'test'=205873894","hash_bytes_b''=0","hash_tuple=1291798628","hash_frozenset=-2021384008","hash_int_0=0","hash_int_1=1","hash_int_-1=-2","hash_int_42=42","hash_int_9223372036854775808=2","hash_algorithm=fnv","hash_bits=32","hash_seed_bits=64"])` +executed with `UserError(["PYTHONHASHSEED=NOT_SET","hash_hello=592056820","hash_world=1937845678","hash_test=-1912517414","hash_=0","hash_a=-883847300","hash_determinism=573997128","hash_bytes_b'hello'=592056820","hash_bytes_b'test'=-1912517414","hash_bytes_b''=0","hash_tuple=-1146266688","hash_frozenset=-2021384008","hash_int_0=0","hash_int_1=1","hash_int_-1=-2","hash_int_42=42","hash_int_9223372036854775808=2","hash_algorithm=fnv","hash_bits=32","hash_seed_bits=64"])` diff --git a/tests/integration/nasty-determinism/set_order/set_order.0.stdout b/tests/integration/nasty-determinism/set_order/set_order.0.stdout index 41381e66..fd5aba6a 100644 --- a/tests/integration/nasty-determinism/set_order/set_order.0.stdout +++ b/tests/integration/nasty-determinism/set_order/set_order.0.stdout @@ -1 +1 @@ -executed with `UserError(["set_order=['grape', 'fig', 'cherry', 'date', 'apple', 'elderberry', 'banana']","dict_from_set=['grape', 'fig', 'cherry', 'date', 'apple', 'elderberry', 'banana']","frozenset_repr=frozenset({'a', 'b', 'y', 'x', 'z', 'w'})","set_union_order=['e', 'd', 'f', 'a', 'c', 'b']","set_inter_order=['d', 'c']","set_diff_order=['a', 'b']","big_set_first_10=['item_38', 'item_39', 'item_18', 'item_19', 'item_16', 'item_17', 'item_14', 'item_15', 'item_12', 'item_13']","big_set_hash=1970560829","dict_keys_after_mutations=['key_1', 'key_3', 'key_5', 'key_7', 'key_9', 'key_11', 'key_13', 'key_15', 'key_17', 'key_19', 'key_20', 'key_21', 'key_22', 'key_23', 'key_24', 'key_25', 'key_26', 'key_27', 'key_28', 'key_29']"])` +executed with `UserError(["set_order=['grape', 'apple', 'elderberry', 'cherry', 'fig', 'date', 'banana']","dict_from_set=['grape', 'apple', 'elderberry', 'cherry', 'fig', 'date', 'banana']","frozenset_repr=frozenset({'y', 'x', 'z', 'w', 'a', 'b'})","set_union_order=['e', 'd', 'f', 'a', 'c', 'b']","set_inter_order=['d', 'c']","set_diff_order=['a', 'b']","big_set_first_10=['item_27', 'item_26', 'item_25', 'item_24', 'item_23', 'item_22', 'item_21', 'item_20', 'item_40', 'item_41']","big_set_hash=-1278125357","dict_keys_after_mutations=['key_1', 'key_3', 'key_5', 'key_7', 'key_9', 'key_11', 'key_13', 'key_15', 'key_17', 'key_19', 'key_20', 'key_21', 'key_22', 'key_23', 'key_24', 'key_25', 'key_26', 'key_27', 'key_28', 'key_29']"])` diff --git a/tests/integration/wasi/wasi_random/wasi_random.0.stdout b/tests/integration/wasi/wasi_random/wasi_random.0.stdout index 3dbbb717..5e97a472 100644 --- a/tests/integration/wasi/wasi_random/wasi_random.0.stdout +++ b/tests/integration/wasi/wasi_random/wasi_random.0.stdout @@ -1 +1 @@ -executed with `UserError(["urandom_hex=2c9273c5c0adcaaf84a62017e5fbef2e","random_int=13859541689743913502","random_float=0.5689966077088449","urandom_seq_0=6371184d6195dce8","urandom_seq_1=259de5b32901e606","urandom_seq_2=d8e5cb9f0558c28c","urandom_seq_3=e38799ba78f09295","urandom_seq_4=f06e5067a429f2e4"])` +executed with `UserError(["urandom_hex=61a0c525d6329b8cdb371e0601561a11","random_int=8322076690314587541","random_float=0.3512706561726313","urandom_seq_0=d638216c40f0f842","urandom_seq_1=bc9f79cdf637ab21","urandom_seq_2=493c3b910779b700","urandom_seq_3=084cf0c7a404cf65","urandom_seq_4=23e97f17a89c5bb8"])` diff --git a/tests/integration/wasi/wasi_random/wasi_random.jsonnet b/tests/integration/wasi/wasi_random/wasi_random.jsonnet index 7deae8d2..8c3e64b8 100644 --- a/tests/integration/wasi/wasi_random/wasi_random.jsonnet +++ b/tests/integration/wasi/wasi_random/wasi_random.jsonnet @@ -3,7 +3,7 @@ local util = import 'templates/util.jsonnet'; local simple_deploy = import 'templates/simple_deploy.jsonnet'; { - tags: util.features([['wasi', 'random']], 'stable') + ['fuzz'], + tags: util.features([['wasi', 'random'], ['nasty-determinism']], 'stable') + ['fuzz'], entry: util.addPaths([ simple_deploy.run('${jsonnetDir}/contract.py') { expected_semantics_components: ['return'], From 114118bbc81292e3e33402cfb10ed9e4a541ff1d Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 14 Jul 2026 19:56:08 +0900 Subject: [PATCH 22/25] =?UTF-8?q?chore:=20adopt=20git-hooks.nix=20for=20co?= =?UTF-8?q?mmit=20hooks=20=F0=9F=AA=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dev-only top-level flake providing the developer shell and the git-hooks.nix pre-commit/commit-msg hooks, pinned to this executor's own toolchain (clang-format v19). Replace the support/nix/precommit tool flake — its hooks now live in the flake — and carry self-contained copies of the check-commit-message and md-local-links hook scripts. Ignore the git-hooks generated .pre-commit-config.yaml. --- .genvm-tool.py | 148 +--------- .gitignore | 3 + executor/crates/common/src/host_fns.rs | 2 + .../crates/common/src/public_abi_pending.rs | 2 + executor/crates/sdk-rs/src/abi/consts.rs | 2 + .../nix/precommit/flake.lock => flake.lock | 44 ++- flake.nix | 160 +++++++++++ support/nix/precommit/flake.nix | 45 --- support/scripts/check-commit-message.py | 257 ++++++++++++++++++ support/scripts/md-local-links.py | 89 ++++++ 10 files changed, 560 insertions(+), 192 deletions(-) rename support/nix/precommit/flake.lock => flake.lock (52%) create mode 100644 flake.nix delete mode 100644 support/nix/precommit/flake.nix create mode 100755 support/scripts/check-commit-message.py create mode 100644 support/scripts/md-local-links.py diff --git a/.genvm-tool.py b/.genvm-tool.py index 92e2c0c1..f867371a 100644 --- a/.genvm-tool.py +++ b/.genvm-tool.py @@ -1,151 +1,11 @@ """Executor-root genvm-tool project config. -Loaded by genvm-tool (``common.load_project``) for this executor submodule; the -hook engine asks it for ``hooks(ctx)`` (was ``support/nix/precommit/hooks.toml``). -The umbrella test suite lives in the manager's ``.genvm-tool.py``. +Loaded by genvm-tool (``common.load_project``) for this executor submodule to +provide its build ``configure``/``integration`` config. The umbrella test suite +lives in the manager's ``.genvm-tool.py``; commit hooks live in this repo's +flake (git-hooks.nix). """ -import genvm_tool - - -def hooks(ctx: 'genvm_tool.common.Context'): - """Executor commit-hook definitions (was support/nix/precommit/hooks.toml). - - Tools resolve from the sibling flake's buildEnv (``nix = ""``); - ``builtin`` hooks run logic baked into genvm-tool. Formatters run in --check - mode so a hook's exit code alone decides pass/fail. ``files``/``exclude`` are - repo-relative (i.e. relative to executors/v0.3.x). - """ - return [ - # --- generic checks (mirror the manager's) ------------------------- - { - 'id': 'trailing-whitespace', - 'nix': 'pre-commit-hooks', - 'entry': 'trailing-whitespace-fixer', - 'types_or': ['text'], - 'exclude': r'^\.git-third-party|/fuzz/', - }, - { - 'id': 'end-of-file-fixer', - 'nix': 'pre-commit-hooks', - 'entry': 'end-of-file-fixer', - 'types_or': ['text'], - 'exclude': r'^\.git-third-party|/fuzz/', - }, - { - 'id': 'check-added-large-files', - 'nix': 'pre-commit-hooks', - 'entry': 'check-added-large-files', - }, - { - 'id': 'check-json', - 'nix': 'pre-commit-hooks', - 'entry': 'check-json', - 'types_or': ['json'], - 'exclude': r'^\.git-third-party', - }, - { - 'id': 'check-yaml', - 'nix': 'pre-commit-hooks', - 'entry': 'check-yaml', - 'types_or': ['yaml'], - }, - { - 'id': 'check-toml', - 'nix': 'pre-commit-hooks', - 'entry': 'check-toml', - 'types_or': ['toml'], - }, - { - 'id': 'toml-format', - 'nix': 'taplo', - 'entry': 'taplo', - 'args': ['format', '--check'], - 'fix_args': ['format'], - 'types_or': ['toml'], - 'pass_filenames': True, - }, - { - 'id': 'check-merge-conflict', - 'nix': 'pre-commit-hooks', - 'entry': 'check-merge-conflict', - 'types_or': ['text'], - }, - # --- executor-owned languages: python, c/c++, rust ----------------- - { - 'id': 'ruff-format', - 'nix': 'ruff', - 'entry': 'ruff', - 'args': ['format', '--check'], - 'fix_args': ['format'], - 'types_or': ['python'], - }, - { - 'id': 'clang-format', - 'nix': 'clang-tools', - 'entry': 'clang-format', - 'args': ['--dry-run', '--Werror'], - 'fix_args': ['-i'], - 'types_or': ['c', 'c++'], - 'exclude': r'runners/softfloat/berkeley-softfloat-3|runners/py-libs', - }, - { - 'id': 'editorconfig-checker', - 'nix': 'editorconfig-checker', - 'entry': 'editorconfig-checker', - # text-only: keep binaries (e.g. runners/models/*.onnx) out of it. - 'types_or': ['text'], - 'exclude': r'\.git-third-party|runners/py-libs|runners/genlayer-py-std/src-emb/onnx|/fuzz/', - }, - { - 'id': 'cargo-fmt', - 'nix': 'cargo', - 'builtin': 'cargo-fmt', - 'files': r'\.rs$', - 'pass_filenames': False, - }, - { - 'id': 'nixfmt', - 'nix': 'nixfmt', - 'entry': 'nixfmt', - 'args': ['--check'], - 'fix_args': [], - 'files': r'\.nix$', - }, - { - 'id': 'markdown-local-links', - 'builtin': 'md-local-links', - 'types_or': ['markdown'], - }, - # --- local guard --------------------------------------------------- - { - # Refuse commits that leave dev-mode on or any hash as "test". - # On failure prints docs/contributing/howto/committing/runners.md. - 'id': 'no-commit-dev-mode', - 'local': True, - 'entry': ctx.python_command[0], - 'args': [ - *ctx.python_command[1:], - 'runners/support/versions/check-dev-mode.py', - ], - 'files': r'^runners/support/versions/(dev-mode|current)\.nix$', - 'pass_filenames': False, - }, - # --- commit-msg ---------------------------------------------------- - { - 'id': 'check-commit-message', - 'local': True, - 'entry': ctx.python_command[0], - 'args': [ - *ctx.python_command[1:], - str(ctx.root / 'support/scripts/check-commit-message.py'), - '--message-file', - ], - 'stages': ['commit-msg'], - 'pass_filenames': False, - }, - ] - def integration(): """Per-executor integration test configuration. diff --git a/.gitignore b/.gitignore index fce89a07..e43bb4d0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ __pycache__ perf.data* core.[0-9]* node_modules + +# git-hooks.nix dev-shell artifact +/.pre-commit-config.yaml diff --git a/executor/crates/common/src/host_fns.rs b/executor/crates/common/src/host_fns.rs index d52f4804..6a525c8f 100644 --- a/executor/crates/common/src/host_fns.rs +++ b/executor/crates/common/src/host_fns.rs @@ -136,3 +136,5 @@ impl VmErrorDetail { pub const CURRENT_MAJOR: u8 = 0; pub const CURRENT_MAJOR_STR: &'static str = "v0.0.0"; + +// EOF diff --git a/executor/crates/common/src/public_abi_pending.rs b/executor/crates/common/src/public_abi_pending.rs index 5bc570a2..26ef8423 100644 --- a/executor/crates/common/src/public_abi_pending.rs +++ b/executor/crates/common/src/public_abi_pending.rs @@ -1,3 +1,5 @@ // This file is auto-generated. Do not edit! #![allow(dead_code, clippy::redundant_static_lifetimes)] + +// EOF diff --git a/executor/crates/sdk-rs/src/abi/consts.rs b/executor/crates/sdk-rs/src/abi/consts.rs index d14dd5c8..95e6a72a 100644 --- a/executor/crates/sdk-rs/src/abi/consts.rs +++ b/executor/crates/sdk-rs/src/abi/consts.rs @@ -389,3 +389,5 @@ impl VmError { } pub const EVENT_MAX_TOPICS: u32 = 4; + +// EOF diff --git a/support/nix/precommit/flake.lock b/flake.lock similarity index 52% rename from support/nix/precommit/flake.lock rename to flake.lock index 2d3322f9..1addf387 100644 --- a/support/nix/precommit/flake.lock +++ b/flake.lock @@ -1,5 +1,21 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,13 +34,34 @@ "type": "github" } }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1783008725, + "narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1782467914, - "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=", + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", "type": "github" }, "original": { @@ -37,6 +74,7 @@ "root": { "inputs": { "flake-utils": "flake-utils", + "git-hooks": "git-hooks", "nixpkgs": "nixpkgs" } }, diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..fc04e913 --- /dev/null +++ b/flake.nix @@ -0,0 +1,160 @@ +{ + ############################################################################## + # # + # ⚠ DEV SHELL ONLY — NO PACKAGING HERE. ⚠ # + # # + # This flake exists ONLY to provide the developer shell and the # + # git-hooks (pre-commit / commit-msg) when this executor submodule is # + # checked out and worked on standalone. # + # # + # The executor is BUILT by the umbrella manager flake, which imports this # + # directory through `default.nix`. DO NOT add packages / build outputs to # + # this flake — put nothing here that the release build depends on. # + # # + ############################################################################## + description = "genvm-executor dev shell + commit hooks (DEV ONLY — not a build entry point)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + git-hooks = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + git-hooks, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + hooks-python = pkgs.python312; + # cargo fmt per crate: each Cargo.toml picks up its own edition. + cargo-fmt-all = pkgs.writeShellScript "cargo-fmt-all" '' + set -euo pipefail + export PATH="${pkgs.cargo}/bin:${pkgs.rustfmt}/bin:$PATH" + rc=0 + while IFS= read -r toml; do + ( cd "$(dirname -- "$toml")" && cargo fmt ) || rc=1 + done < <(git ls-files '*Cargo.toml') + exit $rc + ''; + pre-commit-check = git-hooks.lib.${system}.run { + src = ./.; + excludes = [ + "^build/" + "^\\.direnv/" + ]; + hooks = { + # --- generic checks (mirror the manager's) ---------------- + trim-trailing-whitespace = { + enable = true; + excludes = [ + "^\\.git-third-party" + "/fuzz/" + ]; + }; + end-of-file-fixer = { + enable = true; + excludes = [ + "^\\.git-third-party" + "/fuzz/" + ]; + }; + check-added-large-files.enable = true; + check-json = { + enable = true; + excludes = [ "^\\.git-third-party" ]; + }; + check-yaml.enable = true; + check-toml.enable = true; + check-merge-conflicts.enable = true; + taplo.enable = true; + editorconfig-checker = { + enable = true; + # text-only (the built-in defaults to every file): keep binary + # blobs (models/*.onnx, test zips, ...) out of it, mirroring the + # old engine's `text` pseudo-type. + types = [ "text" ]; + excludes = [ + "\\.git-third-party" + "runners/py-libs" + "runners/genlayer-py-std/src-emb/onnx" + "/fuzz/" + ]; + }; + # --- executor-owned languages: python, c/c++, rust, nix --- + ruff-format.enable = true; + clang-format = { + enable = true; + # v19 to match the executor's historical clang-format pin. + package = pkgs.llvmPackages_19.clang-tools; + # Restrict to C/C++ only (the built-in also covers json/js/...). + types_or = [ + "c" + "c++" + ]; + excludes = [ + "runners/softfloat/berkeley-softfloat-3" + "runners/py-libs" + ]; + }; + cargo-fmt = { + enable = true; + name = "cargo-fmt"; + entry = toString cargo-fmt-all; + files = "\\.rs$"; + pass_filenames = false; + }; + nixfmt = { + enable = true; + files = "\\.nix$"; + }; + markdown-local-links = { + enable = true; + name = "markdown-local-links"; + entry = "${hooks-python}/bin/python3 support/scripts/md-local-links.py"; + types = [ "markdown" ]; + }; + # --- local guards ----------------------------------------- + # Refuse commits that leave dev-mode on or any hash as "test". + no-commit-dev-mode = { + enable = true; + name = "no-commit-dev-mode"; + entry = "${hooks-python}/bin/python3 runners/support/versions/check-dev-mode.py"; + files = "^runners/support/versions/(dev-mode|current)\\.nix$"; + pass_filenames = false; + }; + # --- commit-msg ------------------------------------------- + check-commit-message = { + enable = true; + name = "check-commit-message"; + entry = "${hooks-python}/bin/python3 support/scripts/check-commit-message.py --message-file"; + stages = [ "commit-msg" ]; + }; + }; + }; + in + { + checks.pre-commit-check = pre-commit-check; + # `nix fmt` runs the same generated hook config as `nix flake check`, + # in the working tree (so the executor's cross-repo cargo-fmt paths + # resolve — the executor must be checked out under the manager). + formatter = pkgs.writeShellScriptBin "pre-commit-run" '' + exec ${pre-commit-check.config.package}/bin/pre-commit run --all-files --config ${pre-commit-check.config.configFile} + ''; + devShells.default = pkgs.mkShell { + # pre-commit + the hook tools, so `pre-commit run` works in-shell. + packages = pre-commit-check.enabledPackages; + # Standalone dev shell: installs the git-hooks stubs into .git/hooks. + shellHook = pre-commit-check.shellHook; + }; + } + ); +} diff --git a/support/nix/precommit/flake.nix b/support/nix/precommit/flake.nix deleted file mode 100644 index 9c930b16..00000000 --- a/support/nix/precommit/flake.nix +++ /dev/null @@ -1,45 +0,0 @@ -{ - description = "genvm-executor pre-commit lint tools (pinned, independent flake)"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - }; - - outputs = - { - self, - nixpkgs, - flake-utils, - }: - flake-utils.lib.eachDefaultSystem ( - system: - let - pkgs = import nixpkgs { inherit system; }; - # One named output per tool: hooks.toml `nix = ""` references - # these, and they are what `default` aggregates. - tools = { - inherit (pkgs) - ruff - editorconfig-checker - rustfmt - nixfmt - cargo - taplo - ; - pre-commit-hooks = pkgs.python3Packages.pre-commit-hooks; - # v19 to match the executor's historical clang-format pin. - clang-tools = pkgs.llvmPackages_19.clang-tools; - }; - in - { - packages = tools // { - default = pkgs.buildEnv { - name = "genvm-precommit-tools-executor"; - ignoreCollisions = true; - paths = builtins.attrValues tools; - }; - }; - } - ); -} diff --git a/support/scripts/check-commit-message.py b/support/scripts/check-commit-message.py new file mode 100755 index 00000000..01f1bce4 --- /dev/null +++ b/support/scripts/check-commit-message.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 + +import argparse +import re +import sys +import unicodedata +from dataclasses import dataclass + +VALID_TYPES = r'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert' +COMMIT_FORMAT_RE = re.compile(rf'^({VALID_TYPES})(\([a-zA-Z0-9_-]+\))?!?: .+$') + +AI_PATTERNS = [ + re.compile(p, re.IGNORECASE) + for p in [ + r'Co-Authored-By:.*claude', + r'Co-Authored-By:.*gpt', + r'Co-Authored-By:.*copilot', + r'Co-Authored-By:.*anthropic', + r'Co-Authored-By:.*openai', + r'Co-Authored-By:.*\bai\b', + r'Co-Authored-By:.*noreply@anthropic', + r'generated by', + r'generated with', + r'created by claude', + r'created with claude', + r'created by gpt', + r'created with gpt', + r'claude[\s-]code', + r'Claude-Session:\s*https?://', + ] +] + +TICKET_PATTERNS = [ + re.compile(p, re.IGNORECASE) + for p in [ + r'fixes #\d+', + r'closes #\d+', + r'resolves #\d+', + r'part of [A-Z]+-\d+', + r'related to [A-Z]+-\d+', + r'fixes [A-Z]+-\d+', + ] +] + +SUBJECT_LEN_SUGGESTED = 72 +SUBJECT_LEN_MAX = 120 +TRAILING_EMOJI_MAX = 5 + +BODY_MIN_WORDS = 3 +BODY_MIN_CHARS = 20 + +# Matches git trailer lines: "Token: value" (Co-authored-by:, Signed-off-by:, etc.) +GIT_TRAILER_RE = re.compile(r'^[A-Za-z][A-Za-z0-9-]*:\s*.+$') + +PR_REF_RE = re.compile(r'\s*\(#\d+\)\s*$') +SQUASH_BULLET_RE = re.compile(r'^\*\s*') + + +_EMOJI_MODIFIERS = frozenset([0xFE0F, 0xFE0E, 0x200D, 0x20E3]) + + +def _is_emoji_char(ch: str) -> bool: + cp = ord(ch) + if cp in _EMOJI_MODIFIERS: + return True + cat = unicodedata.category(ch) + if cat in ('So', 'Sm') and cp > 0x2000: + return True + if 0x1F300 <= cp <= 0x1FAFF: + return True + if 0x2600 <= cp <= 0x27BF: + return True + return False + + +def _split_trailing_emojis(text: str) -> tuple[str, str]: + """Return (prefix, suffix) where suffix is trailing spaces+emojis.""" + i = len(text) + while i > 0 and (text[i - 1] == ' ' or _is_emoji_char(text[i - 1])): + i -= 1 + return text[:i], text[i:] + + +def _count_emojis(text: str) -> int: + return sum(1 for ch in text if _is_emoji_char(ch) and ord(ch) not in _EMOJI_MODIFIERS) + + +def _body_without_trailers(body: str) -> str: + """Strip git trailer lines from body text, return the remaining content.""" + lines = [line for line in body.splitlines() if not GIT_TRAILER_RE.match(line.strip())] + return '\n'.join(lines).strip() + + +def _first_match(patterns: list[re.Pattern], text: str) -> str | None: + for pat in patterns: + m = pat.search(text) + if m: + return m.group() + return None + + +@dataclass +class CommitPart: + title: str + body: str = '' + + +def parse_commit(message: str) -> list[CommitPart]: + """Split a commit message into parts to validate independently. + + A squash/PR-merge commit (GitHub style) has bullet lines in the body that + are themselves conventional commit subjects. Each becomes its own CommitPart; + the umbrella title becomes the first part with any non-bullet body text. + A regular commit returns a single CommitPart. + """ + lines = message.splitlines() + title = lines[0] if lines else '' + body_lines = lines[1:] if len(lines) > 1 else [] + + squash_parts: list[CommitPart] = [] + non_bullet: list[str] = [] + + for line in body_lines: + stripped = line.strip() + if not stripped: + continue + m = SQUASH_BULLET_RE.match(stripped) + if m: + bullet = stripped[m.end() :] + clean, _ = _split_trailing_emojis(PR_REF_RE.sub('', bullet)) + if COMMIT_FORMAT_RE.match(clean): + squash_parts.append(CommitPart(title=bullet)) + continue + non_bullet.append(line) + + umbrella = CommitPart(title=title, body='\n'.join(non_bullet).strip()) + return [umbrella, *squash_parts] if squash_parts else [umbrella] + + +def _check_subject(subject: str, errors: list[str], warnings: list[str]) -> None: + clean, suffix = _split_trailing_emojis(PR_REF_RE.sub('', subject)) + + if not COMMIT_FORMAT_RE.match(clean): + errors.append('Invalid format. Expected: (): [emoji…]') + errors.append(f" Got: '{subject}'") + errors.append(f' Valid types: {VALID_TYPES.replace("|", ", ")}') + + if _count_emojis(clean) > 0: + errors.append( + f"Emojis must only appear at the end of the subject line: '{subject}'" + ) + + n = _count_emojis(suffix) + if n > TRAILING_EMOJI_MAX: + warnings.append(f'Too many trailing emojis ({n}). Max {TRAILING_EMOJI_MAX}.') + + length = len(subject) + if length > SUBJECT_LEN_MAX: + errors.append( + f'Subject too long ({length} chars). Maximum: {SUBJECT_LEN_MAX} chars' + ) + elif length > SUBJECT_LEN_SUGGESTED: + warnings.append( + f'Subject exceeds {SUBJECT_LEN_SUGGESTED} chars ({length} chars). Consider shortening.' + ) + + +def validate_part(part: CommitPart) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + + _check_subject(part.title, errors, warnings) + + if hit := _first_match(AI_PATTERNS, part.title + '\n' + part.body): + errors.append(f"AI attribution detected: '{hit}'.") + errors.append( + ' Commits must not contain AI tool references or Co-Authored-By with AI names.' + ) + + real_body = _body_without_trailers(part.body) + if real_body: + words = len(real_body.split()) + chars = len(real_body) + if words < BODY_MIN_WORDS or chars < BODY_MIN_CHARS: + warnings.append( + f'Commit body is very short ({words} word(s), {chars} char(s)). Are you sure you need a body?' + ) + + if _count_emojis(part.body) > 0: + errors.append('Emojis in the commit body are not allowed.') + + if hit := _first_match(TICKET_PATTERNS, part.body): + warnings.append( + f"Ticket reference in commit body ('{hit}'). Move to PR description instead." + ) + + return errors, warnings + + +def validate(message: str) -> tuple[list[str], list[str]]: + all_errors: list[str] = [] + all_warnings: list[str] = [] + for part in parse_commit(message): + errors, warnings = validate_part(part) + all_errors.extend(errors) + all_warnings.extend(warnings) + return all_errors, all_warnings + + +def main() -> int: + parser = argparse.ArgumentParser( + description='Validate a commit message against conventional commit rules.' + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + '--message-file', metavar='FILE', help='Path to file containing the commit message' + ) + group.add_argument('--message-text', metavar='TEXT', help='Commit message text') + args = parser.parse_args() + + if args.message_file: + if args.message_file == '-': + message = sys.stdin.read() + else: + with open(args.message_file, encoding='utf-8') as f: + message = f.read() + else: + message = args.message_text + + errors, warnings = validate(message) + + if warnings: + print(f'Warnings ({len(warnings)}):') + for w in warnings: + print(f' {w}') + + if errors: + print(f'\nErrors ({len(errors)}):') + for e in errors: + print(f' {e}') + print() + print('Commit message rules:') + print(' - Format: (): ') + print(f' - Types: {VALID_TYPES.replace("|", ", ")}') + print( + f' - Title: <={SUBJECT_LEN_SUGGESTED} chars recommended, <={SUBJECT_LEN_MAX} max' + ) + print(" - NO AI attribution (Co-Authored-By with AI, 'Generated by', etc.)") + print(' - NO ticket references in body (use PR description)') + return 1 + + print('Commit message is valid.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/support/scripts/md-local-links.py b/support/scripts/md-local-links.py new file mode 100644 index 00000000..f1c75760 --- /dev/null +++ b/support/scripts/md-local-links.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Pre-commit guard: verify local file-path links in Markdown resolve. + +Validates that **local** link targets (and reference definitions) point at +existing files; ignores external links (http(s)/mailto/other schemes), +protocol-relative `//host` links, and in-page `#anchor` links. Replaces the +network `markdown-link-check`. + +Invoked by the git-hooks `markdown-local-links` hook with the changed markdown +files as arguments; the repo root is the current working directory (pre-commit +runs hooks from the repo top level). +""" + +import os +import re +import sys +import urllib.parse +from pathlib import Path + +# Drop fenced code blocks so ``` ...[x](y)... ``` samples are not scanned. +_FENCE = re.compile(r'```.*?```', re.DOTALL) +_INLINE = re.compile(r'!?\[[^\]]*\]\(([^)]+)\)') +_REFDEF = re.compile(r'(?m)^[ \t]*\[[^\]]+\]:[ \t]*(\S+)') +_SCHEME = re.compile(r'^[a-zA-Z][a-zA-Z0-9+.\-]*:') # http:, mailto:, ... + + +def _targets(text: str): + text = _FENCE.sub('', text) + for m in _INLINE.finditer(text): + yield m.group(1) + for m in _REFDEF.finditer(text): + yield m.group(1) + + +def _clean(target: str) -> str: + t = target.strip() + if t.startswith('<') and t.endswith('>'): + # Angle-bracketed targets may contain spaces; take them verbatim. + t = t[1:-1] + else: + # Drop an optional "title" suffix: [x](path "title") / (path 'title'). + t = re.sub(r"""\s+["'].*$""", '', t) + t = t.split('#', 1)[0] # drop an in-page anchor: path#section -> path + return urllib.parse.unquote(t) # %20 -> space, etc. + + +def _is_local(t: str) -> bool: + if not t or t.startswith('#') or t.startswith('//'): + return False + return not _SCHEME.match(t) + + +def _rel(p: Path, root: Path) -> str: + return os.path.relpath(os.path.normpath(p), root) + + +def broken_links(md: Path, root: Path) -> list[str]: + """Return human-readable messages for each broken local link in `md`. + + Targets resolve relative to the markdown file's directory; a leading `/` is + repo-root-relative (`root`). Paths in messages are shown relative to `root`. + """ + text = md.read_text(encoding='utf-8', errors='replace') + base = md.parent + out = [] + for raw in _targets(text): + t = _clean(raw) + if not _is_local(t): + continue + dest = (root / t.lstrip('/')) if t.startswith('/') else (base / t) + if not dest.exists(): + out.append( + f'{_rel(md, root)}: broken local link {raw!r} -> {_rel(dest, root)} (not found)' + ) + return out + + +def main(argv: list[str]) -> int: + root = Path.cwd() + problems = [] + for arg in argv: + problems += broken_links(Path(arg), root) + for p in problems: + print(p, file=sys.stderr) + return 1 if problems else 0 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:])) From 2eff323aab6a2a0bae7781dc357868a9425e3817 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 15 Jul 2026 01:00:43 +0900 Subject: [PATCH 23/25] =?UTF-8?q?chore(ci):=20match=20all=20dev=20base=20b?= =?UTF-8?q?ranches=20in=20coderabbit=20=F0=9F=90=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit reads .coderabbit.yaml from the PR head branch, and declaring base_branches overrides the org default. The `v\d+\.\d+(\.x|-dev)?` pattern did not match `v0.3-dev`, so PRs to the dev branches were auto-skipped. Spell the suffixes out as separate alternatives instead of an optional group. --- .coderabbit.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c901430f..e2051383 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,9 +1,13 @@ reviews: - # Review PRs targeting the long-lived version branches (e.g. v0.3.x, v0.3-dev) - # and main; feature/topic branches are reviewed via their target base. + # Auto-review PRs targeting any long-lived branch: main, a release branch + # (v0.3.x) or its dev branch (v0.3-dev). Feature/topic branches are reviewed + # through the base they target. Patterns are regexes; keep them free of + # optional groups, an `(\.x|-dev)?` suffix here did not match `v0.3-dev`. base_branches: - "main" - - "v\\d+\\.\\d+(\\.x|-dev)?" + - "v\\d+\\.\\d+" + - "v\\d+\\.\\d+\\.x" + - "v\\d+\\.\\d+-dev" path_filters: - "!**/*.onnx" - "!**/*.txt" From 9f504b2f1ba62fb8c4a1f6dfd34eb183c14aaff8 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Wed, 15 Jul 2026 22:01:26 +0900 Subject: [PATCH 24/25] chore: split docs --- docs/website/.gitignore | 5 + docs/website/src/conf.py | 188 ++++++++++ docs/website/src/index.rst | 17 + .../src/python-sdk/guides/boot_process.rst | 73 ++++ .../src/python-sdk/guides/floating_point.rst | 14 + docs/website/src/python-sdk/guides/index.rst | 7 + docs/website/src/python-sdk/index.rst | 16 + docs/website/src/python-sdk/introduction.rst | 15 + .../src/python-sdk/migration-guide.rst | 345 ++++++++++++++++++ .../src/python-sdk/reference/genlayer.rst | 127 +++++++ .../reference/genlayer_embeddings.rst | 8 + .../src/python-sdk/reference/index.rst | 7 + docs/website/src/python-sdk/runners/index.rst | 38 ++ flake.nix | 24 ++ 14 files changed, 884 insertions(+) create mode 100644 docs/website/.gitignore create mode 100644 docs/website/src/conf.py create mode 100644 docs/website/src/index.rst create mode 100644 docs/website/src/python-sdk/guides/boot_process.rst create mode 100644 docs/website/src/python-sdk/guides/floating_point.rst create mode 100644 docs/website/src/python-sdk/guides/index.rst create mode 100644 docs/website/src/python-sdk/index.rst create mode 100644 docs/website/src/python-sdk/introduction.rst create mode 100644 docs/website/src/python-sdk/migration-guide.rst create mode 100644 docs/website/src/python-sdk/reference/genlayer.rst create mode 100644 docs/website/src/python-sdk/reference/genlayer_embeddings.rst create mode 100644 docs/website/src/python-sdk/reference/index.rst create mode 100644 docs/website/src/python-sdk/runners/index.rst diff --git a/docs/website/.gitignore b/docs/website/.gitignore new file mode 100644 index 00000000..77c9214c --- /dev/null +++ b/docs/website/.gitignore @@ -0,0 +1,5 @@ +# Generated by the manager's docs pipeline (generate.py) from runner-version +# data; see support/ci/pipelines/docs.py. +/src/python-sdk/changelog.rst +/src/python-sdk/available-runners.rst +/src/python-sdk/runners-versions.json diff --git a/docs/website/src/conf.py b/docs/website/src/conf.py new file mode 100644 index 00000000..577b3c7a --- /dev/null +++ b/docs/website/src/conf.py @@ -0,0 +1,188 @@ +import datetime +import enum +import json +import os +import sys +import typing +from pathlib import Path +from types import ModuleType + +# executors/.x/ — three parents up from docs/website/src/conf.py +_root = Path(__file__).resolve().parents[3] +_manifest = json.loads((_root / 'manifest.json').read_text()) + +project = 'GenVM Executor' +author = 'GenLayer Labs' + +copyright_year = os.environ.get('COPYRIGHT_YEAR', str(datetime.date.today().year)) +copyright = f'{copyright_year}, GenLayer Labs' + +# This sub-site's version is always the line's own executor-version. Do NOT +# read DOCS_VERSION here: the deploy sets it to the *manager* version, which +# would otherwise leak in and make every line's sub-site claim the same version. +release = _manifest['executor-version'] +version = release + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.todo', + 'sphinx.ext.intersphinx', + 'sphinxcontrib.mermaid', + 'myst_parser', +] + +exclude_patterns = [ + '_build', + 'Thumbs.db', + '.DS_Store', + # `.. include::`d into changelog.rst; excluding them from the doc set only + # suppresses the "not in any toctree" orphan warnings (include still works). + 'python-sdk/changelog-notes/*', +] + +language = 'en' + +mermaid_version = '11.6.0' +mermaid_output_format = 'svg' +mermaid_params = ['--theme', 'dark', '--backgroundColor', 'transparent'] + +html_theme = 'pydata_sphinx_theme' + +master_doc = 'index' + +# --- Python SDK autodoc ----------------------------------------------------- +# The SDK lives in THIS line's runner tree; autodoc imports it from there. +sys.path.insert(0, str(_root / 'runners' / 'genlayer-py-std' / 'src')) +sys.path.insert(0, str(_root / 'runners' / 'genlayer-py-std' / 'src-emb')) + +os.environ['GENERATING_DOCS'] = 'true' + +todo_include_todos = True + +autodoc_mock_imports = ['_genlayer_wasi', 'google', 'onnx', 'word_piece_tokenizer'] + +fake_genlayer_wasi = ModuleType('_genlayer_wasi') +fake_genlayer_wasi.__dict__['FAKE_VM'] = True +sys.modules['_genlayer_wasi'] = fake_genlayer_wasi + +# --- intersphinx ------------------------------------------------------------ +# Cross-links back to the manager site (spec / impl-spec pages the SDK runners +# page points at). The manager builds first (see the manager's docs.py), so its +# objects.inv is on disk by the time this sub-site builds. The base URL is where +# the manager site is served: https://// . +_docs_domain = os.environ.get('DOCS_DOMAIN', 'sdk.genlayer.com') +_manager_version = os.environ.get('DOCS_VERSION', 'main') + +_manager_root = _root +while not (_manager_root / '.genvm-monorepo-root').exists(): + _manager_root = _manager_root.parent +_manager_inv = _manager_root / 'build' / 'doc' / 'html' / 'objects.inv' + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3.12', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'genvm': ( + f'https://{_docs_domain}/{_manager_version}/', + str(_manager_inv) if _manager_inv.exists() else None, + ), +} + +# --- autodoc rendering ------------------------------------------------------ +ignored_special = [ + '__dict__', + '__abstractmethods__', + '__annotations__', + '__class_getitem__', + '__init_subclass__', + '__module__', + '__orig_bases__', + '__parameters__', + '__slots__', + '__subclasshook__', + '__type_params__', + '__weakref__', + '__reversed__', + '__protocol_attrs__', + '__dataclass_fields__', + '__match_args__', + '__dataclass_params__', +] + +autodoc_default_options: dict[str, str | bool] = { + 'inherited-members': True, + 'private-members': False, + 'special-members': True, + 'exclude-members': ','.join(ignored_special + ['gl']), +} + +autoapi_python_class_content = 'class' +autodoc_class_signature = 'separated' +autodoc_typehints = 'both' +autodoc_typehints_description_target = 'documented_params' +autodoc_inherit_docstrings = True +autodoc_typehints_format = 'short' +autodoc_preserve_defaults = True +autodoc_signature_line_length = 1 + + +def setup(app): + def handle_bases(app, name, obj, options, bases: list): + idx = 0 + for i in range(len(bases)): + cur = bases[i] + cur_name = cur if isinstance(cur, str) else cur.__qualname__ + if cur_name.startswith('_'): + pass + else: + bases[idx] = cur + idx += 1 + bases[idx:] = [] + if len(bases) == 0: + bases.append(object) + + def handle_skip_member(app, what, name, obj, skip, options): + import types + + if 'what' == 'class' and isinstance(obj, types.MethodType): + if obj.__self__.__class__ is typing.NewType: + return True + # Sphinx 9's autodoc options object is attribute-based (underscored) and + # forbids the old item assignment; None is the "off" value for + # inherited_members. + if what == 'module' and isinstance(obj, type): + if any(base in obj.mro() for base in [dict, tuple, bytes, enum.Enum]): + options.special_members = [] + options.inherited_members = None + return + if what == 'module': + if isinstance(obj, typing.NewType): + options.special_members = [] + options.inherited_members = None + return + + # Build u8..u256, i8..i256 display aliases for Annotated[int, StaticIntMeta(...)] + _type_display_aliases = {} + for _prefix, _signed in [('u', False), ('i', True)]: + for _sz in range(1, 33): + _bits = _sz * 8 + _type_display_aliases[ + f'typing.Annotated[int, StaticIntMeta(size={_sz}, signed={_signed})]' + ] = f'genlayer.types.{_prefix}{_bits}' + _type_display_aliases[ + f'Annotated[int, StaticIntMeta(size={_sz}, signed={_signed})]' + ] = f'genlayer.types.{_prefix}{_bits}' + + def autodoc_process_signature( + app, what, name, obj, options, signature, return_annotation + ): + for old, new in _type_display_aliases.items(): + if signature: + signature = signature.replace(old, new) + if return_annotation: + return_annotation = return_annotation.replace(old, new) + return (signature, return_annotation) + + app.connect('autodoc-process-bases', handle_bases) + app.connect('autodoc-skip-member', handle_skip_member) + app.connect('autodoc-process-signature', autodoc_process_signature) diff --git a/docs/website/src/index.rst b/docs/website/src/index.rst new file mode 100644 index 00000000..ecf4e513 --- /dev/null +++ b/docs/website/src/index.rst @@ -0,0 +1,17 @@ +GenVM Executor Documentation +============================ + +Documentation for the |release| executor line, including the ``genlayer`` +Python SDK shipped by this line's runners. + +See the `main GenVM documentation <../../index.html>`_ for the protocol +specification and implementation internals. + +.. toctree:: + :maxdepth: 2 + + python-sdk/index + +.. note:: + More executor-line docs (Lua API, greyboxing, ...) will be published on this + sub-site alongside the SDK reference. diff --git a/docs/website/src/python-sdk/guides/boot_process.rst b/docs/website/src/python-sdk/guides/boot_process.rst new file mode 100644 index 00000000..7fe5ef90 --- /dev/null +++ b/docs/website/src/python-sdk/guides/boot_process.rst @@ -0,0 +1,73 @@ +.. _startup-process-reference: + +Startup Process +=============== + +Standard library provides a built-in bootloader that bridges :external+genvm:ref:`internal ABI ` +to a more Pythonic interface. + +Bootloader is located in module ``_genlayer_bootloader`` and is executed on import. + +Bootloader Module +----------------- + +``_genlayer_bootloader`` handles contract execution in three distinct modes: + +Entry Points +~~~~~~~~~~~~ + +The bootloader dispatches execution based on ``entry_kind`` from the VM message: + +MAIN +^^^^ +Handles standard contract method calls and initialization. + +* Loads user ``contract`` module and resolves the target method of a declared ``Contract`` class +* Validates method access permissions (public/private, payable) +* Handles special methods: ``__receive__``, ``__handle_undefined_method__`` +* Routes initialization calls to ``__init__`` method +* Enforces security restrictions on dunder methods + +SANDBOX +^^^^^^^ +Executes pickled function objects. + +CONSENSUS_STAGE +^^^^^^^^^^^^^^^ +Runs consensus stage functions with additional stage data parameter (leader non-deterministic blocks outputs or ``None``). + +Method Resolution +~~~~~~~~~~~~~~~~~ + +For MAIN entry kind, the bootloader implements comprehensive method resolution: + +* **Initialization**: Routes to private ``__init__`` method during contract deployment +* **Named Methods**: Resolves public method calls by name with validation +* **Special Handling**: Supports ``__receive__`` for direct calls and ``__handle_undefined_method__`` for undefined method calls +* **Security**: Blocks calls to methods starting with ``__`` and unknown special methods starting with ``#`` + +Error Handling +~~~~~~~~~~~~~~ + +The bootloader provides centralized error management: + +* Catches :py:class:`genlayer.gl.vm.UserError` exceptions and triggers rollback with error message +* Validates contract structure and method accessibility +* Returns appropriate error messages for invalid method calls + +Profiling Support +~~~~~~~~~~~~~~~~~ + +Optional performance profiling when ``GENLAYER_ENABLE_PROFILER`` environment variable is set to ``true``: + +* Uses ``cProfile`` with microsecond timing precision +* Outputs compressed, base64-encoded statistics to stderr on exit +* Integrates with :external+genvm:term:`GenVM` :external+genvm:ref:`debugging infrastructure ` + +Storage Integration +~~~~~~~~~~~~~~~~~~~ + +Manages contract storage lifecycle: + +* Locks default :external+genvm:term:`Storage Slot`\s during contract initialization +* Provides contract instance from the root slot diff --git a/docs/website/src/python-sdk/guides/floating_point.rst b/docs/website/src/python-sdk/guides/floating_point.rst new file mode 100644 index 00000000..173fbc24 --- /dev/null +++ b/docs/website/src/python-sdk/guides/floating_point.rst @@ -0,0 +1,14 @@ +Floating Point in Python API +============================ + +GenVM handles floating point arithmetic differently depending on the execution mode. + +Non-Deterministic Mode +---------------------- + +In non-deterministic mode, floating point operations are unrestricted. Standard hardware-native IEEE 754 floating point is used directly, providing full performance with no additional overhead. + +Deterministic Mode +------------------ + +In deterministic mode, GenVM uses a software-implemented IEEE 754 floating point via integer arithmetic. This ensures that floating point computations produce bit-exact identical results across all validator nodes, regardless of the underlying hardware or platform. Native floating point instructions can yield slightly different results depending on the CPU architecture, compiler optimizations, or rounding behavior, which would break consensus. The software implementation eliminates this source of non-determinism at the cost of **significant** performance overhead. diff --git a/docs/website/src/python-sdk/guides/index.rst b/docs/website/src/python-sdk/guides/index.rst new file mode 100644 index 00000000..f1b97eaa --- /dev/null +++ b/docs/website/src/python-sdk/guides/index.rst @@ -0,0 +1,7 @@ +Guides +====== + +.. toctree:: + + boot_process + floating_point diff --git a/docs/website/src/python-sdk/index.rst b/docs/website/src/python-sdk/index.rst new file mode 100644 index 00000000..8397b369 --- /dev/null +++ b/docs/website/src/python-sdk/index.rst @@ -0,0 +1,16 @@ +Python SDK +========== + +The ``genlayer`` Python package is the intelligent contract SDK for writing +contracts that run inside GenVM. For tutorials and getting started guides, +see the `main documentation `_. + +.. toctree:: + + introduction + reference/index + runners/index + available-runners + guides/index + changelog + migration-guide diff --git a/docs/website/src/python-sdk/introduction.rst b/docs/website/src/python-sdk/introduction.rst new file mode 100644 index 00000000..fba5380d --- /dev/null +++ b/docs/website/src/python-sdk/introduction.rst @@ -0,0 +1,15 @@ +Introduction +============ + +The ``genlayer`` package provides the Python SDK for writing intelligent +contracts that run inside GenVM. It includes storage primitives, contract +interfaces, non-deterministic operations, equivalence principles, and EVM +interoperability -- everything needed to build contracts in Python. + +Under the hood, Python code executes via CPython compiled to WebAssembly as +a "runner" inside GenVM. The SDK abstracts the VM interface so contract +authors can write idiomatic Python while GenVM handles deterministic execution +and consensus. + +For tutorials and getting started, see the +`main documentation `_. diff --git a/docs/website/src/python-sdk/migration-guide.rst b/docs/website/src/python-sdk/migration-guide.rst new file mode 100644 index 00000000..a77d5cd8 --- /dev/null +++ b/docs/website/src/python-sdk/migration-guide.rst @@ -0,0 +1,345 @@ +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: + +.. code-block:: python + + # v0.2.x + from genlayer import * + + # v0.3.0 + import genlayer as gl + from genlayer.types import * + +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``, ``gl.storage``, etc.) and decorators (``gl.public``, ``gl.private``). 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: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - v0.2.x + - v0.3.0 + * - ``genlayer.py.types`` + - ``genlayer.types`` + * - ``genlayer.py.keccak`` + - ``genlayer.types.keccak`` + * - ``genlayer.py.calldata`` + - ``genlayer.calldata`` + * - ``genlayer.py.storage`` + - ``genlayer.storage`` + * - ``genlayer.py.evm`` + - ``genlayer.evm`` + * - ``genlayer.gl.vm`` + - ``genlayer.vm`` + * - ``genlayer.gl.nondet`` + - ``genlayer.nondet`` + * - ``genlayer.gl.eq_principle`` + - ``genlayer.eq_principle`` + * - ``genlayer.gl.genvm_contracts`` + - ``genlayer.contract`` + * - ``genlayer.gl.annotations`` + - ``genlayer._internal.annotations`` + * - ``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``: + +.. code-block:: python + + # v0.2.x + class MyToken(gl.Contract): + ... + + # v0.3.0 + class MyToken(gl.contract.Contract): + ... + +Contract Interaction +~~~~~~~~~~~~~~~~~~~~~ + +Functions for interacting with other contracts have been renamed and moved into ``gl.contract``: + +.. code-block:: python + + # v0.2.x + contract = gl.get_contract_at(address) + gl.deploy_contract(code=source, args=[...]) + + @gl.contract_interface + class IToken: + class View: + def balance_of(self, owner: Address) -> u256: ... + + # v0.3.0 + contract = gl.contract.get_at(address) + gl.contract.deploy(code=source, args=[...]) + + @gl.contract.interface + class IToken: + class View: + def balance_of(self, owner: Address) -> u256: ... + +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'``): + +.. code-block:: python + + contract.emit(value=100, on='finalized').transfer(to, amount) + gl.contract.deploy(code=source, value=1000, on='finalized', salt_nonce=42) + +A new ``emit_transfer`` helper sends a plain value transfer without a method call. The receiver may catch it via ``Contract.__receive__`` (must be ``@gl.public.write.payable``): + +.. code-block:: python + + contract.emit_transfer(1000, on='finalized') + +Accounts +~~~~~~~~ + +``gl.chain.Account`` is a new lightweight wrapper around an ``Address`` that allows querying the balance of, or emitting a transfer to, any on-chain account (contract or EoA): + +.. code-block:: python + + acc = gl.chain.Account(some_address) + bal = acc.balance + acc.emit_transfer(100) + +Message Context +~~~~~~~~~~~~~~~ + +The ``gl.message`` object was a ``NamedTuple``. It is now a module (``genlayer.message``) with the same fields as module-level attributes: + +.. code-block:: python + + # v0.2.x + sender = gl.message.sender_address + value = gl.message.value + + # v0.3.0 (identical usage, but gl.message is a module now) + sender = gl.message.sender_address + value = gl.message.value + +``gl.message_raw`` is now ``gl.message.raw``. The chain ID is exposed as ``gl.message.chain_id`` (and as ``gl.chain.id``). + +Events +~~~~~~ + +The ``Event`` class has moved from ``genlayer.gl`` to ``genlayer.chain``: + +.. code-block:: python + + # v0.2.x + class Transfer(gl.Event): + def __init__(self, sender: Address, to: Address, /): ... + + # v0.3.0 + class Transfer(gl.chain.Event): + def __init__(self, sender: Address, to: Address, /): ... + +``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: + +.. code-block:: python + + # v0.2.x + gl.advanced.user_error_immediate("reason") + gl.advanced.emit_raw_event(topics, blob) + + # v0.3.0 + gl.vm.UserError.immediate("reason") + gl.chain.Event.emit_raw(topics, blob) + +``UserError`` now carries an arbitrary calldata-encodable payload instead of a string. The payload is accessed via ``.data`` (previously ``.message``), and ``UserError.immediate`` accepts any ``calldata.Encodable``: + +.. code-block:: python + + raise gl.vm.UserError({'kind': 'InsufficientBalance', 'have': have, 'need': need}) + + try: + ... + except gl.vm.UserError as e: + payload = e.data + +The error-message handler hook (``__on_errored_message__``) has been removed. + +VM Tracing +~~~~~~~~~~ + +Trace functions have moved from ``genlayer.gl`` to ``genlayer.vm``: + +.. code-block:: python + + # v0.2.x + gl.trace("debug message") + gl.trace_time_micro() + + # v0.3.0 + 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 +~~~~~~~ + +Storage types (``DynArray``, ``Array``, ``TreeMap``) and the ``allow`` decorator are accessible via ``gl.storage``: + +.. code-block:: python + + # v0.2.x + x: gl.DynArray[str] + m: gl.TreeMap[str, u32] + @gl.allow + class MyRecord: ... + + # v0.3.0 + x: gl.storage.DynArray[str] + m: gl.storage.TreeMap[str, u32] + @gl.storage.allow + class MyRecord: ... + +``allow_storage`` was renamed to ``allow`` (accessible as ``gl.storage.allow``). A new ``gl.storage.Pickled[T]`` helper is available for storing arbitrary picklable objects. + +Decorators +~~~~~~~~~~ + +``gl.public`` and ``gl.private`` are still available directly on ``gl``: + +.. code-block:: python + + # Both v0.2.x and v0.3.0 + @gl.public.write + def transfer(self, to: Address, amount: u256): ... + + @gl.public.view + 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 ``gl.gvm32`` module provides Crockford Base32 ``encode``/``decode`` helpers (mirroring the Rust ``genlayer_sdk::gvm32`` implementation). + +Summary of Renames +~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - v0.2.x + - v0.3.0 + * - ``gl.Contract`` + - ``gl.contract.Contract`` + * - ``gl.contract_interface`` + - ``gl.contract.interface`` + * - ``gl.deploy_contract(...)`` + - ``gl.contract.deploy(...)`` + * - ``gl.get_contract_at(addr)`` + - ``gl.contract.get_at(addr)`` + * - ``gl.ContractProxy`` + - ``gl.contract.Proxy`` + * - ``gl.Event`` + - ``gl.chain.Event`` + * - ``gl.advanced.user_error_immediate(...)`` + - ``gl.vm.UserError.immediate(...)`` + * - ``gl.advanced.emit_raw_event(...)`` + - ``gl.chain.Event.emit_raw(...)`` + * - ``gl.trace(...)`` + - ``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`` + - ``gl.storage.allow`` + * - ``gl.allow`` + - ``gl.storage.allow`` + * - ``gl.DynArray`` + - ``gl.storage.DynArray`` + * - ``gl.Array`` + - ``gl.storage.Array`` + * - ``gl.TreeMap`` + - ``gl.storage.TreeMap`` + * - ``UserError(msg: str).message`` + - ``UserError(data: Encodable).data`` diff --git a/docs/website/src/python-sdk/reference/genlayer.rst b/docs/website/src/python-sdk/reference/genlayer.rst new file mode 100644 index 00000000..bdf4b2eb --- /dev/null +++ b/docs/website/src/python-sdk/reference/genlayer.rst @@ -0,0 +1,127 @@ +Package genlayer +================ + +######### +Top level +######### + +.. automodule:: genlayer + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +Integer aliases +^^^^^^^^^^^^^^^ + +It also has aliases for signed and unsigned integer types (such as :py:obj:`~genlayer.types.u256`) and a :py:obj:`~genlayer.types.bigint` alias that can be used in storage unlike regular :py:class:`int` + +######## +contract +######## + +.. automodule:: genlayer.contract + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +####### +message +####### + +.. automodule:: genlayer.message + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +##### +chain +##### + +.. automodule:: genlayer.chain + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +## +vm +## + +.. automodule:: genlayer.vm + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +### +evm +### + +.. automodule:: genlayer.evm + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +###### +nondet +###### + +.. automodule:: genlayer.nondet + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +########## +nondet.web +########## + +.. automodule:: genlayer.nondet.web + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +############ +eq_principle +############ + +.. automodule:: genlayer.eq_principle + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +######## +calldata +######## + +.. automodule:: genlayer.calldata + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +####### +storage +####### + +.. automodule:: genlayer.storage + :members: + :imported-members: + :undoc-members: + :show-inheritance: + +##### +types +##### + +.. automodule:: genlayer.types + :members: + :imported-members: + :undoc-members: + :show-inheritance: diff --git a/docs/website/src/python-sdk/reference/genlayer_embeddings.rst b/docs/website/src/python-sdk/reference/genlayer_embeddings.rst new file mode 100644 index 00000000..06811a73 --- /dev/null +++ b/docs/website/src/python-sdk/reference/genlayer_embeddings.rst @@ -0,0 +1,8 @@ +Package genlayer_embeddings +=========================== + +.. automodule:: genlayer_embeddings + :members: + :imported-members: + :undoc-members: + :show-inheritance: diff --git a/docs/website/src/python-sdk/reference/index.rst b/docs/website/src/python-sdk/reference/index.rst new file mode 100644 index 00000000..6666f445 --- /dev/null +++ b/docs/website/src/python-sdk/reference/index.rst @@ -0,0 +1,7 @@ +API Reference +============= + +.. toctree:: + + genlayer + genlayer_embeddings diff --git a/docs/website/src/python-sdk/runners/index.rst b/docs/website/src/python-sdk/runners/index.rst new file mode 100644 index 00000000..4ca6a04d --- /dev/null +++ b/docs/website/src/python-sdk/runners/index.rst @@ -0,0 +1,38 @@ +Runners +======= + +Runners are language runtimes compiled to WebAssembly that execute inside GenVM. +For the Python SDK, this includes CPython and supporting libraries (cloudpickle, +protobuf, embeddings, etc.) compiled to WASM. + +Each runner is identified by a content hash. When GenVM executes a contract, it +loads the specific runner versions declared in the contract's dependencies. + +Using Runners +------------- + +In your contract's first line, specify the runner dependency: + +.. code-block:: python + + # { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } + +For multi-file contracts, use ``py-genlayer-multi`` instead. + +For contracts using embeddings or semantic search, add ``py-lib-genlayer-embeddings`` +before the main runner using a Seq block: + +.. code-block:: python + + # { + # "Seq": [ + # { "Depends": "py-lib-genlayer-embeddings:" }, + # { "Depends": "py-genlayer:" } + # ] + # } + +For the full runner specification, see :external+genvm:doc:`spec/02-execution-environment/04-runners`. + +For version history and what changed, see the :doc:`Changelog `. + +For list of available runners and their hashes, see :doc:`Available Runners `. diff --git a/flake.nix b/flake.nix index fc04e913..f7d32904 100644 --- a/flake.nix +++ b/flake.nix @@ -35,6 +35,20 @@ let pkgs = import nixpkgs { inherit system; }; hooks-python = pkgs.python312; + # Self-contained docs toolchain (sphinx + extensions) for this line's + # standalone docs sub-site, built via `nix develop .#gen-docs`. The + # manager's support/ci/pipelines/docs.py enters this shell and drops the + # output under build/doc/html/executors//. + docs-python = pkgs.python312.withPackages ( + ps: with ps; [ + sphinx + myst-parser + pydata-sphinx-theme + sphinxcontrib-mermaid + # genlayer_embeddings autodoc imports numpy (not mocked). + numpy + ] + ); # cargo fmt per crate: each Cargo.toml picks up its own edition. cargo-fmt-all = pkgs.writeShellScript "cargo-fmt-all" '' set -euo pipefail @@ -155,6 +169,16 @@ # Standalone dev shell: installs the git-hooks stubs into .git/hooks. shellHook = pre-commit-check.shellHook; }; + devShells.gen-docs = pkgs.mkShell { + # Standalone sphinx toolchain for this line's docs sub-site (mmdc + # backs the sphinxcontrib.mermaid svg output). The text→api.txt merge + # is plain python (docs/website/merge_txts.py), run by the manager + # pipeline, so no ruby here. + packages = [ + docs-python + pkgs.mermaid-cli + ]; + }; } ); } From a210d2d2da046db1337bd032974dc1219c8377ee Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 16 Jul 2026 19:53:04 +0900 Subject: [PATCH 25/25] chore: fix tests --- executor/Cargo.lock | 51 +- executor/Cargo.toml | 9 +- executor/crates/common/Cargo.lock | 24 +- executor/crates/common/Cargo.toml | 1 + executor/crates/common/tests/expr.rs | 5 +- executor/crates/common/tests/fees_abi.rs | 23 +- executor/crates/modules-interfaces/Cargo.lock | 1004 +++++++++++++++++ executor/crates/modules-interfaces/Cargo.toml | 2 +- executor/src/runners/mod.rs | 5 + executor/src/wasi/genlayer_sdk/tests.rs | 11 +- 10 files changed, 1063 insertions(+), 72 deletions(-) create mode 100644 executor/crates/modules-interfaces/Cargo.lock diff --git a/executor/Cargo.lock b/executor/Cargo.lock index 5e68f036..d1028bdd 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -806,12 +806,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - [[package]] name = "flate2" version = "1.1.1" @@ -1008,7 +1002,7 @@ dependencies = [ "genlayer_calldata", "genlayer_sdk", "genvm-common", - "genvm-modules-interfaces", + "genvm_modules_interfaces", "getrandom 0.3.2", "hex", "itertools", @@ -1033,12 +1027,9 @@ dependencies = [ "tokio-mpmc", "tokio-tungstenite", "tracing", - "wasm-encoder", - "wasm-smith", "wasmparser", "wasmtime", "wasmtime-internal-cache", - "wat", "wiggle", "zip", ] @@ -1057,6 +1048,7 @@ dependencies = [ "dashmap", "genlayer_calldata", "genlayer_sdk", + "genvm_modules_interfaces", "hex", "libc", "num-bigint", @@ -1076,7 +1068,7 @@ dependencies = [ ] [[package]] -name = "genvm-modules-interfaces" +name = "genvm_modules_interfaces" version = "0.1.0" dependencies = [ "anyhow", @@ -2516,12 +2508,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -2633,17 +2619,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-smith" -version = "0.245.1" -dependencies = [ - "anyhow", - "arbitrary", - "flagset", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasmparser" version = "0.245.1" @@ -2868,24 +2843,6 @@ dependencies = [ "leb128", ] -[[package]] -name = "wast" -version = "245.0.1" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder", -] - -[[package]] -name = "wat" -version = "1.245.1" -dependencies = [ - "wast 245.0.1", -] - [[package]] name = "wiggle" version = "42.0.1" @@ -3151,7 +3108,7 @@ dependencies = [ "anyhow", "log", "thiserror 1.0.69", - "wast 35.0.2", + "wast", ] [[package]] diff --git a/executor/Cargo.toml b/executor/Cargo.toml index 92b06c62..6eb41527 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -56,7 +56,7 @@ genvm-common = { path = "crates/common" } # it). The executor is intentionally NOT standalone-buildable on its own. genlayer_calldata = { path = "crates/calldata" } genlayer_sdk = { path = "crates/sdk-rs", default-features = false } -genvm-modules-interfaces = { path = "crates/modules-interfaces" } +genvm_modules_interfaces = { path = "crates/modules-interfaces" } anyhow = { version = "1.0.97", default-features = false } arbitrary = { version = "1.0.3", features = ["derive"] } @@ -117,10 +117,3 @@ zip = "4.0.0" [dev-dependencies] afl = { version = "0.15.18", features = ["no_cfg_fuzzing"] } libc = "0.2.162" -wasm-encoder = { path = "third-party/wasm-tools/crates/wasm-encoder" } -wasm-smith = { path = "third-party/wasm-tools/crates/wasm-smith", features = [ - "wasmparser", -] } -wat = { path = "third-party/wasm-tools/crates/wat" } -#wasm-encoder = { path = "third-party/wasm-tools/crates/wasm-encoder", default-features = false, features = ["wasmparser"]} -#wasmtime-fuzzing = { path = "third-party/wasmtime/crates/fuzzing", default-features = false } diff --git a/executor/crates/common/Cargo.lock b/executor/crates/common/Cargo.lock index e8047774..1bf78833 100644 --- a/executor/crates/common/Cargo.lock +++ b/executor/crates/common/Cargo.lock @@ -217,9 +217,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -527,6 +527,7 @@ dependencies = [ "dashmap", "genlayer_calldata", "genlayer_sdk", + "genvm_modules_interfaces", "hex", "libc", "num-bigint", @@ -545,6 +546,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "genvm_modules_interfaces" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "chrono", + "genlayer_calldata", + "num-bigint", + "num-traits", + "primitive-types", + "regex", + "serde", + "serde_bytes", + "serde_derive", + "serde_json", + "tokio", +] + [[package]] name = "getrandom" version = "0.2.16" diff --git a/executor/crates/common/Cargo.toml b/executor/crates/common/Cargo.toml index 761f7783..a186fa98 100644 --- a/executor/crates/common/Cargo.toml +++ b/executor/crates/common/Cargo.toml @@ -11,6 +11,7 @@ genlayer_calldata = { path = "../calldata" } genlayer_sdk = { path = "../sdk-rs", default-features = false, features = [ "arbitrary", ] } +genvm_modules_interfaces = { path = "../modules-interfaces" } anyhow = { version = "1.0.97", features = ["backtrace"] } arbitrary = "1.4.2" diff --git a/executor/crates/common/tests/expr.rs b/executor/crates/common/tests/expr.rs index b2d52314..1b8773c5 100644 --- a/executor/crates/common/tests/expr.rs +++ b/executor/crates/common/tests/expr.rs @@ -62,7 +62,10 @@ fn has_key_builtin() { #[test] fn object_missing_key_is_error() { let expr = Expr::parse("{ a = 1; }.b").unwrap(); - assert!(matches!(expr.evaluate(), Err(EvalError::Custom(_)))); + match expr.evaluate() { + Err(EvalError::ObjectKeyNotFound(key)) => assert_eq!(key, "b"), + other => panic!("expected ObjectKeyNotFound, got {other:?}"), + } } #[test] diff --git a/executor/crates/common/tests/fees_abi.rs b/executor/crates/common/tests/fees_abi.rs index ada8ecb0..87485a59 100644 --- a/executor/crates/common/tests/fees_abi.rs +++ b/executor/crates/common/tests/fees_abi.rs @@ -1,5 +1,5 @@ use genlayer_sdk::abi::gl_call::On; -use genvm_modules_interfaces::domain::{ +use genvm_modules_interfaces::fees::{ ExternalMessageParams, InternalMessageParams, MessageAllocationNode, MessageAllocationNodeParams, }; @@ -29,10 +29,10 @@ fn external_node( ) -> MessageAllocationNode { MessageAllocationNode { recipient: recipient.map(genlayer_sdk::calldata::Address::from), - call_key: call_key.map(genlayer_sdk::abi::CallKey), + call_key: call_key.map(genvm_modules_interfaces::CallKey), budget: U256::from(budget), // External messages have no acceptance/finalize lifecycle; value is unused. - on: On::Finalized, + on: genvm_modules_interfaces::On::Finalized, fee_params: MessageAllocationNodeParams::External(ExternalMessageParams { gas_limit: U256::from(gas_limit), max_gas_price: U256::from(max_gas_price), @@ -42,7 +42,7 @@ fn external_node( } fn internal_node( - on: On, + on: genvm_modules_interfaces::On, budget: u64, rotations: &[u64], children: Vec, @@ -104,7 +104,12 @@ fn external_root_node_matches_exact_encoding() { fn nested_internal_flattens_with_parent_pointers() { // root (internal, accepted) with a single external child. let child = external_node(Some([0x22u8; 20]), None, 1, 100, 200, vec![]); - let root = internal_node(On::Accepted, 10, &[2, 3], vec![child]); + let root = internal_node( + genvm_modules_interfaces::On::Accepted, + 10, + &[2, 3], + vec![child], + ); let encoded = MessageAllocationNode::abi_encode(&[root]); @@ -158,8 +163,12 @@ fn nested_internal_flattens_with_parent_pointers() { fn internal_params_encode_derived_appeal_rounds() { // appealRounds is not stored on the Rust side; it is reconstructed as // len(rotations) - 1 when encoding. - let encoded = - MessageAllocationNode::abi_encode(&[internal_node(On::Finalized, 10, &[2, 3, 4], vec![])]); + let encoded = MessageAllocationNode::abi_encode(&[internal_node( + genvm_modules_interfaces::On::Finalized, + 10, + &[2, 3, 4], + vec![], + )]); // Walk to the feeParams bytes inside the single element. let heads_base = 2 * 32; diff --git a/executor/crates/modules-interfaces/Cargo.lock b/executor/crates/modules-interfaces/Cargo.lock new file mode 100644 index 00000000..fabd90f1 --- /dev/null +++ b/executor/crates/modules-interfaces/Cargo.lock @@ -0,0 +1,1004 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation-sys" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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" +dependencies = [ + "bytes", + "chrono", + "genlayer_calldata_derive", + "hex", + "num-bigint", + "primitive-types", + "serde", + "sha3", +] + +[[package]] +name = "genlayer_calldata_derive" +version = "0.0.1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "genvm_modules_interfaces" +version = "0.1.0" +dependencies = [ + "anyhow", + "arbitrary", + "bytes", + "chrono", + "genlayer_calldata", + "num-bigint", + "num-traits", + "primitive-types", + "regex", + "serde", + "serde_bytes", + "serde_derive", + "serde_json", + "tokio", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "impl-codec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d40b9d5e17727407e55028eafc22b2dc68781786e6d7eb8a21103f5058e3a14" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-serde" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a143eada6a1ec4aefa5049037a26a6d597bfd64f8c026d07b77133e02b7dd0b" +dependencies = [ + "serde", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "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 = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "impl-codec", + "impl-serde", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/executor/crates/modules-interfaces/Cargo.toml b/executor/crates/modules-interfaces/Cargo.toml index acf2fb5b..46e32c2b 100644 --- a/executor/crates/modules-interfaces/Cargo.toml +++ b/executor/crates/modules-interfaces/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "genvm-modules-interfaces" +name = "genvm_modules_interfaces" version = "0.1.0" edition = "2021" diff --git a/executor/src/runners/mod.rs b/executor/src/runners/mod.rs index f2e8a6b2..1580f12c 100644 --- a/executor/src/runners/mod.rs +++ b/executor/src/runners/mod.rs @@ -205,6 +205,11 @@ pub fn parse_runner_id(id: &str) -> Option { return None; } + if !address.starts_with("0x") { + log_warn!("chain address must be 0x-prefixed"); + return None; + } + let address = calldata::Address::from(parse_safe_address(address)?); let on = match on_str { Some("a") | None => Some(ChainState::Accepted), diff --git a/executor/src/wasi/genlayer_sdk/tests.rs b/executor/src/wasi/genlayer_sdk/tests.rs index 9ab907c3..488df791 100644 --- a/executor/src/wasi/genlayer_sdk/tests.rs +++ b/executor/src/wasi/genlayer_sdk/tests.rs @@ -2,8 +2,8 @@ use super::message::{validate_balance_fee, FEE_PARAM_COUNT_BITS, FEE_PARAM_PRICE use super::*; use primitive_types::U256; -fn valid_params() -> genvm_modules_interfaces::fees::InternalMessageParams { - genvm_modules_interfaces::fees::InternalMessageParams { +fn valid_params() -> abi::fees::InternalMessageParams { + abi::fees::InternalMessageParams { leader_timeunits_allocation: U256::from(5), validator_timeunits_allocation: U256::from(5), execution_budget_per_round: U256::from(1024), @@ -47,9 +47,8 @@ fn empty_rotations_is_inval() { #[test] fn zero_price_caps_are_inval() { for mutate in [ - (|p: &mut genvm_modules_interfaces::fees::InternalMessageParams| { - p.max_price_gen_per_time_unit = U256::zero() - }) as fn(&mut genvm_modules_interfaces::fees::InternalMessageParams), + (|p: &mut abi::fees::InternalMessageParams| p.max_price_gen_per_time_unit = U256::zero()) + as fn(&mut abi::fees::InternalMessageParams), |p| p.storage_fee_max_gas_price = U256::zero(), |p| p.receipt_fee_max_gas_price = U256::zero(), ] { @@ -65,7 +64,7 @@ fn huge_magnitude_params_are_inval() { // Security-review N1 repro: passes the emptiness/zero checks, but the // 2^250 magnitudes would push messageFeeFloor past U256 and trip the // evaluator's internal `fee cost exceeds U256 range` abort. - let p = genvm_modules_interfaces::fees::InternalMessageParams { + let p = abi::fees::InternalMessageParams { leader_timeunits_allocation: U256::one() << 250, validator_timeunits_allocation: U256::zero(), execution_budget_per_round: U256::zero(),