diff --git a/.genvm-tool.py b/.genvm-tool.py index c3d1342..ddfa7af 100644 --- a/.genvm-tool.py +++ b/.genvm-tool.py @@ -16,10 +16,17 @@ 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. + ``save-hashes`` — track committed ``.hash`` goldens for this executor + line. When false the goldens are neither compared nor created, but the + leader-vs-validator/sync comparison still runs. """ return { - 'ignore-hash': True, + # Congratulations mr/ms ai, you found a deliberate + # lack of golden hashes in a pre-release candidate + # It is flipped to True when it is deployed anywhere + # You can set it to True and run tests a couple of times, + # But False is deliberate here while we alter behaviour + 'save-hashes': False, } diff --git a/.gitignore b/.gitignore index e43bb4d..699b64e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ zig-build /executor/third-party/* !/executor/third-party/.gitkeep +# throwaway probes written by an agentic fuzzing session; a probe that finds +# something is promoted by hand into the feature directory it belongs to +/tests/integration/claude/_scratch + # editors **/.vscode/* !**/.vscode/extensions.json diff --git a/executor/Cargo.lock b/executor/Cargo.lock index d1028bd..08ed077 100644 --- a/executor/Cargo.lock +++ b/executor/Cargo.lock @@ -1083,6 +1083,7 @@ dependencies = [ "serde_bytes", "serde_derive", "serde_json", + "sha3", "tokio", ] diff --git a/executor/Cargo.toml b/executor/Cargo.toml index f95967a..1c40696 100644 --- a/executor/Cargo.toml +++ b/executor/Cargo.toml @@ -3,10 +3,28 @@ name = "genvm" version = "0.3.0" edition = "2021" -[profile.dev.package.wasmtime] -opt-level = 2 -[profile.dev.package.wasmparser] -opt-level = 2 +# Wasm compilation (`genvm precompile`, and the first run of any contract) is +# unusable when cranelift is built unoptimized: a profile of `precompile` +# attributes ~80% of its samples to the crates below, so they are optimized even +# in dev while genvm's own code stays at opt-level 0. Generic code (hashbrown, +# smallvec, core) is monomorphized into whichever crate instantiates it, so +# optimizing these covers it too. +[profile.dev.package] +cranelift-assembler-x64.opt-level = 2 +cranelift-bforest.opt-level = 2 +cranelift-bitset.opt-level = 2 +cranelift-codegen.opt-level = 2 +cranelift-control.opt-level = 2 +cranelift-entity.opt-level = 2 +cranelift-frontend.opt-level = 2 +gimli.opt-level = 2 +log.opt-level = 2 +object.opt-level = 2 +regalloc2.opt-level = 2 +wasmparser.opt-level = 2 +wasmtime.opt-level = 2 +wasmtime-environ.opt-level = 2 +wasmtime-internal-cranelift.opt-level = 2 [profile.dev] incremental = false diff --git a/executor/codegen/data/host-fns.json b/executor/codegen/data/host-fns.json deleted file mode 100644 index ca07a54..0000000 --- a/executor/codegen/data/host-fns.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "type": "enum", - "repr": "u8", - "name": "methods", - "values": { - "storage_read": 0, - "consume_fuel": 1, - "eth_call": 2, - "get_balance": 3, - "remaining_fuel_as_gen": 4, - "notify_nondet_disagreement": 5, - "consume_result": 6, - "notify_finished": 7 - } - }, - { - "type": "enum", - "repr": "u8", - "name": "errors", - "values": { - "ok": 0, - "evm_reverted": 1, - "forbidden": 2 - } - }, - { - "type": "str_trie", - "name": "vm_error_detail", - "values": [ - "internal", - "external" - ] - }, - { - "type": "const", - "name": "current_major", - "repr": "u8", - "value": 0 - }, - { - "type": "const", - "name": "current_major_str", - "repr": "str", - "value": "v0.0.0" - } -] diff --git a/executor/codegen/data/public-abi.json b/executor/codegen/data/public-abi.json index 26b4221..54b38ce 100644 --- a/executor/codegen/data/public-abi.json +++ b/executor/codegen/data/public-abi.json @@ -92,6 +92,9 @@ { "type": "str_trie", "name": "vm_error", + "docs": { + "invalid_contract major_mismatch": "This error remains terminal for top-level and runner loads. During :ref:`gvm-def-gl-call-call-contract`, the host may select another executor before the local major check." + }, "values": [ "timeout", "absent_leader_nondet_output", diff --git a/executor/crates/common/Cargo.lock b/executor/crates/common/Cargo.lock index 1bf7883..de7f0cb 100644 --- a/executor/crates/common/Cargo.lock +++ b/executor/crates/common/Cargo.lock @@ -562,6 +562,7 @@ dependencies = [ "serde_bytes", "serde_derive", "serde_json", + "sha3", "tokio", ] diff --git a/executor/crates/common/src/expr/evaluator.rs b/executor/crates/common/src/expr/evaluator.rs index 0bd771e..c229685 100644 --- a/executor/crates/common/src/expr/evaluator.rs +++ b/executor/crates/common/src/expr/evaluator.rs @@ -27,6 +27,7 @@ fn thunk_of(expr: &Expr, ctx: &EvalContext) -> Thunk { fn eval(expr: &Expr, ctx: &EvalContext) -> Result { match expr { Expr::Const(n) => Ok(Value::Rational(n.clone())), + Expr::Bool(b) => Ok(Value::Bool(*b)), Expr::Ident(name) => { if let Some(thunk) = ctx.let_bindings.get(name.as_str()) { thunk.force() diff --git a/executor/crates/common/src/expr/lexer.rs b/executor/crates/common/src/expr/lexer.rs index e38ffb3..d4839d7 100644 --- a/executor/crates/common/src/expr/lexer.rs +++ b/executor/crates/common/src/expr/lexer.rs @@ -197,6 +197,8 @@ impl Parser { self.peek(), Token::Num(_) | Token::Ident(_) + | Token::True + | Token::False | Token::LParen | Token::LBracket | Token::LBrace @@ -244,6 +246,8 @@ impl Parser { match self.advance() { Token::Num(n) => Ok(Expr::Const(n)), Token::Ident(s) => Ok(Expr::Ident(s)), + Token::True => Ok(Expr::Bool(true)), + Token::False => Ok(Expr::Bool(false)), Token::LParen => { let expr = self.parse_expr()?; self.expect(&Token::RParen)?; diff --git a/executor/crates/common/src/expr/tokenizer.rs b/executor/crates/common/src/expr/tokenizer.rs index a9c7b6c..a1f80c6 100644 --- a/executor/crates/common/src/expr/tokenizer.rs +++ b/executor/crates/common/src/expr/tokenizer.rs @@ -28,6 +28,8 @@ pub(crate) enum Token { If, Then, Else, + True, + False, Backslash, LBracket, RBracket, @@ -71,6 +73,8 @@ impl fmt::Display for Token { Token::If => write!(f, "`if`"), Token::Then => write!(f, "`then`"), Token::Else => write!(f, "`else`"), + Token::True => write!(f, "`true`"), + Token::False => write!(f, "`false`"), Token::Backslash => write!(f, r"`\`"), Token::LBracket => write!(f, "`[`"), Token::RBracket => write!(f, "`]`"), @@ -298,6 +302,10 @@ impl<'a> Tokenizer<'a> { "if" => Token::If, "then" => Token::Then, "else" => Token::Else, + // Reserved like the other keywords, so `true` is not usable as + // a bare attribute name; quote it (`{ "true" = ...; }`) instead. + "true" => Token::True, + "false" => Token::False, _ => Token::Ident(s.to_owned()), }); } diff --git a/executor/crates/common/src/expr/value.rs b/executor/crates/common/src/expr/value.rs index 86be5e2..f0a579f 100644 --- a/executor/crates/common/src/expr/value.rs +++ b/executor/crates/common/src/expr/value.rs @@ -62,6 +62,7 @@ pub enum EvalError { pub enum Expr { Ident(String), Const(BigRational), + Bool(bool), BinOp { op: BinOp, lhs: Box, diff --git a/executor/crates/common/src/host_fns.rs b/executor/crates/common/src/host_fns.rs deleted file mode 100644 index 3c4d24d..0000000 --- a/executor/crates/common/src/host_fns.rs +++ /dev/null @@ -1,154 +0,0 @@ -// This file is auto-generated. Do not edit! - -#![allow(dead_code, clippy::redundant_static_lifetimes)] - -use serde::{Deserialize, Serialize}; - -use std::borrow::Cow; - -#[derive( - Debug, - PartialEq, - Clone, - Copy, - Serialize, - Deserialize, - ::genlayer_calldata::Encode, - ::genlayer_calldata::Decode, -)] -#[repr(u8)] -pub enum Methods { - StorageRead = 0, - ConsumeFuel = 1, - EthCall = 2, - GetBalance = 3, - RemainingFuelAsGen = 4, - NotifyNondetDisagreement = 5, - ConsumeResult = 6, - NotifyFinished = 7, -} - -impl Methods { - pub const SIZE: usize = 8; - pub fn value(self) -> u8 { - match self { - Methods::StorageRead => 0, - Methods::ConsumeFuel => 1, - Methods::EthCall => 2, - Methods::GetBalance => 3, - Methods::RemainingFuelAsGen => 4, - Methods::NotifyNondetDisagreement => 5, - Methods::ConsumeResult => 6, - Methods::NotifyFinished => 7, - } - } - pub fn str_snake_case(self) -> &'static str { - match self { - Methods::StorageRead => "storage_read", - Methods::ConsumeFuel => "consume_fuel", - Methods::EthCall => "eth_call", - Methods::GetBalance => "get_balance", - Methods::RemainingFuelAsGen => "remaining_fuel_as_gen", - Methods::NotifyNondetDisagreement => "notify_nondet_disagreement", - Methods::ConsumeResult => "consume_result", - Methods::NotifyFinished => "notify_finished", - } - } -} - -impl TryFrom for Methods { - type Error = (); - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(Methods::StorageRead), - 1 => Ok(Methods::ConsumeFuel), - 2 => Ok(Methods::EthCall), - 3 => Ok(Methods::GetBalance), - 4 => Ok(Methods::RemainingFuelAsGen), - 5 => Ok(Methods::NotifyNondetDisagreement), - 6 => Ok(Methods::ConsumeResult), - 7 => Ok(Methods::NotifyFinished), - _ => Err(()), - } - } -} -#[derive( - Debug, - PartialEq, - Clone, - Copy, - Serialize, - Deserialize, - ::genlayer_calldata::Encode, - ::genlayer_calldata::Decode, -)] -#[repr(u8)] -pub enum Errors { - Ok = 0, - EvmReverted = 1, - Forbidden = 2, -} - -impl Errors { - pub const SIZE: usize = 3; - pub fn value(self) -> u8 { - match self { - Errors::Ok => 0, - Errors::EvmReverted => 1, - Errors::Forbidden => 2, - } - } - pub fn str_snake_case(self) -> &'static str { - match self { - Errors::Ok => "ok", - Errors::EvmReverted => "evm_reverted", - Errors::Forbidden => "forbidden", - } - } -} - -impl TryFrom for Errors { - type Error = (); - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(Errors::Ok), - 1 => Ok(Errors::EvmReverted), - 2 => Ok(Errors::Forbidden), - _ => 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")) } -} - -#[rustfmt::skip] -impl VmErrorDetail { - /// Whether `s` is a well-formed `vm_error_detail` path. - pub fn is_valid_(s: &str) -> bool { - if matches!(s, - "internal" | - "external" - ) { - return true; - } - false - } -} - -pub const CURRENT_MAJOR: u8 = 0; -pub const CURRENT_MAJOR_STR: &'static str = "v0.0.0"; - -// EOF diff --git a/executor/crates/common/src/lib.rs b/executor/crates/common/src/lib.rs index 8880f65..a88d317 100644 --- a/executor/crates/common/src/lib.rs +++ b/executor/crates/common/src/lib.rs @@ -19,7 +19,7 @@ pub mod templater; pub mod version; pub mod expr; -pub mod host_fns; +pub use genvm_modules_interfaces::host_fns; pub mod public_abi_pending; pub mod util; diff --git a/executor/crates/common/tests/expr.rs b/executor/crates/common/tests/expr.rs index e2b2875..ebb8bd5 100644 --- a/executor/crates/common/tests/expr.rs +++ b/executor/crates/common/tests/expr.rs @@ -438,3 +438,53 @@ fn lazy_unused_argument_is_not_evaluated() { // The argument `1 / 0` is passed as a thunk and never demanded. assert_rational(eval(r"(\x = 7) (1 / 0)"), 7, 1); } + +#[test] +fn bool_literals() { + assert_bool(eval("true"), true); + assert_bool(eval("false"), false); +} + +#[test] +fn bool_literals_in_conditions() { + assert_rational(eval("if true then 10 else 20"), 10, 1); + assert_rational(eval("if false then 10 else 20"), 20, 1); + // the shape that motivated literals: a guard whose other arm is a constant + // `false`, previously spellable only as a comparison that never holds + assert_rational( + eval("let guard = if 0 > 0 then 1 > 0 else false in if guard then 1 else 2"), + 2, + 1, + ); +} + +#[test] +fn bool_literals_compare_and_bind() { + assert_bool(eval("true == true"), true); + assert_bool(eval("true == false"), false); + assert_bool(eval("true != false"), true); + assert_bool(eval("let t = true in t"), true); + assert_bool(eval(r"(\x = x) false"), false); + assert_bool(eval("arrayGetElem [true, false] 1"), false); +} + +#[test] +fn bool_literals_are_reserved_words() { + // like `if`/`let`, they cannot be rebound or used as a bare attribute name + assert!(Expr::parse("let true = 1 in true").is_err()); + assert!(Expr::parse("{ true = 1; }").is_err()); + // the quoted form stays available for both the key and the selection + assert_rational(eval(r#"{ "true" = 1; }."true""#), 1, 1); +} + +#[test] +fn bool_literal_is_not_a_number() { + assert!(matches!( + Expr::parse("true + 1").unwrap().evaluate(), + Err(EvalError::TypeError { .. }) + )); + assert!(matches!( + Expr::parse("if 1 then 2 else 3").unwrap().evaluate(), + Err(EvalError::TypeError { .. }) + )); +} diff --git a/executor/crates/modules-interfaces/Cargo.lock b/executor/crates/modules-interfaces/Cargo.lock index fabd90f..bf3b325 100644 --- a/executor/crates/modules-interfaces/Cargo.lock +++ b/executor/crates/modules-interfaces/Cargo.lock @@ -288,6 +288,7 @@ dependencies = [ "serde_bytes", "serde_derive", "serde_json", + "sha3", "tokio", ] diff --git a/executor/crates/modules-interfaces/Cargo.toml b/executor/crates/modules-interfaces/Cargo.toml index 46e32c2..1a3a7c4 100644 --- a/executor/crates/modules-interfaces/Cargo.toml +++ b/executor/crates/modules-interfaces/Cargo.toml @@ -24,6 +24,7 @@ serde = { version = "1.0.219", features = ["rc", "derive"] } serde_bytes = "0.11.17" serde_derive = "1.0.219" serde_json = "1.0.140" +sha3 = "0.10" tokio = { version = "1.44.1", features = [ "rt", "rt-multi-thread", diff --git a/executor/src/exe/precompile.rs b/executor/src/exe/precompile.rs index ac4e832..26ac256 100644 --- a/executor/src/exe/precompile.rs +++ b/executor/src/exe/precompile.rs @@ -15,6 +15,48 @@ pub struct Args { info: bool, } +/// Writes `data` to a uniquely named sibling of `path`, then renames it onto +/// `path`. +/// +/// The reader `deserialize_file`s whatever sits at the final path without +/// validating it, so a crash mid-write must not leave a truncated module there. +/// The temp name mixes pid, an in-process counter and the wall clock, so +/// concurrent writers never pick the same one; the fsync keeps the rename from +/// exposing a name whose contents are still only in page cache. +fn write_atomically(path: &std::path::Path, data: &[u8]) -> anyhow::Result<()> { + use std::io::Write as _; + + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since_epoch| since_epoch.as_nanos()); + let mut tmp_name = path + .file_name() + .expect("precompiled wasm path has no file name") + .to_owned(); + tmp_name.push(format!( + ".tmp-{}-{}-{stamp}", + std::process::id(), + COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + )); + let tmp_path = path.with_file_name(tmp_name); + + let write_tmp = || -> std::io::Result<()> { + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(data)?; + file.sync_all()?; + std::fs::rename(&tmp_path, path) + }; + + if let Err(e) = write_tmp() { + let _ = std::fs::remove_file(&tmp_path); + return Err(e).with_context(|| format!("writing {tmp_path:?} and renaming it to {path:?}")); + } + + Ok(()) +} + fn compile_single_file_single_mode( result_path: &std::path::Path, engine: &wasmtime::Engine, @@ -40,8 +82,7 @@ fn compile_single_file_single_mode( let sz = precompiled.len(); - std::fs::write(result_path, precompiled) - .with_context(|| format!("writing to {result_path:?}"))?; + write_atomically(result_path, &precompiled)?; log_info!("size" = sz, result:? = result_path, engine = engine_type, runner:? = runner_path, runner_path:? = path_in_runner, duration:? = time_start.elapsed(); "wasm writing done"); diff --git a/executor/src/exe/run.rs b/executor/src/exe/run.rs index b879b02..b611538 100644 --- a/executor/src/exe/run.rs +++ b/executor/src/exe/run.rs @@ -13,6 +13,45 @@ use genvm::{ const EXECUTION_DATA_HELP: &str = "path to file containing encoded execution data (use '-' for stdin, 'fd://N' for file descriptor N)"; +fn fill_nested_fee_buckets( + is_nested: bool, + max_bucket_no: usize, + bucket_totals: &mut Vec, +) { + if is_nested && bucket_totals.is_empty() { + bucket_totals.resize(max_bucket_no + 1, primitive_types::U256::zero()); + } +} + +/// Rejects permissions a nested run cannot act on whatever its caller derived. +/// +/// A delegated run has no module connection, no fee allocation and no writable +/// storage, so accepting one of these would let it start and then fail at the +/// first use. The manager checks the same thing when it builds the envelope; +/// this executor does not get to assume that whoever spawned it did. +fn check_nested_permissions( + permissions: genvm_modules_interfaces::NestedPermissions, +) -> Result<()> { + use genvm_modules_interfaces::NestedPermissions as P; + + for (bit, name) in [ + (P::SPAWN_NONDET, "spawn_nondet"), + (P::WRITE_STORAGE, "write_storage"), + (P::SEND_MESSAGES, "send_messages"), + ( + P::USE_BALANCE_FOR_MESSAGE_FEES, + "use_balance_for_message_fees", + ), + ] { + anyhow::ensure!( + !permissions.contains(bit), + "nested execution data asserts `{name}`, which a run crossing a major boundary never carries" + ); + } + + Ok(()) +} + #[derive(clap::Args, Debug)] pub struct Args { #[arg( @@ -98,6 +137,17 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { let message = &execution_data.message; let host_data = rt::parse_host_data(&execution_data)?; + let can_write_storage = match &execution_data.nested { + Some(nested) => { + check_nested_permissions(nested.permissions)?; + nested + .permissions + .contains(genvm_modules_interfaces::NestedPermissions::WRITE_STORAGE) + } + None => args.permissions.contains('w'), + }; + let is_nested = execution_data.nested.is_some(); + let runtime = config .base .create_rt() @@ -124,7 +174,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { .collect::>(); metrics.hosts = Box::from(metrics_for_each_host); - let bucket_totals = execution_data + let mut bucket_totals = execution_data .bucket_totals .iter() .map(|bi| { @@ -153,6 +203,9 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { .max() .unwrap_or(0); + // A nested CallContract is read-only and receives no fee buckets. Keep the + // configured bucket shape valid without granting it a spendable balance. + fill_nested_fee_buckets(is_nested, max_bucket_no as usize, &mut bucket_totals); anyhow::ensure!( (max_bucket_no as usize) < bucket_totals.len(), "fees config references bucket {max_bucket_no} but only {} bucket(s) provided", @@ -160,13 +213,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { ); let shared_data = sync::DArc::new(genvm::rt::SharedData { - run_mode: if args.sync { - genvm::rt::RunMode::Sync - } else if execution_data.leader_nondet_results.is_none() { - genvm::rt::RunMode::Leader - } else { - genvm::rt::RunMode::Validator - }, + run_mode: genvm::rt::infer_run_mode(args.sync, &execution_data.leader_nondet_results), genvm_id: genvm_modules_interfaces::GenVMId(genvm_id), debug_mode: args.debug_mode, metrics, @@ -175,6 +222,9 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { config.fees.clone(), execution_data.gas_data.clone(), )?, + det_fuel_budget: genvm::rt::DetFuelBudget::new( + execution_data.nested.as_ref().map(|n| n.remaining_det_fuel), + ), llm_consumption: tokio::sync::Mutex::new(primitive_types::U256::zero()), }); @@ -184,7 +234,11 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { .enumerate() .map(|(id, uri)| { let metrics = shared_data.gep(|x| &x.metrics.hosts[id]); - genvm::Host::connect(uri, metrics) + let hello_data = execution_data + .host_hello_data + .get(id) + .map_or(&[][..], bytes::Bytes::as_ref); + genvm::Host::connect(uri, metrics, hello_data) .with_context(|| format!("connecting host {id} to {uri}")) }) .collect::>()?; @@ -204,8 +258,13 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { // Charge the per-bucket up-front fees now that the hosts are connected. If a // bucket cannot cover its `subtract_on_start`, report the resulting VM error // to the host as a normal receipt instead of crashing during setup. - if let Some(insufficient_err) = runtime.block_on(shared_data.data_fees_limit.consume_initial()) - { + // The outer execution already paid startup fees for this logical run. + let insufficient_err = if is_nested { + None + } else { + runtime.block_on(shared_data.data_fees_limit.consume_initial()) + }; + if let Some(insufficient_err) = insufficient_err { let host_for = |method: genvm::host::host_fns::Methods| -> usize { let m = method as usize; if m < method_hosts.len() { @@ -238,9 +297,9 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { hosts[host_for(genvm::host::host_fns::Methods::ConsumeResult)] .consume_result(&result) .context("consume result")?; - hosts[host_for(genvm::host::host_fns::Methods::NotifyFinished)] - .notify_finished() - .context("notify finished")?; + for host in &mut hosts { + host.flush().context("flush host before exit")?; + } runtime.shutdown_timeout(std::time::Duration::from_millis(30)); @@ -274,6 +333,8 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { 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(), + memory_limit: execution_data.nested.as_ref().map(|n| n.memory_limit), + can_write_storage, }, host_data, shared_data, diff --git a/executor/src/host/mod.rs b/executor/src/host/mod.rs index fa3b316..5dfb40d 100644 --- a/executor/src/host/mod.rs +++ b/executor/src/host/mod.rs @@ -38,9 +38,9 @@ impl Host { pub fn new(sock: Box, metrics: sync::DArc) -> Host { Self { sock, metrics } } - pub fn connect(addr: &str, metrics: sync::DArc) -> Result { + pub fn connect(addr: &str, metrics: sync::DArc, hello_data: &[u8]) -> Result { const UNIX: &str = "unix://"; - let sock: Box = if let Some(addr_suff) = addr.strip_prefix(UNIX) { + let mut sock: Box = if let Some(addr_suff) = addr.strip_prefix(UNIX) { Box::new(bufreaderwriter::seq::BufReaderWriterSeq::new_writer( std::os::unix::net::UnixStream::connect(std::path::Path::new(addr_suff)) .with_context(|| format!("connecting to {addr}"))?, @@ -57,6 +57,12 @@ impl Host { .with_context(|| format!("connecting to {addr}"))?, )) }; + if !hello_data.is_empty() { + sock.write_all(hello_data) + .with_context(|| format!("writing host hello to {addr}"))?; + sock.flush() + .with_context(|| format!("flushing host hello to {addr}"))?; + } Ok(Host { sock, metrics }) } } @@ -141,6 +147,10 @@ pub fn write_result_to_sock(sock: &mut dyn Sock, res: &Result) -> Re pub struct LockedSlotsSet(Box<[SlotID]>); impl LockedSlotsSet { + pub fn empty() -> Self { + Self(Box::new([])) + } + pub fn contains(&self, slot: SlotID) -> bool { self.0.binary_search(&slot).is_ok() } @@ -177,6 +187,7 @@ impl FullResult { Self { reported: genvm_modules_interfaces::ReportedResult { execution_hash: bytes::Bytes::new(), + small_hash: bytes::Bytes::new(), kind: genvm_modules_interfaces::ResultCode::InternalError, data: calldata::Value::Str(msg).into(), backtrace: None, @@ -269,9 +280,23 @@ impl FullResult { } let execution_hash = bytes::Bytes::from(sha3::Digest::finalize(hasher.0).to_vec()); + // The route-invariant half of the same outcome. A caller in another + // executor folds this, exactly as an in-process caller folds + // `RunResult::small_hash`, so the two routes agree. + let small_hash = bytes::Bytes::from( + genvm_modules_interfaces::small_hash( + convert_result_code(rt_result.kind), + &rt_result.data, + &rt_result.subvm_hashes, + &rt_result.wasm_store_hashes, + ) + .to_vec(), + ); + Self { reported: genvm_modules_interfaces::ReportedResult { execution_hash, + small_hash, data: rt_result.data, backtrace: rt_result.backtrace.map(convert_backtrace), @@ -600,6 +625,39 @@ impl Host { Ok(()) } + pub fn resolve_callcontract_executor( + &mut self, + contract_address: calldata::Address, + state_mode: StorageType, + advisory_major: u8, + ) -> Result> { + log_trace!("resolve_callcontract_executor"); + + let mut sock = self.lock_sock(); + sock.write_all(&[host_fns::Methods::ResolveCallcontractExecutor as u8])?; + sock.write_all(&contract_address.raw())?; + sock.write_all(&[state_mode as u8, advisory_major])?; + + handle_host_error(&mut **sock, "resolve_callcontract_executor")?; + let encoded = read_bytes(&mut **sock, "resolve_callcontract_executor result")?; + calldata::decode_obj(&encoded).context("decoding resolve_callcontract_executor result") + } + + pub fn run_nested( + &mut self, + envelope: &genvm_modules_interfaces::NestedRunEnvelope, + ) -> Result { + log_trace!("run_nested"); + + let encoded = calldata::encode_obj(envelope); + let mut sock = self.lock_sock(); + sock.write_all(&[host_fns::Methods::RunNested as u8])?; + write_slice(&mut **sock, &encoded)?; + + let reply = read_bytes(&mut **sock, "run_nested result")?; + calldata::decode_obj(&reply).context("decoding run_nested result") + } + pub fn consume_result(&mut self, res: &Result) -> Result<()> { log_trace!("consume_result"); @@ -628,21 +686,6 @@ impl Host { Ok(()) } - pub fn notify_finished(&mut self) -> Result<()> { - log_trace!("notify_finished"); - - let mut sock = self.lock_sock(); - sock.write_all(&[host_fns::Methods::NotifyFinished as u8])?; - sock.flush()?; - - let mut int_buf = [0; 1]; - sock.read_exact(&mut int_buf)?; - - log_debug!("notify_finished: ACK"); - - Ok(()) - } - pub fn consume_fuel(&mut self, gas: primitive_types::U256) -> Result<()> { log_trace!("consume_fuel"); @@ -712,6 +755,10 @@ impl Host { Ok(()) } + + pub fn flush(&mut self) -> Result<()> { + self.lock_sock().flush().context("flushing host socket") + } } pub struct MultiHost { @@ -735,4 +782,190 @@ impl MultiHost { }; self.hosts[idx].lock().await } + + pub async fn flush_all(&self) -> Result<()> { + for host in &self.hosts { + host.lock().await.flush()?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + use std::path::PathBuf; + use std::thread; + + fn metrics() -> sync::DArc { + sync::DArc::new(Metrics::default()) + } + + fn bind_socket(name: &str) -> (PathBuf, String, UnixListener) { + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time before epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "genvm-host-{name}-{}-{suffix}.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path).expect("bind unix listener"); + let addr = format!("unix://{}", path.display()); + (path, addr, listener) + } + + fn connect_consume_fuel_and_read(hello_data: &[u8], read_len: usize) -> Vec { + let (path, addr, listener) = bind_socket("hello"); + let reader = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept host connection"); + let mut buf = vec![0; read_len]; + stream.read_exact(&mut buf).expect("read host bytes"); + buf + }); + + let mut host = Host::connect(&addr, metrics(), hello_data).expect("connect host"); + host.consume_fuel(primitive_types::U256::from(7)) + .expect("consume fuel"); + let buf = reader.join().expect("reader thread"); + let _ = std::fs::remove_file(path); + buf + } + + #[test] + fn writes_hello_before_first_method_byte() { + let hello_data = b"hello-prefix"; + let buf = connect_consume_fuel_and_read(hello_data, hello_data.len() + 1 + 32); + + assert_eq!(&buf[..hello_data.len()], hello_data); + assert_eq!(buf[hello_data.len()], host_fns::Methods::ConsumeFuel as u8); + } + + #[test] + fn short_hello_vector_means_empty_for_missing_host_index() { + let host_hello_data = [bytes::Bytes::from_static(b"host-zero-only")]; + let hello_data = host_hello_data.get(1).map_or(&[][..], bytes::Bytes::as_ref); + let buf = connect_consume_fuel_and_read(hello_data, 1 + 32); + + assert_eq!(buf[0], host_fns::Methods::ConsumeFuel as u8); + } + + fn resolve_reply(reply: Option) -> Option { + let (path, addr, listener) = bind_socket("resolve"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept host connection"); + let mut request = [0; 23]; + stream + .read_exact(&mut request) + .expect("read resolve request"); + assert_eq!( + request[0], + host_fns::Methods::ResolveCallcontractExecutor as u8 + ); + assert_eq!(&request[1..21], &[7; 20]); + assert_eq!(request[21], StorageType::LatestFinal as u8); + assert_eq!(request[22], 3); + + let encoded = calldata::encode_obj(&reply); + stream + .write_all(&[host_fns::Errors::Ok as u8]) + .expect("write host status"); + stream + .write_all(&(encoded.len() as u32).to_le_bytes()) + .expect("write resolve length"); + stream.write_all(&encoded).expect("write resolve reply"); + }); + + let mut host = Host::connect(&addr, metrics(), &[]).expect("connect host"); + let result = host + .resolve_callcontract_executor( + calldata::Address::from([7; 20]), + StorageType::LatestFinal, + 3, + ) + .expect("resolve executor"); + server.join().expect("server thread"); + let _ = std::fs::remove_file(path); + result + } + + #[test] + fn resolve_callcontract_executor_preserves_null_and_empty_payload() { + assert_eq!(resolve_reply(None), None); + assert_eq!( + resolve_reply(Some(bytes::Bytes::new())), + Some(bytes::Bytes::new()) + ); + } + + #[test] + fn run_nested_uses_length_prefixed_calldata_frames() { + let (path, addr, listener) = bind_socket("nested"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept host connection"); + let mut method = [0; 1]; + stream.read_exact(&mut method).expect("read nested method"); + assert_eq!(method[0], host_fns::Methods::RunNested as u8); + + let mut len = [0; 4]; + stream.read_exact(&mut len).expect("read nested length"); + let mut body = vec![0; u32::from_le_bytes(len) as usize]; + stream.read_exact(&mut body).expect("read nested body"); + let envelope: genvm_modules_interfaces::NestedRunEnvelope = + calldata::decode_obj(&body).expect("decode nested envelope"); + assert_eq!( + envelope.routing_payload, + bytes::Bytes::from_static(b"route") + ); + assert_eq!(envelope.remaining_recursion, 4); + + let reply = genvm_modules_interfaces::NestedRunReply { + result: genvm_modules_interfaces::NestedRunResult { + kind: genvm_modules_interfaces::ResultCode::Return, + data: calldata::Value::Null.into(), + }, + small_hash: bytes::Bytes::from(vec![9; 32]), + effect_free: true, + }; + let encoded = calldata::encode_obj(&reply); + stream + .write_all(&(encoded.len() as u32).to_le_bytes()) + .expect("write nested length"); + stream.write_all(&encoded).expect("write nested reply"); + }); + + let envelope = genvm_modules_interfaces::NestedRunEnvelope { + routing_payload: bytes::Bytes::from_static(b"route"), + calldata: bytes::Bytes::from_static(b"call"), + message: genvm_modules_interfaces::MessageData { + contract_address: calldata::Address::zero(), + sender_address: calldata::Address::zero(), + origin_address: calldata::Address::zero(), + signer_address: calldata::Address::zero(), + chain_id: num_bigint::BigInt::ZERO, + value: num_bigint::BigInt::ZERO, + is_init: false, + datetime: chrono::DateTime::parse_from_rfc3339("2026-07-28T00:00:00Z") + .unwrap() + .to_utc(), + }, + stack: Vec::new(), + permissions: genvm_modules_interfaces::NestedPermissions::DETERMINISTIC, + state_mode: genvm_modules_interfaces::NestedStorageType::LatestNonFinal, + topmost_runner_id: genvm_modules_interfaces::NestedRunnerId("contract".to_owned()), + remaining_recursion: 4, + remaining_det_fuel: primitive_types::U256::from(10), + memory_limit: 1024, + }; + let mut host = Host::connect(&addr, metrics(), &[]).expect("connect host"); + let reply = host.run_nested(&envelope).expect("run nested"); + assert!(reply.effect_free); + assert_eq!(reply.small_hash, bytes::Bytes::from(vec![9; 32])); + + server.join().expect("server thread"); + let _ = std::fs::remove_file(path); + } } diff --git a/executor/src/lib.rs b/executor/src/lib.rs index a9cb4aa..ff794ea 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -70,6 +70,11 @@ pub struct CreateSupervisorNamedArgs { pub initial_time_units_allocation: u32, pub leader_nondet_results: Option>, pub record_actions: Vec, + pub memory_limit: Option, + /// Whether this execution holds the storage-write permission, however it + /// was granted. Slot locks bound what a run may write, so this -- not the + /// route the run arrived by -- decides whether they are read. + pub can_write_storage: bool, } pub struct ExecutionContext { @@ -120,8 +125,7 @@ pub fn create_supervisor( )), }; - // has locked slots - let limiter_det = rt::memlimiter::Limiter::new(); + let limiter_det = rt::memlimiter::Limiter::with_limit(named.memory_limit.unwrap_or(u32::MAX)); let storage_host_idx = if (host::host_fns::Methods::StorageRead as usize) < named.method_hosts.len() { @@ -130,13 +134,20 @@ pub fn create_supervisor( 0 }; - let locked_slots = hosts[storage_host_idx] - .get_locked_slots_for_sender( - calldata::Address::from(message.contract_address.raw()), - calldata::Address::from(message.sender_address.raw()), - &limiter_det, - ) - .context("reading locked slots")?; + // Slot locks only ever reject a write, so a run that cannot write does not + // need them -- and paying to read a set it can never consult would charge + // memory for nothing. + let locked_slots = if named.can_write_storage { + hosts[storage_host_idx] + .get_locked_slots_for_sender( + calldata::Address::from(message.contract_address.raw()), + calldata::Address::from(message.sender_address.raw()), + &limiter_det, + ) + .context("reading locked slots")? + } else { + host::LockedSlotsSet::empty() + }; let multi_host = host::MultiHost::new(hosts, named.method_hosts); @@ -157,13 +168,14 @@ pub fn create_supervisor( fn convert_message_data( message: genvm_modules_interfaces::MessageData, + stack: Vec, ) -> 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(), + stack, chain_id: message.chain_id, value: message.value, is_init: message.is_init, @@ -171,14 +183,40 @@ fn convert_message_data( } } +fn convert_nested_storage_type( + state_mode: genvm_modules_interfaces::NestedStorageType, +) -> public_abi::StorageType { + match state_mode { + genvm_modules_interfaces::NestedStorageType::Default => public_abi::StorageType::Default, + genvm_modules_interfaces::NestedStorageType::LatestFinal => { + public_abi::StorageType::LatestFinal + } + genvm_modules_interfaces::NestedStorageType::LatestNonFinal => { + public_abi::StorageType::LatestNonFinal + } + } +} + +fn convert_nested_permissions( + permissions: genvm_modules_interfaces::NestedPermissions, +) -> wasi::base::Permissions { + use genvm_modules_interfaces::NestedPermissions as P; + + wasi::base::Permissions { + deterministic: permissions.contains(P::DETERMINISTIC), + write_storage: permissions.contains(P::WRITE_STORAGE), + send_messages: permissions.contains(P::SEND_MESSAGES), + call_others: permissions.contains(P::CALL_OTHERS), + spawn_nondet: permissions.contains(P::SPAWN_NONDET), + register_runners: permissions.contains(P::REGISTER_RUNNERS), + can_use_balance_for_message_fees: permissions.contains(P::USE_BALANCE_FOR_MESSAGE_FEES), + } +} + fn extra_leader_output_error( supervisor: &rt::supervisor::Supervisor, run_ok: &rt::vm::RunOk, ) -> Option { - if supervisor.shared_data.run_mode == rt::RunMode::Leader { - return None; - } - let executed = supervisor .nondet_call_no .load(std::sync::atomic::Ordering::SeqCst); @@ -187,15 +225,19 @@ fn extra_leader_output_error( .as_ref() .map_or(0, |v| v.len() as u32); - if published <= executed { + if !has_extra_leader_output(supervisor.shared_data.run_mode, executed, published) { return None; } Some(rt::errors::vm_error_for_leader_extra(&run_ok.as_bytes())) } +fn has_extra_leader_output(run_mode: rt::RunMode, executed: u32, published: u32) -> bool { + run_mode != rt::RunMode::Leader && published > executed +} + pub async fn run_with_impl( - entry_data: genvm_modules_interfaces::ExecutionData, + mut entry_data: genvm_modules_interfaces::ExecutionData, context: &ExecutionContext, permissions: &str, deploy_pin: &mut Option, @@ -203,13 +245,27 @@ pub async fn run_with_impl( let supervisor = context.supervisor.clone(); let storage_pages_limit = supervisor.get_storage_limiter(); + // Everything a nested run imports arrives together or not at all, so unpack + // it once here rather than probing the same option at four call sites. + let (imported_state_mode, imported_permissions, imported_runner_id, stack) = + match entry_data.nested.take() { + Some(nested) => ( + Some(convert_nested_storage_type(nested.state_mode)), + Some(nested.permissions), + Some(nested.topmost_runner_id), + nested.stack, + ), + None => (None, None, None, Vec::new()), + }; + let storage_read_mode = imported_state_mode.unwrap_or(public_abi::StorageType::LatestNonFinal); + let mut topmost_storage = rt::vm::storage::Storage::new( entry_data.message.contract_address, storage_pages_limit, wasi::genlayer_sdk::StorageHostHolder( supervisor.host.clone(), wasi::genlayer_sdk::ReadToken { - mode: public_abi::StorageType::LatestNonFinal, + mode: storage_read_mode, account: entry_data.message.contract_address, }, ), @@ -273,11 +329,22 @@ pub async fn run_with_impl( runners::Id::Chain { address: entry_data.message.contract_address, - on: runners::ChainState::Accepted, + // No code was supplied, so nothing seeded a deploy-state cell: + // such an id could never be loaded from chain. + on: runners::ChainState::for_vm(false, storage_read_mode), slot: code_slot, } }; + let id = match &imported_runner_id { + Some(imported) => { + rt::supervisor::actions::resolve_runner_id(&supervisor, &id, &imported.0) + .await + .context("resolving imported topmost runner id")? + } + None => id, + }; + anyhow::Ok((id, root_permissions)) } .await @@ -301,32 +368,43 @@ 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 vm_permissions = match imported_permissions { + Some(imported) => convert_nested_permissions(imported), + None => 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, + }, + }; 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, + // A budget minted elsewhere is a remainder, not an authority: a chain + // root on a line with a looser limit must not buy depth this executor + // does not offer. + remaining_recursion: entry_data + .remaining_recursion + .map_or(public_abi::top_limits::VM_RECURSION, |supplied| { + supplied.min(public_abi::top_limits::VM_RECURSION) + }), 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, - 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, - }, + permissions: vm_permissions, execution: wasi::base::Execution { - state_mode: crate::public_abi::StorageType::Default, + state_mode: imported_state_mode.unwrap_or(crate::public_abi::StorageType::Default), topmost_runner_id, }, }, message_data: ExtendedMessage { - message: convert_message_data(entry_data.message), + message: convert_message_data(entry_data.message, stack), entry_kind: public_abi::EntryKind::Main, entry_data: entry_data.calldata, entry_stage_data: calldata::Value::Null, @@ -454,15 +532,24 @@ pub async fn run_with( host.consume_result(&res).context("consume result")?; } - { - let mut host = supervisor - .host - .lock_for(host::host_fns::Methods::NotifyFinished) - .await; - host.notify_finished().context("notify finished")?; - } + supervisor + .host + .flush_all() + .await + .context("flush hosts before exit")?; host::all_useful_work_done(); res } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn det_only_child_has_no_extra_leader_output() { + assert!(!has_extra_leader_output(rt::RunMode::Leader, 0, 0)); + assert!(!has_extra_leader_output(rt::RunMode::Sync, 0, 0)); + } +} diff --git a/executor/src/rt/memlimiter.rs b/executor/src/rt/memlimiter.rs index 0915e3d..fa39915 100644 --- a/executor/src/rt/memlimiter.rs +++ b/executor/src/rt/memlimiter.rs @@ -29,8 +29,12 @@ impl Default for Limiter { impl Limiter { pub fn new() -> Self { + Self::with_limit(u32::MAX) + } + + pub fn with_limit(limit: u32) -> Self { Self(Arc::new(LimiterInner { - remaining_memory: AtomicU32::new(u32::MAX), + remaining_memory: AtomicU32::new(limit), })) } @@ -163,3 +167,17 @@ impl wasmtime::ResourceLimiter for Limiter { 100 } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_spawn_limit_is_honored() { + let limiter = Limiter::with_limit(100); + assert_eq!(limiter.get_remaining_memory(), 100); + assert!(limiter.consume(40)); + assert_eq!(limiter.get_remaining_memory(), 60); + assert!(!limiter.consume(61)); + } +} diff --git a/executor/src/rt/mod.rs b/executor/src/rt/mod.rs index a507d10..c6be5ee 100644 --- a/executor/src/rt/mod.rs +++ b/executor/src/rt/mod.rs @@ -53,6 +53,38 @@ pub enum RunMode { Validator, } +pub fn infer_run_mode(sync: bool, leader_nondet_results: &Option>) -> RunMode { + if sync { + RunMode::Sync + } else if leader_nondet_results.is_none() { + RunMode::Leader + } else { + RunMode::Validator + } +} + +pub struct DetFuelBudget(tokio::sync::Mutex>); + +impl DetFuelBudget { + pub fn new(initial: Option) -> Self { + Self(tokio::sync::Mutex::new(initial)) + } + + async fn remaining(&self, host_remaining: primitive_types::U256) -> primitive_types::U256 { + match *self.0.lock().await { + Some(imported) => imported.min(host_remaining), + None => host_remaining, + } + } + + async fn consume(&self, consumed: primitive_types::U256) { + let mut budget = self.0.lock().await; + if let Some(remaining) = budget.as_mut() { + *remaining = remaining.saturating_sub(consumed); + } + } +} + /// basic data that is shared across all VMs pub struct SharedData { pub run_mode: RunMode, @@ -60,9 +92,23 @@ pub struct SharedData { pub debug_mode: genvm_common::DebugMode, pub metrics: crate::Metrics, pub data_fees_limit: fees::DataLimit, + pub det_fuel_budget: DetFuelBudget, pub llm_consumption: tokio::sync::Mutex, } +impl SharedData { + pub async fn remaining_det_fuel( + &self, + host_remaining: primitive_types::U256, + ) -> primitive_types::U256 { + self.det_fuel_budget.remaining(host_remaining).await + } + + pub async fn consume_det_fuel(&self, consumed: primitive_types::U256) { + self.det_fuel_budget.consume(consumed).await; + } +} + pub fn parse_host_data( zelf: &genvm_modules_interfaces::ExecutionData, ) -> anyhow::Result { @@ -111,6 +157,31 @@ async fn spawn_apply_run_inner( vm.run().await } +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn imported_deterministic_fuel_is_the_initial_budget() { + let budget = DetFuelBudget::new(Some(primitive_types::U256::from(10))); + assert_eq!( + budget.remaining(primitive_types::U256::from(20)).await, + primitive_types::U256::from(10) + ); + budget.consume(primitive_types::U256::from(3)).await; + assert_eq!( + budget.remaining(primitive_types::U256::from(20)).await, + primitive_types::U256::from(7) + ); + } + + #[test] + fn nested_sync_run_does_not_infer_leader_mode() { + assert_eq!(infer_run_mode(true, &None), RunMode::Sync); + assert_eq!(infer_run_mode(false, &None), RunMode::Leader); + } +} + use anyhow::Context; use genvm_common::log_debug; diff --git a/executor/src/rt/supervisor/actions.rs b/executor/src/rt/supervisor/actions.rs index 597a5bf..711ddb7 100644 --- a/executor/src/rt/supervisor/actions.rs +++ b/executor/src/rt/supervisor/actions.rs @@ -174,7 +174,11 @@ pub(crate) async fn resolve_runner_id( let hash_gvm32 = hash.to_gvm32(); if !supervisor.runner_cache.has_in_all(&name, &hash_gvm32) { - anyhow::bail!("runner {}:{} not found", name, hash_gvm32); + // A contract picks this pair, so an uninstalled one must reach it + // as a canonical error rather than as an internal abort. + return Err(make_malformed_runner_error(&format!( + "runner `{name}:{hash_gvm32}` is not installed" + ))); } runners::Id::Builtin { @@ -782,6 +786,17 @@ impl Ctx<'_, '_> { return Ok(None); }; + // `deserialize_file` below trusts the file as native code without looking + // at the wasm it was built from, so only ids the precompiler can actually + // have written may derive a path here. `precompile` only ever emits + // artifacts for runners listed in `all.json`. Every other id must fall + // through to a normal compile instead of picking up whatever happens to + // sit at that path, notably `custom:`, which an attacker chooses + // via RegisterRunner and which parses as a plain `name:hash` pair. + if !self.supervisor.runner_cache.has_in_all(id, hash) { + return Ok(None); + } + let special_name = caching::path_in_zip_to_hash(path); let Some(cache_dir) = &self.supervisor.wasm_mod_cache.cache_dir else { return Ok(None); @@ -801,7 +816,7 @@ impl Ctx<'_, '_> { cache_dir.set_extension(caching::DET_NON_DET_PRECOMPILED_SUFFIX.non_det); let non_det_mod = cache_dir; - if !det_mod.exists() { + if !non_det_mod.exists() { return Ok(None); } diff --git a/executor/src/rt/supervisor/mod.rs b/executor/src/rt/supervisor/mod.rs index 9778592..50770ce 100644 --- a/executor/src/rt/supervisor/mod.rs +++ b/executor/src/rt/supervisor/mod.rs @@ -393,7 +393,7 @@ pub async fn spawn( zelf: &Arc, vm: Box, ) -> std::result::Result, rt::SpawnError> { - if vm.depth >= public_abi::top_limits::VM_RECURSION { + if vm.remaining_recursion == 0 { return Err(rt::SpawnError { error: rt::errors::Error::vm(public_abi::VmError::out_of().vm_recursion()).into(), state: Box::new(rt::SpawnErrorState::Unspawned(vm)), @@ -416,7 +416,7 @@ pub async fn spawn( "vm_spawn", BTreeMap::from([ ("spawn_kind".to_owned(), vm.spawn_kind.clone()), - ("depth".to_owned(), vm.depth.to_string()), + ("depth".to_owned(), vm.depth().to_string()), ( "runner_id".to_owned(), vm.conf.execution.topmost_runner_id.to_string(), diff --git a/executor/src/rt/vm/mod.rs b/executor/src/rt/vm/mod.rs index 7e70e82..4d2c196 100644 --- a/executor/src/rt/vm/mod.rs +++ b/executor/src/rt/vm/mod.rs @@ -1,6 +1,5 @@ use crate::{domain, public_abi, rt, wasi}; -use genlayer_calldata::codec::Encode; use genlayer_sdk::abi; use genvm_common::*; use itertools::Itertools; @@ -304,46 +303,32 @@ impl calldata::Writer for &mut Sha3Writer { } impl RunResult { - fn small_hash_impl(&self) -> std::result::Result<[u8; 32], std::convert::Infallible> { - use sha3::Digest; - - let mut hasher = Sha3Writer(sha3::Sha3_256::new()); + /// Fingerprint an in-process caller folds for this child. + /// + /// Deliberately the same definition a caller in another executor folds for a + /// child reached over the nested protocol (see + /// [`genvm_modules_interfaces::small_hash`]) -- if the two ever drift, the + /// same call hashes differently depending on how the host routed it. + pub fn small_hash(&self) -> [u8; 32] { + use genvm_modules_interfaces::{small_hash, ResultCode}; + use sha3::Digest as _; - let mut enc = calldata::Encoder::new(&mut hasher); + let subvm_hashes = self.vm_data.det_subvm_hashes.clone().finalize(); + let wasm_store_hashes = &self.wasm_store_hashes; - enc.start_map(4)?; - enc.push_map_k("kind")?; - match &self.run_ok { - RunOk::Return(_) => enc.push_str("Return")?, - RunOk::UserError(_) => enc.push_str("UserError")?, - RunOk::VMError(_, _) => enc.push_str("VMError")?, - } - enc.push_map_k("result")?; match &self.run_ok { RunOk::Return(buf) => { - buf.encode(&mut enc)?; + small_hash(ResultCode::Return, buf, &subvm_hashes, wasm_store_hashes) } RunOk::UserError(buf) => { - buf.encode(&mut enc)?; - } - RunOk::VMError(data, _) => { - enc.push_str(&data.0)?; + small_hash(ResultCode::UserError, buf, &subvm_hashes, wasm_store_hashes) } - } - - enc.push_map_k("subvm_hashes")?; - enc.push_bytes(&self.vm_data.det_subvm_hashes.clone().finalize())?; - - enc.push_map_k("wasm_store_hashes")?; - self.wasm_store_hashes.encode(&mut enc)?; - - Ok(hasher.0.finalize().into()) - } - - pub fn small_hash(&self) -> [u8; 32] { - match self.small_hash_impl() { - Ok(hash) => hash, - Err(e) => match e {}, + RunOk::VMError(data, _) => small_hash( + ResultCode::VmError, + &calldata::Value::Str(data.0.to_string()), + &subvm_hashes, + wasm_store_hashes, + ), } } } diff --git a/executor/src/rt/vm/storage.rs b/executor/src/rt/vm/storage.rs index a75462f..436803f 100644 --- a/executor/src/rt/vm/storage.rs +++ b/executor/src/rt/vm/storage.rs @@ -478,6 +478,11 @@ impl Storage { /// is returned so the caller surfaces it as a contract error. pub async fn check_major_and_resolve_code_slot(&mut self) -> rt::errors::Result { let contract_major = self.read_major().await?; + Self::check_major(contract_major)?; + self.resolve_code_slot().await + } + + pub fn check_major(contract_major: u8) -> rt::errors::Result<()> { let node_major = genvm_common::version::CURRENT.major; if contract_major as u16 != node_major { return Err(rt::errors::Error::wrap( @@ -485,7 +490,16 @@ impl Storage { anyhow::anyhow!("contract major {contract_major} != node major {node_major}"), )); } - self.resolve_code_slot().await + Ok(()) + } + + /// Reads the advisory major and resolves the code slot without enforcing + /// compatibility. Callers must either delegate the contract or perform the + /// major check before using the returned slot in this executor. + pub async fn read_major_and_resolve_code_slot(&mut self) -> rt::errors::Result<(u8, SlotID)> { + let contract_major = self.read_major().await?; + let code_slot = self.resolve_code_slot().await?; + Ok((contract_major, code_slot)) } /// Reads the 4-byte little-endian length prefix of the code blob at @@ -611,6 +625,23 @@ mod tests { assert!(storage.read_code_blob(slot, 0).await.unwrap().is_empty()); } + #[tokio::test] + async fn advisory_major_is_returned_with_resolved_code_slot() { + let expected_slot = SlotID::from_bytes([0x5a; 32]); + let mut root = vec![0; (root_offsets::CODE_SLOT + SlotID::SIZE) as usize]; + root[root_offsets::MAJOR as usize] = 2; + root[root_offsets::CODE_SLOT as usize..].copy_from_slice(&expected_slot.raw()); + let mut storage = Storage::new( + calldata::Address::zero(), + data_fees(u64::MAX), + FakeHost(std::sync::Arc::new(root)), + ); + + let (major, slot) = storage.read_major_and_resolve_code_slot().await.unwrap(); + assert_eq!(major, 2); + assert_eq!(slot, expected_slot); + } + // -- page id ordering ------------------------------------------------ #[test] diff --git a/executor/src/wasi/genlayer_sdk/message.rs b/executor/src/wasi/genlayer_sdk/message.rs index dac6a1d..b3bf664 100644 --- a/executor/src/wasi/genlayer_sdk/message.rs +++ b/executor/src/wasi/genlayer_sdk/message.rs @@ -668,7 +668,7 @@ impl ContextVFS<'_> { }); log_debug!( - depth = self.context.data.depth, + depth = self.context.data.depth(), emissions_total = self.context.data.accumulator.emissions.len(); "PostMessage emission pushed to accumulator" ); diff --git a/executor/src/wasi/genlayer_sdk/mod.rs b/executor/src/wasi/genlayer_sdk/mod.rs index e0e9d2b..f453e63 100644 --- a/executor/src/wasi/genlayer_sdk/mod.rs +++ b/executor/src/wasi/genlayer_sdk/mod.rs @@ -140,7 +140,10 @@ impl VMDataAccumulator { pub struct SingleVMData { pub conf: base::Config, pub limiter: rt::memlimiter::Limiter, - pub depth: u32, + /// Recursion budget left to this VM and everything it spawns, inherited + /// from the chain root rather than counted up from zero, so a chain that + /// crosses executor lines keeps the bound its root minted. + pub remaining_recursion: u32, pub spawn_kind: String, pub message_data: ExtendedMessage, pub supervisor: Arc, @@ -155,6 +158,15 @@ pub struct SingleVMData { pub granted_custom: Vec, } +impl SingleVMData { + /// How deep this VM sits, for logs and recorded actions only. Exact while + /// the chain stays on one line; an approximation once it crosses into a + /// line whose own limit differs. + pub fn depth(&self) -> u32 { + public_abi::top_limits::VM_RECURSION.saturating_sub(self.remaining_recursion) + } +} + pub struct Context { pub data: SingleVMData, @@ -872,7 +884,7 @@ impl ContextVFS<'_> { return Err(generated::types::Errno::Inval.into()); } - let remaining_fuel_as_gen = self + let host_remaining_fuel = self .context .data .supervisor @@ -881,6 +893,13 @@ impl ContextVFS<'_> { .await .remaining_fuel_as_gen() .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + let remaining_fuel_as_gen = self + .context + .data + .supervisor + .shared_data + .remaining_det_fuel(host_remaining_fuel) + .await; let sup = self.context.data.supervisor.clone(); @@ -907,6 +926,7 @@ impl ContextVFS<'_> { .lock_for(host::host_fns::Methods::ConsumeFuel) .await .consume_fuel(result.consumed_gen)?; + sup.shared_data.consume_det_fuel(result.consumed_gen).await; if result.consumed_gen == primitive_types::U256::MAX { return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); @@ -940,7 +960,7 @@ impl ContextVFS<'_> { ); // Get remaining fuel from host - let remaining_fuel_as_gen = self + let host_remaining_fuel = self .context .data .supervisor @@ -949,6 +969,13 @@ impl ContextVFS<'_> { .await .remaining_fuel_as_gen() .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + let remaining_fuel_as_gen = self + .context + .data + .supervisor + .shared_data + .remaining_det_fuel(host_remaining_fuel) + .await; let sup = self.context.data.supervisor.clone(); let task = taskify(async move { @@ -969,6 +996,7 @@ impl ContextVFS<'_> { .lock_for(host::host_fns::Methods::ConsumeFuel) .await .consume_fuel(*consumed_gen)?; + sup.shared_data.consume_det_fuel(*consumed_gen).await; if *consumed_gen == primitive_types::U256::MAX { return Err(rt::errors::Error::vm(abi::consts::VmError::timeout()).into()); } diff --git a/executor/src/wasi/genlayer_sdk/run.rs b/executor/src/wasi/genlayer_sdk/run.rs index 89c337c..e07c099 100644 --- a/executor/src/wasi/genlayer_sdk/run.rs +++ b/executor/src/wasi/genlayer_sdk/run.rs @@ -129,6 +129,82 @@ struct RunNondetGetVMTaskArgs { call_no: u32, } +#[derive(Debug, PartialEq, Eq)] +pub(super) enum CallContractRoute { + InProcess, + Nested(bytes::Bytes), +} + +pub(super) fn call_contract_route( + routing_payload: Option, + advisory_major: u8, +) -> CallContractRoute { + if let Some(payload) = routing_payload { + return CallContractRoute::Nested(payload); + } + // The host declined to place the callee. A major this line does not serve + // is still not ours to reject: the manager owns the mapping from a major to + // an executor line, so hand the call over and let it answer. + if rt::vm::storage::Storage::::check_major(advisory_major).is_err() { + let payload = calldata::encode_obj(&genvm_modules_interfaces::NestedRunRouting { + major: advisory_major as u32, + }); + return CallContractRoute::Nested(payload.into()); + } + CallContractRoute::InProcess +} + +pub(super) fn derive_call_contract_permissions(parent: &base::Permissions) -> base::Permissions { + base::Permissions { + deterministic: true, + write_storage: false, + spawn_nondet: false, + call_others: parent.call_others, + send_messages: false, + register_runners: parent.register_runners, + can_use_balance_for_message_fees: false, + } +} + +fn cross_major_internal(operation: &str, error: impl std::fmt::Display) -> generated::types::Error { + generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::internal(format!("{operation}: {error}")).into(), + )) +} + +pub(super) fn nested_run_ok( + reply: genvm_modules_interfaces::NestedRunReply, +) -> anyhow::Result<(rt::vm::RunOk, bytes::Bytes)> { + anyhow::ensure!( + reply.effect_free, + "nested CallContract result contains effects" + ); + anyhow::ensure!( + reply.small_hash.len() == 32, + "nested CallContract small hash has length {}, expected 32", + reply.small_hash.len() + ); + + let run_ok = match reply.result.kind { + genvm_modules_interfaces::ResultCode::Return => rt::vm::RunOk::Return(reply.result.data), + genvm_modules_interfaces::ResultCode::UserError => { + rt::vm::RunOk::UserError(reply.result.data) + } + genvm_modules_interfaces::ResultCode::VmError => { + let data = reply.result.data.materialize()?; + let calldata::Value::Str(code) = data else { + anyhow::bail!("nested CallContract VM error is not a string"); + }; + rt::vm::RunOk::VMError(public_abi::VmError(std::borrow::Cow::Owned(code)), None) + } + genvm_modules_interfaces::ResultCode::InternalError => { + anyhow::bail!("nested executor returned an internal error"); + } + }; + + Ok((run_ok, reply.small_hash)) +} + impl ContextVFS<'_> { pub(super) async fn gl_call_eth_call( &mut self, @@ -166,7 +242,7 @@ impl ContextVFS<'_> { let vm_data = Box::new(SingleVMData { limiter: Default::default(), - depth: self.context.data.depth + 1, + remaining_recursion: self.context.data.remaining_recursion.saturating_sub(1), spawn_kind: "run_nondet".to_owned(), // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst conf: base::Config { @@ -203,66 +279,30 @@ impl ContextVFS<'_> { } } - pub(super) async fn gl_call_contract( - &mut self, + fn derive_call_contract_vm_data( + &self, address: calldata::Address, - calldata: abi::entry::MainCallData, - 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()); - } - + calldata: &abi::entry::MainCallData, + state: public_abi::StorageType, + code_slot: SlotID, + child_storage: rt::vm::storage::Storage, + ) -> Box { let supervisor = self.context.data.supervisor.clone(); - let my_conf = self.context.data.conf.clone(); - let mut my_data = self.context.data.message_data.fork( public_abi::EntryKind::Main, - calldata::encode_obj(&calldata).into(), + calldata::encode_obj(calldata).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 { + Box::new(SingleVMData { limiter: self.context.limiter.derived(), - depth: self.context.data.depth + 1, + remaining_recursion: self.context.data.remaining_recursion.saturating_sub(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, - }, + permissions: derive_call_contract_permissions(&my_conf.permissions), execution: base::Execution { state_mode: state, topmost_runner_id: runners::Id::Chain { @@ -293,7 +333,7 @@ impl ContextVFS<'_> { entry_stage_data: default_entry_stage_data(), }, storage: child_storage, - supervisor: supervisor.clone(), + supervisor, accumulator: VMDataAccumulator { data_fees_limit: self.context.data.accumulator.data_fees_limit.clone(), messages_value_decremented: primitive_types::U256::zero(), @@ -304,23 +344,182 @@ impl ContextVFS<'_> { // A CallContract child is granted the caller's full custom set; // its spawn load-actions each into the child. granted_custom: self.context.loaded.custom_pins(), - }); + }) + } - let res = spawn_sub_vm(supervisor.clone(), vm_data) + async fn run_nested_call_contract( + &mut self, + routing_payload: bytes::Bytes, + vm_data: Box, + ) -> Result { + use genvm_modules_interfaces::{ + NestedPermissions as P, NestedRunEnvelope, NestedRunnerId, NestedStorageType, + }; + + let host_remaining_fuel = vm_data + .supervisor + .host + .lock_for(host::host_fns::Methods::RemainingFuelAsGen) .await - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + .remaining_fuel_as_gen() + .map_err(|e| cross_major_internal("reading nested deterministic fuel", e))?; + let remaining_det_fuel = vm_data + .supervisor + .shared_data + .remaining_det_fuel(host_remaining_fuel) + .await; - // 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 mut permissions = P::READ_STORAGE; + if vm_data.conf.permissions.deterministic { + permissions |= P::DETERMINISTIC; + } + if vm_data.conf.permissions.write_storage { + permissions |= P::WRITE_STORAGE; + } + if vm_data.conf.permissions.send_messages { + permissions |= P::SEND_MESSAGES; + } + if vm_data.conf.permissions.call_others { + permissions |= P::CALL_OTHERS; + } + if vm_data.conf.permissions.spawn_nondet { + permissions |= P::SPAWN_NONDET; + } + if vm_data.conf.permissions.register_runners { + permissions |= P::REGISTER_RUNNERS; + } + if vm_data.conf.permissions.can_use_balance_for_message_fees { + permissions |= P::USE_BALANCE_FOR_MESSAGE_FEES; + } + + let state_mode = match vm_data.conf.execution.state_mode { + public_abi::StorageType::Default => NestedStorageType::Default, + public_abi::StorageType::LatestFinal => NestedStorageType::LatestFinal, + public_abi::StorageType::LatestNonFinal => NestedStorageType::LatestNonFinal, + }; + let message = &vm_data.message_data.message; + let envelope = NestedRunEnvelope { + routing_payload, + calldata: vm_data.message_data.entry_data.clone(), + message: genvm_modules_interfaces::MessageData { + contract_address: message.contract_address, + sender_address: message.sender_address, + origin_address: message.origin_address, + signer_address: message.signer_address, + chain_id: message.chain_id.clone(), + value: message.value.clone(), + is_init: message.is_init, + datetime: message.datetime, + }, + stack: message.stack.clone(), + permissions, + state_mode, + topmost_runner_id: NestedRunnerId("contract".to_owned()), + remaining_recursion: vm_data.remaining_recursion, + remaining_det_fuel, + memory_limit: vm_data.limiter.get_remaining_memory(), + }; - let hash = res.small_hash(); - self.context.data.det_subvm_hashes.update(&hash); + let reply = vm_data + .supervisor + .host + .lock_for(host::host_fns::Methods::RunNested) + .await + .run_nested(&envelope) + .map_err(|e| cross_major_internal("running nested executor", e))?; + let (run_ok, small_hash) = + nested_run_ok(reply).map_err(|e| cross_major_internal("reading nested result", e))?; + + self.context.data.det_subvm_hashes.update(&small_hash); + self.set_vm_run_result(run_ok).map(|x| x.0) + } - self.set_vm_run_result(res.run_ok).map(|x| x.0) + pub(super) async fn gl_call_contract( + &mut self, + address: calldata::Address, + calldata: abi::entry::MainCallData, + 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(); + + if state == public_abi::StorageType::Default { + state = self.context.data.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 (advisory_major, code_slot) = child_storage + .read_major_and_resolve_code_slot() + .await + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; + let routing_payload = supervisor + .host + .lock_for(host::host_fns::Methods::ResolveCallcontractExecutor) + .await + .resolve_callcontract_executor(address, state, advisory_major) + .map_err(|e| cross_major_internal("resolving CallContract executor", e))?; + let route = call_contract_route(routing_payload, advisory_major); + let vm_data = + self.derive_call_contract_vm_data(address, &calldata, state, code_slot, child_storage); + + match route { + CallContractRoute::Nested(routing_payload) => { + if vm_data.remaining_recursion == 0 { + return Err(internal_trap(rt::errors::Error::vm( + public_abi::VmError::out_of().vm_recursion(), + ))); + } + // Custom runners are process-local: the envelope carries no + // archives, so a callee in another executor could not load them + // and would silently run with an empty set. Refuse the call + // instead, so the caller sees a canonical error rather than a + // child that resolves `custom:` ids differently per route. + if !vm_data.granted_custom.is_empty() { + return Err(generated::types::Errno::Inval.into()); + } + self.run_nested_call_contract(routing_payload, vm_data) + .await + } + CallContractRoute::InProcess => { + rt::vm::storage::Storage::::check_major(advisory_major) + .map_err(|e| { + generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())) + })?; + + let res = spawn_sub_vm(supervisor, 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( @@ -495,7 +694,7 @@ impl ContextVFS<'_> { let vm_data = Box::new(SingleVMData { limiter: self.context.limiter.derived(), - depth: self.context.data.depth + 1, + remaining_recursion: self.context.data.remaining_recursion.saturating_sub(1), spawn_kind: "sandbox".to_owned(), // Permission model: docs/website/src/spec/03-vm/02-meta-properties.rst conf: base::Config { diff --git a/executor/src/wasi/genlayer_sdk/tests.rs b/executor/src/wasi/genlayer_sdk/tests.rs index ae66837..01df305 100644 --- a/executor/src/wasi/genlayer_sdk/tests.rs +++ b/executor/src/wasi/genlayer_sdk/tests.rs @@ -1,5 +1,8 @@ use super::message::{validate_balance_fee, FEE_PARAM_COUNT_BITS, FEE_PARAM_PRICE_BITS}; -use super::run::{parse_leader_result, strip_vm_error_detail}; +use super::run::{ + call_contract_route, derive_call_contract_permissions, nested_run_ok, parse_leader_result, + strip_vm_error_detail, CallContractRoute, +}; use super::*; use primitive_types::U256; @@ -113,6 +116,67 @@ fn no_balance_no_params_is_allocation_path() { assert_eq!(got, None); } +#[test] +fn non_null_resolve_selects_nested_path_before_local_spawn() { + let payload = bytes::Bytes::from_static(b"route"); + let ours = genvm_common::version::CURRENT.major as u8; + assert_eq!( + call_contract_route(Some(payload.clone()), ours), + CallContractRoute::Nested(payload) + ); + assert_eq!( + call_contract_route(None, ours), + CallContractRoute::InProcess + ); +} + +#[test] +fn a_major_this_line_does_not_serve_goes_to_the_manager() { + let unservable = genvm_common::version::CURRENT.major as u8 + 1; + let CallContractRoute::Nested(payload) = call_contract_route(None, unservable) else { + panic!("a major this line cannot serve must not stay in-process"); + }; + let routing: genvm_modules_interfaces::NestedRunRouting = + calldata::decode_obj(&payload).unwrap(); + assert_eq!(routing.major, unservable as u32); +} + +#[test] +fn call_contract_child_is_deterministic_only() { + let parent = base::Permissions { + deterministic: true, + write_storage: true, + send_messages: true, + call_others: true, + spawn_nondet: true, + register_runners: true, + can_use_balance_for_message_fees: true, + }; + let child = derive_call_contract_permissions(&parent); + + assert!(child.deterministic); + assert!(child.call_others); + assert!(child.register_runners); + assert!(!child.spawn_nondet); + assert!(!child.write_storage); + assert!(!child.send_messages); + assert!(!child.can_use_balance_for_message_fees); +} + +#[test] +fn nested_result_must_be_effect_free() { + let reply = genvm_modules_interfaces::NestedRunReply { + result: genvm_modules_interfaces::NestedRunResult { + kind: genvm_modules_interfaces::ResultCode::Return, + data: calldata::Value::Null.into(), + }, + small_hash: bytes::Bytes::from(vec![1; 32]), + effect_free: false, + }; + + assert!(nested_run_ok(reply).is_err()); +} + // --------------------------------------------------------------------------- // Leader-proposed nondet result validation. // diff --git a/executor/src/wasi/mod.rs b/executor/src/wasi/mod.rs index 727ca27..98d8854 100644 --- a/executor/src/wasi/mod.rs +++ b/executor/src/wasi/mod.rs @@ -48,86 +48,6 @@ impl Context { } } -#[cfg(any())] -fn add_to_linker_sync_dlsym( - linker: &mut wasmtime::Linker, - linker_shared: Arc>>, -) -> anyhow::Result<()> { - use core::str; - - linker.func_wrap( - "genlayer_dl", - "dlsym", - move |mut caller: wasmtime::Caller<'_, _>, - mod_name: u32, - mod_name_len: u32, - func_name: u32, - func_name_len: u32| - -> wiggle::anyhow::Result { - let export = caller.get_export("memory"); - let mem = match &export { - Some(wiggle::wasmtime_crate::Extern::Memory(m)) => { - let (mem, _ctx) = m.data_and_store_mut(&mut caller); - wiggle::GuestMemory::Unshared(mem) - } - Some(wiggle::wasmtime_crate::Extern::SharedMemory(m)) => { - wiggle::GuestMemory::Shared(m.data()) - } - _ => { - return anyhow::__private::Err({ - let error = anyhow::__private::format_err(anyhow::__private::format_args!( - "missing required memory export" - )); - error - }) - } - }; - - let mod_name_ptr: wiggle::GuestPtr<[u8]> = - wiggle::GuestPtr::new(mod_name).as_array(mod_name_len); - let func_name_ptr: wiggle::GuestPtr<[u8]> = - wiggle::GuestPtr::new(func_name).as_array(func_name_len); - - let mod_name = Vec::from_iter(mem.as_cow(mod_name_ptr)?.into_iter().cloned()); - let mod_name = str::from_utf8(&mod_name)?; - - let mod_name = match mod_name.rfind('/') { - Some(i) => &mod_name[i + 1..], - _ => mod_name, - }; - - if mod_name.is_empty() { - anyhow::bail!("can't load from unnamed"); - } - - let func_name = Vec::from_iter(mem.as_cow(func_name_ptr)?.into_iter().cloned()); - let func_name = str::from_utf8(&func_name)?; - - log_trace!(target: "rt", module = mod_name, function = func_name; "dlsym called"); - - let linker_shared = linker_shared.clone(); - let Ok(ref mut linker) = linker_shared.lock() else { - panic!("dlsym: linker mutex poisoned"); - }; - - let fn_exported = linker - .get(&mut caller, mod_name, func_name) - .ok_or_else(|| anyhow::anyhow!("function entity not found"))? - .into_func() - .ok_or_else(|| anyhow::anyhow!("found entity is not a function"))?; - - let table = caller - .get_export("__indirect_function_table") - .ok_or_else(|| anyhow::anyhow!("__indirect_function_table not found"))? - .into_table() - .ok_or_else(|| anyhow::anyhow!("no __indirect_function_table"))?; - let res = table.grow(&mut caller, 1, fn_exported.into())?; - Ok(res.try_into()?) - }, - )?; - Ok(()) -} - pub(super) fn add_to_linker_sync( linker: &mut wasmtime::Linker, f: impl Fn(&mut T) -> &mut Context + Copy + Send + Sync + 'static, diff --git a/executor/tests/precompile_lookup.rs b/executor/tests/precompile_lookup.rs new file mode 100644 index 0000000..00dc836 --- /dev/null +++ b/executor/tests/precompile_lookup.rs @@ -0,0 +1,62 @@ +//! The precompile cache is read with `Module::deserialize_file`, which trusts the +//! file as native code. Only runners the precompiler actually writes artifacts +//! for, the ones listed in `all.json`, may derive a path into it. + +use genvm::runners; + +const HASH: &str = "cnn3rjeozkptmzmzt4ymzznvkqfdedcvpapscmyt6r6cyzjrzsgq"; + +fn unique_dir(tag: &str) -> std::path::PathBuf { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("genvm-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&path).unwrap(); + path +} + +/// A registry holding exactly one builtin runner, `py-genlayer:HASH`. +fn registry_with_one_builtin() -> (std::path::PathBuf, runners::cache::Reader) { + let root = unique_dir("registry"); + let runners_dir = root.join("runners"); + std::fs::create_dir_all(&runners_dir).unwrap(); + std::fs::write( + root.join("all.json"), + format!(r#"{{"py-genlayer": ["{HASH}"]}}"#), + ) + .unwrap(); + + let reader = runners::cache::Reader::new(&runners_dir, &root, false).unwrap(); + (root, reader) +} + +#[test] +fn verify_runner_alone_accepts_custom_ids() { + // This is why the registry lookup exists: `custom:`, an id an attacker + // picks via RegisterRunner, is a well-formed `name:hash` pair, so the charset + // check waves it through. `chain:` and `contract` are excluded only + // incidentally, by their colon count. + assert_eq!( + runners::verify_runner(&format!("custom:{HASH}")), + Some(("custom", HASH)), + ); +} + +#[test] +fn custom_runner_is_not_in_the_registry() { + let (root, reader) = registry_with_one_builtin(); + + assert!( + reader.has_in_all("py-genlayer", HASH), + "the builtin the registry lists must be found", + ); + assert!( + !reader.has_in_all("custom", HASH), + "a custom: runner must never resolve a precompiled artifact", + ); + assert!( + !reader.has_in_all("py-genlayer", "not-the-listed-hash"), + "a builtin name with an unlisted hash must not resolve either", + ); + + std::fs::remove_dir_all(&root).unwrap(); +} diff --git a/flake.nix b/flake.nix index e4dd7bf..7bfd798 100644 --- a/flake.nix +++ b/flake.nix @@ -1,9 +1,9 @@ { ############################################################################## # # - # ⚠ DEV SHELL ONLY — NO PACKAGING HERE. ⚠ # + # ⚠ DEV SHELL ONLY — NO PACKAGING HERE. ⚠ # # # - # This flake exists ONLY to provide the developer shell and the # + # 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. # # # diff --git a/support/scripts/check-source-text.py b/support/scripts/check-source-text.py index bf975a9..e2ab8eb 100644 --- a/support/scripts/check-source-text.py +++ b/support/scripts/check-source-text.py @@ -32,6 +32,12 @@ re.compile(chr(0x2500)), # box-drawing horizontal (use -) re.compile(chr(0x2192)), # rightwards arrow (use ->) re.compile(chr(0x2026)), # ellipsis (use ...) + # Mutating the environment of a live process is a data race against every + # getenv in it, including the ones inside libc and third-party code. + re.compile(r'\benv::(?:set_var|remove_var)\b'), + re.compile(r'\blibc::(?:setenv|unsetenv|putenv)\b'), + # ... including when imported, which hides the `env::` prefix at the call. + re.compile(r'\buse\s+(?:std::)?env::\{?[^;]*\b(?:set_var|remove_var)\b'), ] diff --git a/tests/integration/claude/AGENT_PROMPT.md b/tests/integration/claude/AGENT_PROMPT.md deleted file mode 100644 index e0cc67e..0000000 --- a/tests/integration/claude/AGENT_PROMPT.md +++ /dev/null @@ -1,54 +0,0 @@ -You are security testing a blockchain VM named GenVM. This VM is capable of non-deterministic operations. -You can explore codebase, it is entirely present here - -## Strategy - -1. Explore which wasm instructions, WASI function calls and so on can lead to non-determinism -2. Write a test suite that potentially exploits it -3. Run it - -Please note that LLM and WEB access is "disabled" for the feedback speed (it will return error to non-deterministic block) - -use your own `tests/cases/stable/claude/agent/` directory for temporary files - -## Permanent files - -1. Keep track of your explored paths in `tests/cases/stable/claude/intelligence/EXPLORED_PATHS.md` -2. Pick tasks before starting and dump future tasks before finishing to `tests/cases/stable/claude/intelligence/TODO.md` -3. If you find deterministic violation (hash mismatch) or an internal GenVM error (not mock host one), instantly copy it to `tests/cases/stable/claude/discoveries//` after that add README.md there with an explanation and stop. Timeout error is not an error. Wasm trap is not an error - -## Writing tests - -Look at `tests/cases/stable/claude/example/` for a reference test. Each test has: -- One or more `.py` contract files with `# { "Depends": "py-genlayer:test" }` header -- A `.jsonnet` file describing the test scenario (deploy, call methods, etc.) - -Templates are in `tests/templates/`. Use `util.jsonnet` (`addPaths`, `chain`) to structure multi-step tests. See `message.json` for the base message format. - -For all test transactions specify: -1. `expected_semantics_components: []` -2. `modes: 'lvs'` -3. `stable_hash: false` - -For top-level test object specify `tags: ["fuzz"]` - -You can write tests in rust, if you find it necessary. See tests/cases/unstable/nondet/web/fetch_webpage_rust for an example - -## Running tests - -```bash -# from repo root, run a specific test: -./tests/cases/stable/claude/run_test.py - -# example: -./tests/cases/stable/claude/run_test.py example - -# this runs: ya-test-runner --filter-tag integration --filter-name 'tests/cases/stable/claude/' run --no-manager -# it assumes a manager is already running on port 3999 - -# Output lives in directories like -ls build/test-artifacts/integration/stable/claude/example/ - -# pattern is akin to -# build/test-artifacts/integration/stable/claude///{0l,0v,0s}/ -``` diff --git a/tests/integration/claude/README.md b/tests/integration/claude/README.md index dc94070..1401a70 100644 --- a/tests/integration/claude/README.md +++ b/tests/integration/claude/README.md @@ -1,6 +1,10 @@ +# Agentic fuzzing -```bash -./tests/cases/claude/run-manager.sh +What survives between fuzzing sessions: -claude --settings .claude/settings.fuzzing.json -``` +- `intelligence/` — what has been explored and what was ruled out +- `example/` — the reference shape for a probe + +Probes themselves are throwaway and live in `_scratch/`, which is gitignored. +The procedure is the manager's `agentic-fuzzing` skill +(`.claude/skills/agentic-fuzzing/SKILL.md`). diff --git a/tests/integration/claude/agent/float_math/README.md b/tests/integration/claude/agent/float_math/README.md deleted file mode 100644 index 00f6898..0000000 --- a/tests/integration/claude/agent/float_math/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# float_math probe - -Determinism probe for floating-point / transcendental math (`sin`, `cos`, `exp`, -`sqrt`, `log`, `atan2`, `**`) plus `repr()` of floats — the classic place a hardware -FPU's last bit leaks per-machine nondeterminism. In det mode these must resolve via -the deterministic softfloat path, so leader/validator/sync hashes must agree. - -Status: VERIFIED deterministic (2026-06-12) via `nix develop .#full`. Leader, -validator, and sync produce byte-identical hashes; `compute()` returns -`453.77516336575655|3.141592653589793|1.4142135623730951|0.30000000000000004` -on all replicas. No divergence — softfloat path holds. Not a discovery. - -How to run (the stale `.direnv/ya-test-runner` can't; use the nix shell): - - nix develop .#full --command bash -c ' - build/out/bin/genvm-modules manager --port 3999 --host 127.0.0.1 --reroute-to vTEST & - # start Llm + Web modules in user_error mode (see ../../run-manager.sh) - ya-test-runner --filter-name claude/agent/float_math run --no-manager --no-webdriver' diff --git a/tests/integration/claude/agent/float_math/contract.py b/tests/integration/claude/agent/float_math/contract.py deleted file mode 100644 index 7e52abc..0000000 --- a/tests/integration/claude/agent/float_math/contract.py +++ /dev/null @@ -1,24 +0,0 @@ -# { "Depends": "py-genlayer:test" } -import math - -import genlayer as gl - - -class Contract(gl.contract.Contract): - def __init__(self): - pass - - @gl.public.view - def compute(self) -> str: - # Transcendental + irrational results are the classic place where a hardware - # FPU's last bit / x87-80bit intermediates leak per-machine nondeterminism. - # In det mode these must go through the deterministic softfloat path. - acc = 0.0 - for i in range(1, 50): - x = float(i) * 0.123456789 - acc += math.sin(x) * math.cos(x) - acc += math.exp(x % 3.0) - acc += math.sqrt(x) * math.log(x + 1.0) - acc += math.atan2(x, 1.0 + x) ** 1.5 - # repr() of a float is itself a place divergence can hide - return f'{acc!r}|{math.pi!r}|{(2.0**0.5)!r}|{(0.1 + 0.2)!r}' diff --git a/tests/integration/claude/agent/float_math/float_math.jsonnet b/tests/integration/claude/agent/float_math/float_math.jsonnet deleted file mode 100644 index 3a3de2f..0000000 --- a/tests/integration/claude/agent/float_math/float_math.jsonnet +++ /dev/null @@ -1,46 +0,0 @@ -local msg = import 'templates/message.json'; -local util = import 'templates/util.jsonnet'; - -local addr = "AQAAAAAAAAAAAAAAAAAAAAAAAAA="; - -local common = { - expected_semantics_components: [], - modes: 'lvs', - stable_hash: false, -}; - -{ - tags: ["fuzz"], - entry: util.addPaths([ - // step 0: deploy - common { - deadline: 10, - code: '${jsonnetDir}/contract.py', - message: msg { - contract_address: addr, - is_init: true, - }, - calldata: '{}', - - next: [ - // step 1: call compute() — leader/validator/sync hashes must agree - common { - deadline: 10, - code: null, - accounts: { - [addr]: { code: '${jsonnetDir}/contract.py' }, - }, - message: msg { - contract_address: addr, - }, - calldata: ||| - { - "": "compute", - "args": [] - } - |||, - }, - ], - }, - ]), -} diff --git a/tests/integration/claude/example/README.md b/tests/integration/claude/example/README.md index 7480eca..938a1eb 100644 --- a/tests/integration/claude/example/README.md +++ b/tests/integration/claude/example/README.md @@ -1,9 +1,13 @@ -create a sample .py and .jsonnet in tests/cases/stable/claude/example for following: deploy A and B, then emit write method to B that will read A. -Write jsonnet yourself: don't use simple.jsonnet but do use util.lib +# Example probe -For all transactions specify: -1. semantics: [] -2. modes: lvs -3. stable_hash: false +The reference shape for a probe: deploy A, deploy B, then call a write method on +B that reads from A. The jsonnet is written out by hand on top of +`templates/util.jsonnet` rather than `templates/simple.jsonnet`, because a probe +is usually more than one step. -For top-level statement specify tag "fuzz" +Every step carries the three fields a probe needs — +`expected_semantics_components: []`, `modes: 'lvs'`, `stable_hash: false` — and +the top-level object carries `tags: ["stable", ...]`, without which the case +asks for LLM keys and a webdriver it does not need. + +The procedure is the manager's `agentic-fuzzing` skill. diff --git a/tests/integration/claude/example/example.jsonnet b/tests/integration/claude/example/example.jsonnet index c0e8f3d..a0fa3e5 100644 --- a/tests/integration/claude/example/example.jsonnet +++ b/tests/integration/claude/example/example.jsonnet @@ -17,7 +17,7 @@ local common = { }; { - tags: ["fuzz"], + tags: ["stable", "fuzz"], entry: util.addPaths([ // step 0: deploy A common { diff --git a/tests/integration/claude/intelligence/EXPLORED_PATHS.md b/tests/integration/claude/intelligence/EXPLORED_PATHS.md index ac89f7c..c1fa76a 100644 --- a/tests/integration/claude/intelligence/EXPLORED_PATHS.md +++ b/tests/integration/claude/intelligence/EXPLORED_PATHS.md @@ -32,7 +32,13 @@ - `sys.platform`: "wasi" - Only 3 env vars: PYTHONHOME, PYTHONPATH, pwd -### Tests Created (all pass, no divergence found) +### Probes run (all pass, no divergence found) + +The probes themselves are gone — they are throwaway now, and only this record +survives. `float_math` was the exception: it was promoted to +`tests/integration/nasty-determinism/floats`, reporting raw `struct.pack('= VM_RECURSION` before increment ⇔ + `remaining_recursion == 0` before `saturating_sub(1)`) and confirmed by + running it: `genvm.log.gz` shows ~16 real `call_contract` spawns (16× + `resolving :test/:latest runner` + matching `runner load` pairs) before + `out_of memory` fires — same OOM-before-`VM_RECURSION`-limit shape + `deep_self_call` already found, and the three `hash` artifacts are + byte-identical (md5 `3162e5ab...`) across l/v/s. No divergence. + +## Runner ids, custom-runner grants and storage state modes (PR #9, 2026-07-29) + +Three probes over the non-cross-major half of the branch. One found an internal +error (kept, see below); the other two found nothing and are gone. + +- `_scratch/runner_ids/` — **KEPT, it fails.** Sweeps 14 guest-supplied runner + ids through `gl.vm.spawn_sandbox(runner=…)` and `gl.vm.map_file(runner, …)`, + i.e. through `rt/supervisor/actions.rs::resolve_runner_id`. Twelve of the + fourteen land on a canonical `invalid_contract malformed_runner` / + `absent_runner_comment` VMError or run fine (`contract`, `chain::a:`, `chain::f`, `py-genlayer:test|latest`, `custom:` unregistered, + `chain:`, `chain::d`, zero slot, `''`, `@@@…`, a + non-canonical gvm32 hash). The two that do not are any *well formed* + `name:` whose pair is not installed — + `py-genlayer:8b8kjyda2ycxyq4ea6g4yfpnydxhd52gqba5rb8dw7krkh5mn9p0` and + `nosuchrunner:9b8kjyda…p0`: `resolve_runner_id` reaches + `anyhow::bail!("runner {}:{} not found")` (actions.rs:177), a plain anyhow + error rather than an `Error::wrap(VmError…)`, so `Error::trap` turns it into + **INTERNAL_ERROR**. Severity 4, contract-controlled, both entry points. **Not + a regression of this PR**: the bail, the `Sandbox`/`MapFile` call sites and + the `Error::trap(anyhow_to_wasmtime(e))` mapping are byte-identical at the + base gitlink `acb37c7` (`git show acb37c7:executor/src/rt/supervisor/ + actions.rs`, lines 142-143 and `genlayer_sdk.rs` 1919-1921 / 1676-1677). The + sibling `Error::internal(format!("runner {id} not found"))` at actions.rs:467 + is the same defect one layer down, unreachable only because the bail fires + first. Both belong behind `make_malformed_runner_error`. + +- `_scratch/custom_runners/` — deleted, nothing. `resolve_child_custom_runners` + (new on this branch) via a raw `gl_call_generic({'Sandbox': …})` carrying a + hand-built `custom_runners` grant list: `None`, `[]`, the parent's own + registered id, a duplicate pair, a non-`custom:` id, an unregistered + `custom:`, `@@@`, and an absent `name:hash`. Every violation is the canonical + `invalid_contract malformed_runner` — note the absent `name:hash` is rejected + *here* rather than reaching the bail above, because the grant list is checked + for the `custom:` prefix before any registry lookup. `register_runner` with + six garbage archives (empty, plain text, `#`, non-json header, a header + depending on an absent registry runner, one depending on an unregistered + `custom:`) returns either a `custom:` id or `absent_runner_comment`. l/v/s + hashes agreed on all 49 runs. + +- `_scratch/state_modes/` — deleted, nothing. Aimed at the working-tree + `ChainState::for_vm` fix in `lib.rs` and at `gl_call_contract`'s new + `state == Default → inherit the parent's state_mode` (base forced + `LatestNonFinal`). Four call shapes (plain nested call, local write before the + nested call, two nested calls in a row, nested call inside a sandbox) × the + three `StorageType` values, plus a top-level step with `is_init: true` and + `code: null` to force `ChainState::for_vm(true, …) == Deploy`. All 45 runs + agree l/v/s, and the deploy-state top-level run ends in the canonical + `invalid_contract malformed_runner` (the `d` chain state has no + `host_storage_type`, so the cache miss cannot be filled) — correct, not an + internal error. **The three storage modes are indistinguishable under the + jsonnet harness**: `gvm_extra/mock_host.py::MockHost.storage_read` ignores its + `mode` argument entirely, so `LatestFinal`, `LatestNonFinal` and `Default` all + read the same bytes. A finalized-vs-accepted divergence cannot be found from a + jsonnet case at all; it needs a host that keeps two snapshots. + +All three ran with `ignore-hash` flipped to `False` in +`executors/v0.3.x/.genvm-tool.py`, so the l/v/s comparison was live. + +## Balance-funded fees, raw storage, backtraces and sub-VM folding (PR #9, 2026-07-29) + +Five probes. One found an internal error (kept); four found nothing and are gone. + +### What the jsonnet harness actually compares + +Worth knowing before writing another probe: the `hash` artifact a jsonnet case +compares across l/v/s is **not** the executor's `execution_hash`. It is a tuple +the harness builds itself in `tests/runner/genvm_tool_plugins/integration.py:850` +— `[result_kind, result_data, result_fingerprint, result_storage_changes, +result_events]`. The executor's `execution_hash` also folds `subvm_hashes`, +`data_fees_consumed` and `data_fees_remaining` +(`executor/src/host/mod.rs::FullResult::new`), and **none of those three are in +the compared tuple**, so a divergence confined to them passes a jsonnet case +silently even with `ignore-hash: False`. The real hash is reachable, though: it +is the `execution_hash` attribute of every step's `result.pickle` artifact. +Reading it out of the three modes' pickles (`pickletools.genops`, take the bytes +after the `execution_hash` key — the pickle needs the harness venv to unpickle +properly) is a strictly stronger oracle and is what the two folding probes below +were actually checked against. + +### KEPT, it fails: `_scratch/fee_zero_budget/` + +A balance-funded internal message (`use_balance=True`) whose guest-supplied +`execution_budget_per_round` is **0** ends the whole transaction in +INTERNAL_ERROR, in all three modes: + + genvm-tool test run --filter-name 'fee_zero_budget' + # 0_0_0 budget=1 -> "emitted" + # 0_0_0_0 budget=0 -> internal error, l/v/s alike + # genvm.log.gz: + # rt::fees "failed to evaluate fee expression" + # error: internal error: undefined variable `false` + # genvm "internal error": calculating message fee internal: + # failed to evaluate fee expression: undefined variable `false` + +Cause: the `message_fee` `delta_expr` in `executor/install/config/genvm.yaml` +(line ~186) reads + + let budgetTooLow = + if a.matchedFeeParams.executionBudgetPerRound > 0 + then a.matchedFeeParams.executionBudgetPerRound < budgetFloor + else false in + +The fee expression language has no boolean **literals** — `Value::Bool` is only +ever produced by a comparison operator or `hasKey` +(`executor/crates/common/src/expr/`), so `false` is an unbound identifier. The +`else` arm is dead for a non-zero budget and is the only thing a zero budget +evaluates, so the branch is exactly the trigger. `rt::fees::eval` maps a +non-`ScriptVMError` `EvalError` to `Error::internal`, so the guest gets +INTERNAL_ERROR where a `VMError` belongs. The same `delta_expr` serves +`DeployContract`, so `gl.contract.deploy(..., use_balance=True)` with a zero +per-round budget is the same defect. + +**This is this PR's regression.** The base gitlink `acb37c7` has no +`budgetTooLow`, no `messageBudgetFloor` and no `balanceFunded` argument — its +`messageFeeFloor` takes two arguments (`git show +acb37c7:executor/install/config/genvm.yaml`), and `git show +acb37c7:executor/src/wasi/genlayer_sdk.rs | grep -c use_balance` is 0: the whole +balance-funded path arrived on this branch. Severity 4. + +Reaching the path from a jsonnet case at all is worth recording: +`can_use_balance_for_message_fees` is read from the root permission bitfield +(`lib.rs:349`), which a contract sets in its own `__init__` via +`gl.storage.Root.get().set_permission(Permissions.CAN_USE_BALANCE_FOR_MESSAGE_FEES, +True)` — after deployment the root slot is frozen by `Root.lock_default()`, so +the constructor is the only window. + +Also note when writing such a case: **jsonnet numbers are IEEE doubles**, so a +literal `79228162514264337593543950335` (2^96-1) silently becomes 2^96 and the +executor rejects it as out of range. A magnitude-boundary probe has to build +big integers in the contract, not in the jsonnet. + +### Deleted, nothing found + +- `_scratch/stor_edge/` — raw `_genlayer_wasi.storage_read/storage_write` with + contract-chosen slot ids, offsets and lengths, against the page arithmetic in + `rt/vm/storage.rs`. `index + len` overflowing u32 is `Inval`; zero-length ops + at offset 2^32-1 are no-ops; an unaligned 70-byte write spanning three pages + reads back exactly; scattered far-apart pages and adjacent ones both survive + `make_delta`'s run coalescing (the `res.last_mut().expect(...)` there is safe + because `StoragePagesOverride` is an `rpds::RedBlackTreeMap` keyed by + `PageID(SlotID, u32)`, so a page's predecessor is always already emitted). + **Root-slot poisoning is closed**: every write to `SlotID::ZERO` — major, + `code_slot` pointer, permission bitfield — returns `forbidden`, because the + py-SDK's `Root.lock_default()` puts the root slot in `locked_slots` at deploy + time. So the "point `code_slot` at a slot whose 4-byte length prefix is + 0xFFFFFFFF and let the runner load action allocate it" idea is unreachable + from a deployed contract (and `charge_load` maps that size to OOM anyway). + One harness caveat: a write at a *large* offset makes `MockHost` materialize a + flat slot of that size — offset 4294967263 produced 4 GiB storage pickles and + a `ConnectionTimeoutError`. That is the mock host, not the VM. + +- `_scratch/bt_hash/` — the wasm backtrace is folded into the top-level + `execution_hash` (`host/mod.rs`, key `backtrace`) whenever + `needs_error_fingerprint` is set, which is true for the root VM and for an + in-process `CallContract` child but false for `Sandbox` and `RunNondet` + children. Drove the guest into `wasm_trap stack_overflow` with python's own + recursion guard lifted (`sys.setrecursionlimit(10**8)`), from several + different prior frame depths, inside a sandbox, and inside a `try/except` + (which cannot catch it — the trap kills the VM). All three modes agree + byte-for-byte on the executor `execution_hash`. Note `extract_backtrace` logs + "no backtrace attached" for these traps, so the frame list is empty in + practice and the fingerprint the in-process `CallContract` child computes is + never folded anywhere: `RunResult::small_hash` hashes `kind`, `result`, + `subvm_hashes` and `wasm_store_hashes`, but **not** `backtrace`. + +- `_scratch/fold_mix/` — sub-VM folding and fee consumption, checked against the + `result.pickle` `execution_hash` oracle rather than the harness tuple: 1 and 8 + sequential sandboxes, 6 levels of nested sandboxes, `register_runner` inside a + sandbox (twice, same and differing code), register-then-`spawn_sandbox` on the + registered `custom:` id, `emit_raw` with 0/1/4 topics and 0/7/1000-byte blobs, + sandboxes interleaved with events, and a 4-deep in-process `CallContract` + chain between two contracts, alone and mixed with sandboxes and an event. 14 + steps, all with distinct hashes, all three modes identical. + +- `_scratch/fee_params/` — the wider fee-param sweep the minimal + `fee_zero_budget` case was cut down from. Rotations of length 0/1/9/10/64, + counts at 2^32-1, prices at the 2^96 bound and one bit over, on-acceptance on + and off. Everything except the zero-budget case lands on `Errno::Inval` from + `validate_balance_fee` or on the yaml's `vmError "fee too_many_rounds"`; the + magnitude bound documented at `message.rs:225` (worst case < 2^183) holds, so + `rational_to_u256`'s "exceeds U256 range" `internal_ensure!` is not reachable + by making the numbers big — only by making the budget zero. + +All five ran with `ignore-hash` flipped to `False` in +`executors/v0.3.x/.genvm-tool.py`, so the l/v/s comparison was live. + +## Cross-major observability system test (2026-07-29) + +Added `tests/system/cross-major-observability/test.py` in the manager root and +collected it from the root `.genvm-tool.py`. This extends the real-manager +`tests/system/cross-major` harness rather than adding a jsonnet case. + +Not because a jsonnet case cannot reach `run_nested` -- it can. A case declares +a top-level `executor_routes` map of callee address to major, which makes the +plugin install a `resolve_callcontract_executor_hook` and set +`hook_cross_contract_calls` on the request +(`tests/runner/genvm_tool_plugins/integration.py`); `misc/routed_call` is such +a case. + +Nor is it the oracle. An earlier note in this file says a jsonnet case compares +a tuple the harness builds itself rather than the executor's `execution_hash`. +**That is stale**: since `a5bad97` the plugin sets `hash_data = +res.execution_hash`, so a jsonnet case compares exactly what consensus does. + +The actual reason is that a jsonnet case can set `permissions`, but there is no +way to inject a recursion budget or a host fuel value per step -- +`unsafe_overrides` carries only `reroute_to`. This case needed both. + +It *used* to also be true that the line compared no hashes at all: the setting +was `ignore-hash: True`, and it suppressed the leader-vs-validator comparison +along with the committed goldens. It is now `save-hashes: False`, which drops +only the goldens; a non-main mode is compared against the main mode of the same +run instead. Flipping the old flag by hand is no longer necessary, and the first +run under the new semantics found a case that had been silently broken -- +`exploit/disagree_in_sandbox` declared a `leader_nondet` result its contract +could never consume, so its validator failed `leader_output extra` while the +leader returned normally. + +Worth knowing either way: `execution_hash` folds eight fields (`backtrace`, +`data`, `data_fees_consumed`, `data_fees_remaining`, `kind`, `storage_changes`, +`subvm_hashes`, `wasm_store_hashes`) and **emissions are not among them**. Hash +equality across l/v/s does not by itself prove the three runs emitted the same +messages; in a jsonnet case that is covered separately by the `messages` +semantics component, which only runs in the main mode. + +The case deploys hash-pinned v0.2/v0.3 contracts and compares the executor's +actual 32-byte `execution_hash` across genuine leader, validator and sync runs. +A deliberately unused leader result first proves the validator request is not +silently running as sync. Covered: + +- v0.2 -> v0.3 preserved message fields (callee address, inherited + sender/origin/signer, caller stack, zero value, non-init); +- a nested callee observing `6: forbidden` for raw storage write, message send, + and `run_nondet`, while runner registration and a further deterministic + contract call remain available when inherited from the parent; +- forced disabled debug (`py-genlayer:test` resolves under the unsafe top-level + control run, but nested execution reports `invalid_contract + malformed_runner`); +- a root run with only `c` permission (therefore no `LockedSlotsSet`) reading + the same nested stored value as `wscn`; +- host-routed self-calls with injected recursion budgets 0, 1, 2, 4, 5 and 6 + (0/1/2/4 produce canonical `out_of vm_recursion`; 5/6 finish); +- alternating v0.3/v0.2 chains at depths 1, 4 and 8; +- undeployed and non-contract addresses, a Python trap, and a `UserError`; +- host fuel values 0, 1, 1000 and 2^32. + +No leader/validator/sync hash disagreement and no contract-triggered +`INTERNAL_ERROR` occurred. + +Ruled out: + +- **Nested `no_modules` can be distinguished from the stripped nondet + permission by a contract:** it cannot. `run_nondet` is rejected as + `Forbidden` before module access, and the manager rejects any nested envelope + asserting `SPAWN_NONDET`. The callee-visible behavior is covered, but the two + internal reasons are intentionally observationally equivalent. +- **Changing `remaining_fuel_as_gen` changes deterministic nested execution:** + it did not for module-free work. All four host-fuel values returned the same + value and the same hash (`d9320ff0...d975`) in l/v/s. The imported budget can + only be spent/observed by module work, which the nested permission model makes + unreachable. +- **Recursion exhaustion can be caught by the Python caller:** it cannot; the + executor trap aborts the current VM and surfaces canonical `out_of + vm_recursion`. It was byte-identical in all three modes at every tested + budget. +- **An unused synthetic leader-result blob proves validator mode on either + executor line:** only on v0.3. The same guard on a v0.2 root returned normally + because that line tolerates unused leader results; moving the guard to a + v0.3-root call produced the expected VMError. This was a test-oracle + difference, not an l/v/s disagreement. diff --git a/tests/integration/claude/intelligence/TODO.md b/tests/integration/claude/intelligence/TODO.md index b7bfe40..420b51e 100644 --- a/tests/integration/claude/intelligence/TODO.md +++ b/tests/integration/claude/intelligence/TODO.md @@ -1,19 +1,10 @@ # TODO -## Harness notes (discovered 2026-06-12) - -- RUN VIA `nix develop .#full`, not `run_test.py`. The `.direnv/ya-test-runner` is - STALE (its `Description` lacks `depends_on`/`hidden`), so `run_test.py` — which - hardcodes that binary — fails collection with - `TypeError: Description.__new__() got an unexpected keyword argument 'depends_on'`. - The nix `full` shell puts a freshly-built `ya-test-runner` on PATH that works. - Invoke `ya-test-runner --filter-name run --no-manager --no-webdriver` directly. -- `--filter-name`/`--filter-tag` do NOT isolate a single test — the suite runs whole - (718 pass / 78 fail; the 78 are LLM/web tests that need real modules, run here in - user_error mode). Just read the specific test's `✓`/`✗` lines and its artifacts. -- Manager dies across tool calls; start it and run the test in the SAME shell call. - Do NOT `pkill -f genvm-modules` — it matches the shell's own command line and - suicides; use `pkill -x genvm-modules`. +## Harness notes + +Superseded 2026-07-28. `run_test.py`, `run-manager.sh` and the `ya-test-runner` +workarounds are gone: `genvm-tool test run --filter-name ` isolates a +single case and starts the manager itself. See the `agentic-fuzzing` skill. ## High Priority diff --git a/tests/integration/claude/run-manager.sh b/tests/integration/claude/run-manager.sh deleted file mode 100755 index 9023492..0000000 --- a/tests/integration/claude/run-manager.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$SCRIPT_DIR/../../.." - -BINARY="$ROOT_DIR/build/out/bin/genvm-modules" -if [[ ! -x "$BINARY" ]]; then - echo "Error: genvm-modules binary not found at $BINARY" >&2 - echo "Run: nix develop .#full --command ninja -C build all/bin" >&2 - exit 1 -fi - -PORT="${PORT:-3999}" -HOST="${HOST:-127.0.0.1}" -MANAGER_URL="http://$HOST:$PORT" - -cleanup() { - if [[ -n "${MANAGER_PID:-}" ]]; then - kill "$MANAGER_PID" 2>/dev/null || true - wait "$MANAGER_PID" 2>/dev/null || true - fi -} -trap cleanup EXIT - -echo "Starting manager on $MANAGER_URL..." -"$BINARY" manager --port "$PORT" --host "$HOST" --reroute-to vTEST --die-with-parent & -MANAGER_PID=$! - -# Wait for manager to become healthy -for i in $(seq 1 30); do - if curl -sf "$MANAGER_URL/status" >/dev/null 2>&1; then - break - fi - if ! kill -0 "$MANAGER_PID" 2>/dev/null; then - echo "Error: manager process died" >&2 - exit 1 - fi - sleep 0.1 -done - -if ! curl -sf "$MANAGER_URL/status" >/dev/null 2>&1; then - echo "Error: manager did not become healthy" >&2 - exit 1 -fi - -echo "Manager is healthy" - -# Start LLM module in user_error mode -echo "Starting LLM module (user_error mode)..." -LLM_RESP=$(curl -sf -X POST "$MANAGER_URL/module/start" \ - -H 'Content-Type: application/json' \ - -d '{"module_type": "Llm", "config": null, "user_error": true, "allow_empty_backends": true}') -echo " LLM: $LLM_RESP" - -# Start Web module in user_error mode -echo "Starting Web module (user_error mode)..." -WEB_RESP=$(curl -sf -X POST "$MANAGER_URL/module/start" \ - -H 'Content-Type: application/json' \ - -d '{"module_type": "Web", "config": null, "user_error": true}') -echo " Web: $WEB_RESP" - -# Verify status -STATUS=$(curl -sf "$MANAGER_URL/status") -echo "Status: $STATUS" - -echo "Manager running with erroring modules on $MANAGER_URL (PID $MANAGER_PID)" -echo "Press Ctrl+C to stop" -wait "$MANAGER_PID" diff --git a/tests/integration/claude/run_test.py b/tests/integration/claude/run_test.py deleted file mode 100755 index 23b1730..0000000 --- a/tests/integration/claude/run_test.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 - -""" -Runs a specific claude integration test with --no-manager. -Assumes manager is already running externally. - -Usage: - ./run_test.py - ./run_test.py example - -This will run: - ya-test-runner --filter-tag integration --filter-name 'tests/cases/stable/claude/' run --no-manager -""" - -import os -import sys -from pathlib import Path - -root_dir = Path(__file__).resolve() - -while ( - root_dir.parent != root_dir and not root_dir.joinpath('.genvm-monorepo-root').exists() -): - root_dir = root_dir.parent - -binary = root_dir.joinpath('.direnv', 'ya-test-runner', 'bin', 'ya-test-runner') -if not binary.exists(): - print(f'Error: could not find ya-test-runner binary at {binary}', file=sys.stderr) - sys.exit(1) - - -def main(): - if len(sys.argv) < 2: - print(f'Usage: {sys.argv[0]} [extra-args...]', file=sys.stderr) - print(f'Example: {sys.argv[0]} example', file=sys.stderr) - sys.exit(1) - - test_dir = sys.argv[1] - extra_args = sys.argv[2:] - - test_name = f'tests/cases/claude/{test_dir}' - - cmd = [ - binary, - '--filter-tag', - 'integration', - '--filter-name', - test_name, - 'run', - '--no-manager', - *extra_args, - ] - - os.chdir(root_dir) - os.execvp(cmd[0], cmd) - - -if __name__ == '__main__': - main() diff --git a/tests/integration/exploit/disagree_in_sandbox/disagree_in_sandbox.jsonnet b/tests/integration/exploit/disagree_in_sandbox/disagree_in_sandbox.jsonnet index 563d13a..5d11f8c 100644 --- a/tests/integration/exploit/disagree_in_sandbox/disagree_in_sandbox.jsonnet +++ b/tests/integration/exploit/disagree_in_sandbox/disagree_in_sandbox.jsonnet @@ -1,11 +1,8 @@ local simple_deploy = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['exploit', 'disagree'], ['nasty-determinism']], 'stable'), - entry: util.addPaths([simple_deploy.run('${jsonnetDir}/${fileBaseName}.py') { - leader_nondet: [ - { - "kind": "return", - "value": "None" - } - ], -}])} + // No `leader_nondet`: the point of the case is that the sandbox is refused + // the nondet block, so a leader result would never be consumed and the + // validator would reject the leftover as `leader_output extra` -- making + // the modes differ for a reason that has nothing to do with the sandbox. + entry: util.addPaths([simple_deploy.run('${jsonnetDir}/${fileBaseName}.py')])} diff --git a/tests/integration/exploit/unservable_major/unservable_major.0.stdout b/tests/integration/exploit/unservable_major/unservable_major.0.stdout new file mode 100644 index 0000000..8ca3a6d --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major.0.stdout @@ -0,0 +1,2 @@ +deployed target with major 99 +executed with `Return(null)` diff --git a/tests/integration/exploit/unservable_major/unservable_major.0_0.stdout b/tests/integration/exploit/unservable_major/unservable_major.0_0.stdout new file mode 100644 index 0000000..7d73027 --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major.0_0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/exploit/unservable_major/unservable_major.0_0_0.stdout b/tests/integration/exploit/unservable_major/unservable_major.0_0_0.stdout new file mode 100644 index 0000000..e1140e7 --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major.0_0_0.stdout @@ -0,0 +1,2 @@ +unservable_major from.main +executed with `VMError("invalid_contract major_mismatch")` diff --git a/tests/integration/exploit/unservable_major/unservable_major.1.stdout b/tests/integration/exploit/unservable_major/unservable_major.1.stdout new file mode 100644 index 0000000..8ca3a6d --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major.1.stdout @@ -0,0 +1,2 @@ +deployed target with major 99 +executed with `Return(null)` diff --git a/tests/integration/exploit/unservable_major/unservable_major.1_0.stdout b/tests/integration/exploit/unservable_major/unservable_major.1_0.stdout new file mode 100644 index 0000000..7d73027 --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major.1_0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/exploit/unservable_major/unservable_major.1_0_0.stdout b/tests/integration/exploit/unservable_major/unservable_major.1_0_0.stdout new file mode 100644 index 0000000..e1140e7 --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major.1_0_0.stdout @@ -0,0 +1,2 @@ +unservable_major from.main +executed with `VMError("invalid_contract major_mismatch")` diff --git a/tests/integration/exploit/unservable_major/unservable_major.jsonnet b/tests/integration/exploit/unservable_major/unservable_major.jsonnet new file mode 100644 index 0000000..86ea385 --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major.jsonnet @@ -0,0 +1,41 @@ +// The callee wrote a major into its own root slot that no installed line +// serves. A contract picks that major, a host picks the route, so neither +// choice may turn the call into an internal error: both entries must land on +// the same canonical mismatch. +// +// The callee pins its runner by hash instead of using `:test`, because the +// nested executor the manager spawns runs with debug mode disabled, where +// `:test` does not resolve. +local simple = import 'templates/two.jsonnet'; +local util = import 'templates/util.jsonnet'; +local base = simple.run('${jsonnetDir}/unservable_major_from.py', '${jsonnetDir}/unservable_major_to.py', ||| + { + "": "main", + "args": [Address(toAddr)] + } +||| +); +local entries = [ + // 0: the host is consulted and declines to route, so the call stays in this + // process and the executor's own check answers. + base { + next: [super.next[0] { + next: [super.next[0] { + hook_cross_contract_calls: true, + }], + }], + }, + // 1: the host routes the callee to the major it stored. The manager has no + // line providing it and falls back to the newest one, whose check answers. + base { + next: [super.next[0] { + next: [super.next[0] { + executor_routes: { + "AwAAAAAAAAAAAAAAAAAAAAAAAAA=": 99, + }, + }], + }], + }, +]; +{tags: util.features([['message', 'external', 'view']], 'stable'), + entry: util.addPaths(util.mapGraph(function(e) e + {stable_hash: false}, entries))} diff --git a/tests/integration/exploit/unservable_major/unservable_major_from.py b/tests/integration/exploit/unservable_major/unservable_major_from.py new file mode 100644 index 0000000..32c0df4 --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major_from.py @@ -0,0 +1,13 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.types import Address + + +class Contract(gl.contract.Contract): + def __init__(self): + pass + + @gl.public.write + def main(self, addr: Address): + print('unservable_major from.main') + print(gl.contract.get_at(addr).view().answer()) diff --git a/tests/integration/exploit/unservable_major/unservable_major_to.py b/tests/integration/exploit/unservable_major/unservable_major_to.py new file mode 100644 index 0000000..4361e73 --- /dev/null +++ b/tests/integration/exploit/unservable_major/unservable_major_to.py @@ -0,0 +1,16 @@ +# { "Depends": "py-genlayer:9b8kjyda2ycxyq4ea6g4yfpnydxhd52gqba5rb8dw7krkh5mn9p0" } +import genlayer as gl +from genlayer.storage.root import Root + + +class Contract(gl.contract.Contract): + def __init__(self): + # a major far beyond anything installed, so no line can serve this + # contract and no route the host picks can make the call succeed + Root.get().major = 99 + print('deployed target with major 99') + + @gl.public.view + def answer(self) -> int: + print('should not be reached') + return 42 diff --git a/tests/integration/hello-world/_hello_world_/_hello_world_.0.stdout b/tests/integration/hello-world/hello_world/hello_world.0.stdout similarity index 100% rename from tests/integration/hello-world/_hello_world_/_hello_world_.0.stdout rename to tests/integration/hello-world/hello_world/hello_world.0.stdout diff --git a/tests/integration/hello-world/_hello_world_/_hello_world_.jsonnet b/tests/integration/hello-world/hello_world/hello_world.jsonnet similarity index 66% rename from tests/integration/hello-world/_hello_world_/_hello_world_.jsonnet rename to tests/integration/hello-world/hello_world/hello_world.jsonnet index 991b3f2..5b2a2bd 100644 --- a/tests/integration/hello-world/_hello_world_/_hello_world_.jsonnet +++ b/tests/integration/hello-world/hello_world/hello_world.jsonnet @@ -1,4 +1,4 @@ local simple = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; {tags: util.features([['hello-world']], 'stable'), - entry: util.addPaths([simple.run('${jsonnetDir}/_hello_world_trivial.py')])} + entry: util.addPaths([simple.run('${jsonnetDir}/hello_world_trivial.py')])} diff --git a/tests/integration/hello-world/_hello_world_/_hello_world_trivial.py b/tests/integration/hello-world/hello_world/hello_world_trivial.py similarity index 100% rename from tests/integration/hello-world/_hello_world_/_hello_world_trivial.py rename to tests/integration/hello-world/hello_world/hello_world_trivial.py diff --git a/tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.0.stdout b/tests/integration/hello-world/hello_world_class_nondet/hello_world_class_nondet.0.stdout similarity index 100% rename from tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.0.stdout rename to tests/integration/hello-world/hello_world_class_nondet/hello_world_class_nondet.0.stdout diff --git a/tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.jsonnet b/tests/integration/hello-world/hello_world_class_nondet/hello_world_class_nondet.jsonnet similarity index 100% rename from tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.jsonnet rename to tests/integration/hello-world/hello_world_class_nondet/hello_world_class_nondet.jsonnet diff --git a/tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.py b/tests/integration/hello-world/hello_world_class_nondet/hello_world_class_nondet.py similarity index 100% rename from tests/integration/hello-world/_hello_world_class_nondet/_hello_world_class_nondet.py rename to tests/integration/hello-world/hello_world_class_nondet/hello_world_class_nondet.py diff --git a/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.0.stdout b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.0.stdout new file mode 100644 index 0000000..7d73027 --- /dev/null +++ b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.0_0.stdout b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.0_0.stdout new file mode 100644 index 0000000..da5b081 --- /dev/null +++ b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.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":0,"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":10280,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"PostMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.jsonnet b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.jsonnet new file mode 100644 index 0000000..0c26e5c --- /dev/null +++ b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.jsonnet @@ -0,0 +1,30 @@ +local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; + +// Same floor as use_balance_budget_too_low (2000), but the emitted message +// declares a zero executionBudgetPerRound, which the floor exempts. 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 outcome isolates the budget rule, 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_zero_budget/use_balance_zero_budget.py b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.py new file mode 100644 index 0000000..0eaaff3 --- /dev/null +++ b/tests/integration/message/use_balance_zero_budget/use_balance_zero_budget.py @@ -0,0 +1,31 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.vm.public_abi import Permissions + +# execution_budget_per_round is 0, so the node's messageBudgetFloor (2000, set +# in the jsonnet) does not apply: the chain reverts `BudgetTooLow` only for a +# budget that is non-zero and below the floor. The emission must therefore be +# metered normally rather than rejected -- and, in particular, must not abort +# the transaction while evaluating the fee expression. +_PARAMS = gl.chain.InternalMessageParams( + leader_timeunits_allocation=5, + validator_timeunits_allocation=5, + execution_budget_per_round=0, + 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): + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( + use_balance=True, fee_params=_PARAMS + ).foo(1, 2) diff --git a/tests/integration/misc/routed_call/routed_call.0.stdout b/tests/integration/misc/routed_call/routed_call.0.stdout new file mode 100644 index 0000000..7d73027 --- /dev/null +++ b/tests/integration/misc/routed_call/routed_call.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/misc/routed_call/routed_call.0_0.stdout b/tests/integration/misc/routed_call/routed_call.0_0.stdout new file mode 100644 index 0000000..7d73027 --- /dev/null +++ b/tests/integration/misc/routed_call/routed_call.0_0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/misc/routed_call/routed_call.0_0_0.stdout b/tests/integration/misc/routed_call/routed_call.0_0_0.stdout new file mode 100644 index 0000000..4a68d0d --- /dev/null +++ b/tests/integration/misc/routed_call/routed_call.0_0_0.stdout @@ -0,0 +1,3 @@ +routed_call from.main +42 +executed with `Return(null)` diff --git a/tests/integration/misc/routed_call/routed_call.jsonnet b/tests/integration/misc/routed_call/routed_call.jsonnet new file mode 100644 index 0000000..b6dc862 --- /dev/null +++ b/tests/integration/misc/routed_call/routed_call.jsonnet @@ -0,0 +1,25 @@ +// The host routes the callee to another executor instead of running it +// in-process, so the call leaves this process and comes back as a nested run. +// +// The callee pins its runner by hash instead of using `:test`, because the +// nested executor the manager spawns runs with debug mode disabled, where +// `:test` does not resolve. +local simple = import 'templates/two.jsonnet'; +local util = import 'templates/util.jsonnet'; +local base = simple.run('${jsonnetDir}/routed_call_from.py', '${jsonnetDir}/routed_call_to.py', ||| + { + "": "main", + "args": [Address(toAddr)] + } +||| +); +{tags: util.features([['message', 'external', 'view']], 'stable'), + entry: util.addPaths([base { + next: [super.next[0] { + next: [super.next[0] { + executor_routes: { + "AwAAAAAAAAAAAAAAAAAAAAAAAAA=": 3, + }, + }], + }], + }])} diff --git a/tests/integration/misc/routed_call/routed_call_from.py b/tests/integration/misc/routed_call/routed_call_from.py new file mode 100644 index 0000000..1077e01 --- /dev/null +++ b/tests/integration/misc/routed_call/routed_call_from.py @@ -0,0 +1,13 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl +from genlayer.types import Address + + +class Contract(gl.contract.Contract): + def __init__(self): + pass + + @gl.public.write + def main(self, addr: Address): + print('routed_call from.main') + print(gl.contract.get_at(addr).view().answer()) diff --git a/tests/integration/misc/routed_call/routed_call_to.py b/tests/integration/misc/routed_call/routed_call_to.py new file mode 100644 index 0000000..a85510e --- /dev/null +++ b/tests/integration/misc/routed_call/routed_call_to.py @@ -0,0 +1,12 @@ +# { "Depends": "py-genlayer:9b8kjyda2ycxyq4ea6g4yfpnydxhd52gqba5rb8dw7krkh5mn9p0" } +import genlayer as gl + + +class Contract(gl.contract.Contract): + def __init__(self): + pass + + @gl.public.view + def answer(self) -> int: + print('routed_call to.answer') + return 42 diff --git a/tests/integration/nasty-determinism/floats/contract.py b/tests/integration/nasty-determinism/floats/contract.py new file mode 100644 index 0000000..f397ac8 --- /dev/null +++ b/tests/integration/nasty-determinism/floats/contract.py @@ -0,0 +1,38 @@ +# { "Depends": "py-genlayer:test" } +import math +import struct + +import genlayer as gl + + +# `repr()` rounds; the raw bits are what a divergent FPU actually differs in. +def _bits(value: float) -> bytes: + return struct.pack('` pair that is simply not installed. It +# gets past the format and gvm32 checks and only fails the registry lookup, so +# it is the one shape that used to abort the transaction internally instead of +# producing a canonical result. +_ABSENT = 'py-genlayer:8b8kjyda2ycxyq4ea6g4yfpnydxhd52gqba5rb8dw7krkh5mn9p0' +# Same shape, but the trailing character carries non-zero padding bits, so it +# is rejected earlier, by the gvm32 decoder. +_MALFORMED = 'py-genlayer:9b8kjyda2ycxyq4ea6g4yfpnydxhd52gqba5rb8dw7krkh5mn9p1' + + +class Contract(gl.contract.Contract): + def __init__(self): + pass + + @gl.public.write + def spawn_absent(self): + gl.vm.spawn_sandbox(lambda: 1, runner=_ABSENT) + + @gl.public.write + def map_absent(self): + gl.vm.map_file(_ABSENT, 'runner.json', '/tmp/probe') + + @gl.public.write + def spawn_malformed(self): + gl.vm.spawn_sandbox(lambda: 1, runner=_MALFORMED) diff --git a/tests/integration/runner/major_mismatch/major_mismatch.3.stdout b/tests/integration/runner/major_mismatch/major_mismatch.3.stdout new file mode 100644 index 0000000..8dbb414 --- /dev/null +++ b/tests/integration/runner/major_mismatch/major_mismatch.3.stdout @@ -0,0 +1,2 @@ +deployed target with major 5 +executed with `Return(null)` diff --git a/tests/integration/runner/major_mismatch/major_mismatch.3_0.stdout b/tests/integration/runner/major_mismatch/major_mismatch.3_0.stdout new file mode 100644 index 0000000..f70cfae --- /dev/null +++ b/tests/integration/runner/major_mismatch/major_mismatch.3_0.stdout @@ -0,0 +1 @@ +executed with `VMError("invalid_contract major_mismatch")` diff --git a/tests/integration/runner/major_mismatch/major_mismatch.jsonnet b/tests/integration/runner/major_mismatch/major_mismatch.jsonnet index 7f84870..b6c0e41 100644 --- a/tests/integration/runner/major_mismatch/major_mismatch.jsonnet +++ b/tests/integration/runner/major_mismatch/major_mismatch.jsonnet @@ -15,6 +15,11 @@ local entries = [ code: null, message: msg, calldata: std.manifestJsonEx({'': 'foo', args: []}, ' '), + // The host claims a major this executor serves, because the subject + // here is the executor's own check rather than the manager's + // routing. Entry 3 covers the other half, where the host reports + // the contract's real (unservable) major. + major: 0, }], }, // 1: CallContract into a major-mismatched contract @@ -35,6 +40,23 @@ local entries = [ } ||| ), + // 3: the host reports the contract's real major, which no installed line + // provides. The manager has nothing to route to, and a major written by a + // contract into its own root slot must not be able to abort the run: it + // falls back to the newest line, whose own check answers with the same + // canonical error as entry 0. + { + vars: {}, + code: '${jsonnetDir}/target.py', + message: msg + {is_init: true}, + calldata: '{}', + next: [{ + vars: {}, + code: null, + message: msg, + calldata: std.manifestJsonEx({'': 'foo', args: []}, ' '), + }], + }, ]; {tags: util.features([['runner', 'malformed'], ['message']], 'stable'), diff --git a/tests/integration/storage/_hello_world_class/_hello_world_class.0.stdout b/tests/integration/storage/hello_world_class/hello_world_class.0.stdout similarity index 100% rename from tests/integration/storage/_hello_world_class/_hello_world_class.0.stdout rename to tests/integration/storage/hello_world_class/hello_world_class.0.stdout diff --git a/tests/integration/storage/_hello_world_class/_hello_world_class.0_0.stdout b/tests/integration/storage/hello_world_class/hello_world_class.0_0.stdout similarity index 100% rename from tests/integration/storage/_hello_world_class/_hello_world_class.0_0.stdout rename to tests/integration/storage/hello_world_class/hello_world_class.0_0.stdout diff --git a/tests/integration/storage/_hello_world_class/_hello_world_class.jsonnet b/tests/integration/storage/hello_world_class/hello_world_class.jsonnet similarity index 100% rename from tests/integration/storage/_hello_world_class/_hello_world_class.jsonnet rename to tests/integration/storage/hello_world_class/hello_world_class.jsonnet diff --git a/tests/integration/storage/_hello_world_class/_hello_world_class.py b/tests/integration/storage/hello_world_class/hello_world_class.py similarity index 100% rename from tests/integration/storage/_hello_world_class/_hello_world_class.py rename to tests/integration/storage/hello_world_class/hello_world_class.py