From 393a912c353f522a4a88bef59fe4712f7b28d268 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 27 Aug 2026 17:07:59 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs(security):=20clarify=20pre-mainnet=20r?= =?UTF-8?q?eporting=20=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SECURITY.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index bc8b82bd..dce703c2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,9 +7,13 @@ This repository is a component of [genvm-manager]; the canonical security policy ## Reporting a vulnerability -**Do not open a public issue.** Report privately via GitHub's -[private vulnerability reporting](https://github.com/genlayerlabs/genvm-executor/security/advisories/new), -or email code owners, kira@genlayerlabs.com for instance +**Before mainnet, report everything except remote code execution publicly** — open a +regular issue. Until there is value at stake, an open report gets triaged faster and is +useful to everyone reading along. RCE is the only exception; report it privately. + +For remote code execution, **do not open a public issue** — report it via GitHub's +[private vulnerability reporting](https://github.com/genlayerlabs/genvm-manager/security/advisories/new) +on the [genvm-manager] repository. Include a description, affected component/version, and a reproduction (a contract, calldata, or test case) where possible. We aim to acknowledge within a few business days. @@ -35,8 +39,14 @@ Issues are triaged by impact, highest first: The following relationships are trusted. Hardening them is welcome, but a report that assumes one side is hostile is not treated as a vulnerability: -- host and GenVM -- executor and manager -- the local disk and loopback in general +- Host and GenVM +- Executor and manager +- The local disk and loopback in general + +The following inputs are untrusted, even when delivered through a trusted component: + +- Intelligent Contract code and contract-controlled data, including calldata, messages, and persisted values +- Data originating from other validators, including leader results +- External content processed by modules, including HTTP responses, redirects, rendered pages, JavaScript, subresources, and model-provider responses [genvm-manager]: https://github.com/genlayerlabs/genvm-manager From dd0136eec7f0a272abc426b27c34a9098d021575 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Mon, 31 Aug 2026 15:56:07 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(executor):=20meter=20retained=20outputs?= =?UTF-8?q?=20against=20RAM=20=F0=9F=94=92=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/codegen/data/internal-constants.json | 5 +- .../crates/common/src/internal_constants.rs | 3 + executor/src/rt/memlimiter.rs | 83 +++++++++++++++++++ executor/src/rt/vm/storage.rs | 52 ++++-------- executor/src/wasi/genlayer_sdk/message.rs | 57 +++++++++++++ executor/src/wasi/genlayer_sdk/mod.rs | 26 +++++- executor/src/wasi/genlayer_sdk/run.rs | 9 +- executor/src/wasi/genlayer_sdk/tests.rs | 13 +++ executor/tests/permanent_memory_accounting.rs | 63 ++++++++++++++ 9 files changed, 272 insertions(+), 39 deletions(-) create mode 100644 executor/tests/permanent_memory_accounting.rs diff --git a/executor/codegen/data/internal-constants.json b/executor/codegen/data/internal-constants.json index b3726937..c524c3bd 100644 --- a/executor/codegen/data/internal-constants.json +++ b/executor/codegen/data/internal-constants.json @@ -10,7 +10,10 @@ "runner_load_cost": 4096, "vm_spawn_cost": 134217728, "new_storage_page": 256, - "storage_page_inherited": 128 + "storage_page_inherited": 128, + "execution_emission_base_size": 256, + "message_fee_rotation_element_size": 32, + "nondet_output_base_size": 32 } }, { diff --git a/executor/crates/common/src/internal_constants.rs b/executor/crates/common/src/internal_constants.rs index a41c6c9b..e7776dfb 100644 --- a/executor/crates/common/src/internal_constants.rs +++ b/executor/crates/common/src/internal_constants.rs @@ -10,6 +10,9 @@ pub mod memory_limiter_consts { pub const VM_SPAWN_COST: u32 = 134217728; pub const NEW_STORAGE_PAGE: u32 = 256; pub const STORAGE_PAGE_INHERITED: u32 = 128; + pub const EXECUTION_EMISSION_BASE_SIZE: u32 = 256; + pub const MESSAGE_FEE_ROTATION_ELEMENT_SIZE: u32 = 32; + pub const NONDET_OUTPUT_BASE_SIZE: u32 = 32; } pub mod top_limits { diff --git a/executor/src/rt/memlimiter.rs b/executor/src/rt/memlimiter.rs index 820d87ba..a3d2cd7c 100644 --- a/executor/src/rt/memlimiter.rs +++ b/executor/src/rt/memlimiter.rs @@ -6,6 +6,7 @@ use crate::rt; struct LimiterInner { remaining_memory: AtomicU32, + new_permanent_allocations: AtomicU32, } #[derive(Clone)] @@ -35,6 +36,7 @@ impl Limiter { pub fn with_limit(limit: u32) -> Self { Self(Arc::new(LimiterInner { remaining_memory: AtomicU32::new(limit), + new_permanent_allocations: AtomicU32::new(0), })) } @@ -45,9 +47,64 @@ impl Limiter { .remaining_memory .load(std::sync::atomic::Ordering::SeqCst), ), + new_permanent_allocations: AtomicU32::new(0), })) } + pub fn reserve_permanent(&self, delta: u64) -> Option { + let delta = u32::try_from(delta).ok()?; + if !self.consume(delta) { + return None; + } + if self + .0 + .new_permanent_allocations + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |current| current.checked_add(delta), + ) + .is_err() + { + self.release(delta); + return None; + } + Some(PermanentAllocation { + limiter: self.clone(), + delta, + committed: false, + }) + } + + pub fn fold_permanent(&self, child: &Self) -> bool { + let delta = child + .0 + .new_permanent_allocations + .load(std::sync::atomic::Ordering::SeqCst); + if self + .0 + .new_permanent_allocations + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |current| current.checked_add(delta), + ) + .is_err() + { + return false; + } + if !self.consume(delta) { + return false; + } + true + } + + pub fn get_new_permanent_allocations(&self) -> u32 { + self.0 + .new_permanent_allocations + .load(std::sync::atomic::Ordering::SeqCst) + } + /// Charges `delta` bytes, failing (rather than truncating) when `delta` /// does not fit in the `u32` budget. A body larger than `u32::MAX` can never /// fit the 4 GiB budget anyway, so this maps cleanly onto the OOM path. @@ -111,6 +168,32 @@ impl Limiter { } } +/// A RAM charge released on drop unless [`PermanentAllocation::commit`] retains it. +pub struct PermanentAllocation { + limiter: Limiter, + delta: u32, + committed: bool, +} + +impl PermanentAllocation { + pub fn commit(mut self) { + self.committed = true; + } +} + +impl Drop for PermanentAllocation { + fn drop(&mut self) { + if self.committed { + return; + } + self.limiter + .0 + .new_permanent_allocations + .fetch_sub(self.delta, std::sync::atomic::Ordering::SeqCst); + self.limiter.release(self.delta); + } +} + impl wasmtime::ResourceLimiter for Limiter { fn memory_growing( &mut self, diff --git a/executor/src/rt/vm/storage.rs b/executor/src/rt/vm/storage.rs index f568e06f..a96b4d02 100644 --- a/executor/src/rt/vm/storage.rs +++ b/executor/src/rt/vm/storage.rs @@ -103,7 +103,6 @@ struct StoragePagesOverride { pages: rpds::RedBlackTreeMap, fee: Limiter, mem: rt::memlimiter::Limiter, - new_pages: u32, } impl StoragePagesOverride { @@ -112,7 +111,6 @@ impl StoragePagesOverride { pages: Default::default(), fee: storage_pages_limit, mem, - new_pages: 0, } } @@ -126,25 +124,19 @@ impl StoragePagesOverride { async fn write_page(&mut self, key: PageID, value: [u8; 32]) -> rt::errors::Result<()> { if !self.pages.contains_key(&key) { - let new_pages = self.new_pages.checked_add(1).ok_or_else(|| { - rt::errors::Error::wrap( - abi::consts::VmError::out_of().memory().val(), - anyhow::anyhow!("incrementing storage page count"), - ) - })?; - if !self.mem.consume(memory_limiter_consts::NEW_STORAGE_PAGE) { - return Err(rt::errors::Error::wrap( - abi::consts::VmError::out_of().memory().val(), - anyhow::anyhow!("allocating storage page override"), - )); - } + let allocation = self + .mem + .reserve_permanent(memory_limiter_consts::NEW_STORAGE_PAGE.into()) + .ok_or_else(|| { + rt::errors::Error::wrap( + abi::consts::VmError::out_of().memory().val(), + anyhow::anyhow!("allocating storage page override"), + ) + })?; // Memory is charged first so a write refused for want of RAM costs no // fee; a write refused for want of fee must likewise cost no memory. - if let Err(e) = self.fee.consume(1).await { - self.mem.release(memory_limiter_consts::NEW_STORAGE_PAGE); - return Err(e); - } - self.new_pages = new_pages; + self.fee.consume(1).await?; + allocation.commit(); } self.pages = self.pages.insert(key, value); @@ -171,34 +163,22 @@ impl StoragePagesOverride { pages: self.pages.clone(), fee: self.fee.clone(), mem, - new_pages: 0, }) } fn fold(&mut self, child: Self) -> rt::errors::Result<()> { - // The parent pays only when it keeps the child's new pages. - let new_pages = self.new_pages.checked_add(child.new_pages).ok_or_else(|| { - rt::errors::Error::fatal_vm_cause( - abi::consts::VmError::out_of().memory().val(), - Some(anyhow::anyhow!("folding storage page count")), - ) - })?; - let charged = self - .mem - .consume_mul(child.new_pages, memory_limiter_consts::NEW_STORAGE_PAGE); + let charged = self.mem.fold_permanent(&child.mem); // Unreachable: the child's budget is a snapshot of ours taken at spawn, - // and it paid VM_SPAWN_COST out of it before writing a page, so what it - // owes us is strictly less than what we still hold. Getting here means a - // security researcher broke that invariant, so it is fatal and uncatchable. - debug_assert!(charged, "storage fold charge exceeded the parent budget"); + // and it paid VM_SPAWN_COST out of it before retaining anything, so what + // it owes us is strictly less than what we still hold. + debug_assert!(charged, "permanent fold charge exceeded the parent budget"); if !charged { return Err(rt::errors::Error::fatal_vm_cause( abi::consts::VmError::out_of().memory().val(), - Some(anyhow::anyhow!("folding storage page overrides")), + Some(anyhow::anyhow!("folding permanent allocations")), )); } self.pages = child.pages; - self.new_pages = new_pages; Ok(()) } } diff --git a/executor/src/wasi/genlayer_sdk/message.rs b/executor/src/wasi/genlayer_sdk/message.rs index 20919f4d..9742f7a7 100644 --- a/executor/src/wasi/genlayer_sdk/message.rs +++ b/executor/src/wasi/genlayer_sdk/message.rs @@ -383,6 +383,11 @@ impl ContextVFS<'_> { let calldata_length = calldata.len().into_int_comptime(); let matched_params = convert_external_message_params_to_sdk(matched_params); + let allocation = reserve_permanent( + &self.context.limiter, + emission_allocation_size(&[calldata_length]), + "external message", + )?; let fees = consume_message_fee_external( &self.context.data.supervisor.shared_data, @@ -408,6 +413,7 @@ impl ContextVFS<'_> { receipt_fee: fees.receipt_fee.reported_fee(), fee_params: matched_params, }); + allocation.commit(); self.context.data.accumulator.messages_value_decremented = self .context @@ -471,6 +477,15 @@ impl ContextVFS<'_> { let supervisor = self.context.data.supervisor.clone(); let topics_count = topics.len().into_int_comptime(); + let topics_size = topics + .iter() + .map(|topic| usize_into_u64(topic.len())) + .sum::(); + let allocation = reserve_permanent( + &self.context.limiter, + emission_allocation_size(&[blob_size, topics_size]), + "event", + )?; let storage_fee = supervisor .shared_data @@ -493,6 +508,7 @@ impl ContextVFS<'_> { blob, storage_fee, }); + allocation.commit(); Ok(file_fd_none()) } @@ -540,6 +556,13 @@ impl ContextVFS<'_> { let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); let calldata_length = enc.into_inner().0; + let rotations_size = usize_into_u64(params.rotations.len()) + .saturating_mul(memory_limiter_consts::MESSAGE_FEE_ROTATION_ELEMENT_SIZE.into()); + let allocation = reserve_permanent( + &self.context.limiter, + emission_allocation_size(&[calldata_length, rotations_size]), + "internal message", + )?; let my_balance = self .context @@ -583,6 +606,7 @@ impl ContextVFS<'_> { use_balance: true, }, ); + allocation.commit(); self.context.data.accumulator.messages_value_decremented = messages_value_decremented .saturating_add(value) @@ -652,6 +676,17 @@ impl ContextVFS<'_> { &matched_node.children, ), ); + let rotations_size = usize_into_u64(fee_params.rotations.len()) + .saturating_mul(memory_limiter_consts::MESSAGE_FEE_ROTATION_ELEMENT_SIZE.into()); + let allocation = reserve_permanent( + &self.context.limiter, + emission_allocation_size(&[ + calldata_length, + subtree.len().into_int_comptime(), + rotations_size, + ]), + "internal message", + )?; let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, @@ -683,6 +718,7 @@ impl ContextVFS<'_> { subtree, use_balance: false, }); + allocation.commit(); log_debug!( depth = self.context.data.depth(), @@ -735,6 +771,13 @@ impl ContextVFS<'_> { let mut enc = calldata::Encoder::new(calldata::CounterWriter(0)); calldata::codec::Encode::encode(&calldata, &mut enc).unwrap_or_else(|e| match e {}); let calldata_length = enc.into_inner().0; + let rotations_size = usize_into_u64(params.rotations.len()) + .saturating_mul(memory_limiter_consts::MESSAGE_FEE_ROTATION_ELEMENT_SIZE.into()); + let allocation = reserve_permanent( + &self.context.limiter, + emission_allocation_size(&[calldata_length, code_length, rotations_size]), + "internal deploy message", + )?; let my_balance = self .context @@ -777,6 +820,7 @@ impl ContextVFS<'_> { use_balance: true, }, ); + allocation.commit(); self.context.data.accumulator.messages_value_decremented = messages_value_decremented .saturating_add(value) @@ -840,6 +884,18 @@ impl ContextVFS<'_> { &matched_node.children, ), ); + let rotations_size = usize_into_u64(fee_params.rotations.len()) + .saturating_mul(memory_limiter_consts::MESSAGE_FEE_ROTATION_ELEMENT_SIZE.into()); + let allocation = reserve_permanent( + &self.context.limiter, + emission_allocation_size(&[ + calldata_length, + code_length, + subtree.len().into_int_comptime(), + rotations_size, + ]), + "internal deploy message", + )?; let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, @@ -869,6 +925,7 @@ impl ContextVFS<'_> { use_balance: false, }, ); + allocation.commit(); self.context.data.accumulator.messages_value_decremented = self .context diff --git a/executor/src/wasi/genlayer_sdk/mod.rs b/executor/src/wasi/genlayer_sdk/mod.rs index 67c0a266..cf5fd34f 100644 --- a/executor/src/wasi/genlayer_sdk/mod.rs +++ b/executor/src/wasi/genlayer_sdk/mod.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::sync::Arc; -use genvm_common::internal_constants::top_limits; +use genvm_common::internal_constants::{memory_limiter_consts, top_limits}; use genvm_common::sync::DArc; use genvm_common::*; @@ -35,6 +35,30 @@ fn internal_trap(error: rt::errors::Error) -> generated::types::Error { generated::types::Error::trap(anyhow_to_wasmtime(error.into())) } +fn reserve_permanent( + limiter: &rt::memlimiter::Limiter, + amount: u64, + allocation: &'static str, +) -> Result { + limiter.reserve_permanent(amount).ok_or_else(|| { + internal_trap(rt::errors::Error::wrap( + abi::consts::VmError::out_of().memory().val(), + anyhow::anyhow!("retaining {amount} bytes for {allocation}"), + )) + }) +} + +fn emission_allocation_size(payload_sizes: &[u64]) -> u64 { + payload_sizes + .iter() + .copied() + .try_fold( + memory_limiter_consts::EXECUTION_EMISSION_BASE_SIZE.into(), + u64::checked_add, + ) + .unwrap_or(u64::MAX) +} + /// Extension methods for ExtendedMessage specific to the executor pub trait ExtendedMessageExt { fn fork_leader( diff --git a/executor/src/wasi/genlayer_sdk/run.rs b/executor/src/wasi/genlayer_sdk/run.rs index d77ac9d3..994fbf72 100644 --- a/executor/src/wasi/genlayer_sdk/run.rs +++ b/executor/src/wasi/genlayer_sdk/run.rs @@ -730,14 +730,21 @@ impl ContextVFS<'_> { proposal.into_result_and_encoding() }; - // Retention precedes the charge, so a validator replaying a run that + // Retention precedes the fee charge, so a validator replaying a run that // ran out of fee here sees the same result the leader charged for. if is_leader { + let allocation = reserve_permanent( + &self.context.limiter, + usize_into_u64(encoded.as_slice().len()) + .saturating_add(memory_limiter_consts::NONDET_OUTPUT_BASE_SIZE.into()), + "nondeterministic output", + )?; self.context .data .supervisor .push_nondet_result(call_no, encoded.clone()) .await; + allocation.commit(); } consume_nondet_output( diff --git a/executor/src/wasi/genlayer_sdk/tests.rs b/executor/src/wasi/genlayer_sdk/tests.rs index 8fd573c6..6476647c 100644 --- a/executor/src/wasi/genlayer_sdk/tests.rs +++ b/executor/src/wasi/genlayer_sdk/tests.rs @@ -23,6 +23,19 @@ fn errno(e: generated::types::Error) -> generated::types::Errno { e.downcast().expect("expected a plain errno, got a trap") } +#[test] +fn emission_allocation_includes_fixed_and_payload_costs() { + assert_eq!( + emission_allocation_size(&[3, 5]), + u64::from(memory_limiter_consts::EXECUTION_EMISSION_BASE_SIZE) + 8 + ); +} + +#[test] +fn emission_allocation_overflow_cannot_fit_the_budget() { + assert_eq!(emission_allocation_size(&[u64::MAX]), u64::MAX); +} + #[test] fn balance_no_permission_is_forbidden() { let err = validate_balance_fee(false, true, Some(valid_params())).unwrap_err(); diff --git a/executor/tests/permanent_memory_accounting.rs b/executor/tests/permanent_memory_accounting.rs new file mode 100644 index 00000000..41a4b13c --- /dev/null +++ b/executor/tests/permanent_memory_accounting.rs @@ -0,0 +1,63 @@ +use genvm::rt::memlimiter::Limiter; + +#[test] +fn uncommitted_allocation_is_released() { + let limiter = Limiter::with_limit(100); + + let allocation = limiter.reserve_permanent(40).unwrap(); + assert_eq!(limiter.get_remaining_memory(), 60); + assert_eq!(limiter.get_new_permanent_allocations(), 40); + + drop(allocation); + assert_eq!(limiter.get_remaining_memory(), 100); + assert_eq!(limiter.get_new_permanent_allocations(), 0); +} + +#[test] +fn committed_allocation_is_retained() { + let limiter = Limiter::with_limit(100); + + limiter.reserve_permanent(40).unwrap().commit(); + + assert_eq!(limiter.get_remaining_memory(), 60); + assert_eq!(limiter.get_new_permanent_allocations(), 40); +} + +#[test] +fn derived_limiter_starts_without_permanent_allocations() { + let parent = Limiter::with_limit(100); + parent.reserve_permanent(40).unwrap().commit(); + + let child = parent.derived(); + + assert_eq!(child.get_remaining_memory(), 60); + assert_eq!(child.get_new_permanent_allocations(), 0); +} + +#[test] +fn nested_folds_transfer_permanent_allocations_once_per_level() { + let parent = Limiter::with_limit(100); + let child = parent.derived(); + child.reserve_permanent(30).unwrap().commit(); + let grandchild = child.derived(); + grandchild.reserve_permanent(20).unwrap().commit(); + + assert!(child.fold_permanent(&grandchild)); + assert_eq!(child.get_remaining_memory(), 50); + assert_eq!(child.get_new_permanent_allocations(), 50); + + assert!(parent.fold_permanent(&child)); + assert_eq!(parent.get_remaining_memory(), 50); + assert_eq!(parent.get_new_permanent_allocations(), 50); +} + +#[test] +fn failed_fold_keeps_propagated_parent_counter() { + let parent = Limiter::with_limit(10); + let child = Limiter::with_limit(20); + child.reserve_permanent(15).unwrap().commit(); + + assert!(!parent.fold_permanent(&child)); + assert_eq!(parent.get_remaining_memory(), 10); + assert_eq!(parent.get_new_permanent_allocations(), 15); +} From ee7b55133b637acd22118ed06ff52f233b21366a Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Mon, 31 Aug 2026 23:50:35 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(executor):=20enforce=20nondeterministic?= =?UTF-8?q?=20output=20caps=20=F0=9F=94=92=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/src/rt/fees.rs | 52 +- executor/src/wasi/genlayer_sdk/run.rs | 307 +++++-- executor/src/wasi/genlayer_sdk/tests.rs | 786 +++++++++++++++++- executor/tests/nondet_output_fees.rs | 50 ++ .../output_fee_cap/output_fee_cap.0.stdout | 1 + .../output_fee_cap/output_fee_cap.0_0.stdout | 2 + .../output_fee_cap/output_fee_cap.jsonnet | 17 + .../output_fee_cap/output_fee_cap.py | 20 + 8 files changed, 1157 insertions(+), 78 deletions(-) create mode 100644 executor/tests/nondet_output_fees.rs create mode 100644 tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0.stdout create mode 100644 tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_0.stdout create mode 100644 tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet create mode 100644 tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.py diff --git a/executor/src/rt/fees.rs b/executor/src/rt/fees.rs index 9b418770..2f56dc62 100644 --- a/executor/src/rt/fees.rs +++ b/executor/src/rt/fees.rs @@ -358,6 +358,28 @@ impl DataLimit { async fn consume_bucket_raw(&self, bucket: &Bucket, costs: &[primitive_types::U256]) -> bool { let mut buckets = self.buckets.lock().await; + if !Self::bucket_costs_fit(&buckets, bucket, costs) { + return false; + } + for (i, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() { + buckets[usize::from(bno)] -= cost; + log_debug!( + bucket = bno, + cost:display = cost, + remaining:display = buckets[usize::from(bno)]; + "consume_bucket: ok" + ); + *bucket.total_consumed[i].lock().await += cost; + } + std::mem::drop(buckets); + true + } + + fn bucket_costs_fit( + buckets: &[primitive_types::U256], + bucket: &Bucket, + costs: &[primitive_types::U256], + ) -> bool { for (idx, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() { let Some(remaining) = buckets.get(usize::from(bno)) else { log_warn!(bucket = bno; "consume_bucket: bucket index out of range"); @@ -391,20 +413,19 @@ impl DataLimit { return false; } } - for (i, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() { - buckets[usize::from(bno)] -= cost; - log_debug!( - bucket = bno, - cost:display = cost, - remaining:display = buckets[usize::from(bno)]; - "consume_bucket: ok" - ); - *bucket.total_consumed[i].lock().await += cost; - } - std::mem::drop(buckets); true } + async fn can_consume_bucket( + &self, + bucket: &Bucket, + vars: &[(&str, genvm_common::expr::Value)], + ) -> rt::errors::Result { + let costs = self.calculate_bucket(bucket, vars)?; + let buckets = self.buckets.lock().await; + Ok(Self::bucket_costs_fit(&buckets, bucket, &costs.0)) + } + pub async fn remaining(&self) -> Vec { self.buckets.lock().await.clone() } @@ -486,6 +507,15 @@ impl DataLimit { .ctx("consuming nondet output") } + pub async fn can_consume_nondet_output(&self, output_length: u64) -> rt::errors::Result { + self.can_consume_bucket( + &self.nondet_output, + &[("outputLength", output_length.into())], + ) + .await + .ctx("checking nondet output") + } + pub async fn consume_event( &self, blob_size: u64, diff --git a/executor/src/wasi/genlayer_sdk/run.rs b/executor/src/wasi/genlayer_sdk/run.rs index 994fbf72..8cdb1816 100644 --- a/executor/src/wasi/genlayer_sdk/run.rs +++ b/executor/src/wasi/genlayer_sdk/run.rs @@ -3,23 +3,149 @@ use crate::runners; use sha3::digest::Update; use std::sync::Arc; -async fn consume_nondet_output( - shared_data: &rt::SharedData, - output_length: u64, -) -> Result<(), generated::types::Error> { - if !shared_data - .data_fees_limit +async fn consume_preflighted_nondet_output(fees: &rt::fees::DataLimit, output_length: u64) { + let consumed = fees .consume_nondet_output(output_length) .await - .map_err(internal_trap)? - { - return Err(internal_trap(rt::errors::Error::vm( - abi::consts::VmError::out_of().receipt().nondet_output(), - ))); + .expect("preflighted nondeterministic output fee evaluation must succeed"); + assert!( + consumed, + "preflighted nondeterministic output fee must remain available" + ); +} + +async fn can_consume_nondet_output( + fees: &rt::fees::DataLimit, + output_length: u64, +) -> Result { + fees.can_consume_nondet_output(output_length) + .await + .map_err(internal_trap) +} + +pub(super) struct NondetOutput { + pub(super) result: rt::vm::RunOk, + pub(super) encoded: rt::vm::ContractResultBytes, +} + +impl NondetOutput { + pub(super) fn from_outcome(outcome: rt::vm::ContractOutcome) -> Self { + Self { + result: outcome.duplicate().into(), + encoded: outcome.encode(), + } + } + + pub(super) fn vm_error(error: public_abi::VmError) -> Self { + Self::from_outcome(rt::vm::ContractOutcome::VMError(error, None)) + } + + fn duplicate(&self) -> Self { + Self { + result: self.result.duplicate(), + encoded: self.encoded.clone(), + } + } + + fn is_fatal(&self) -> bool { + matches!(&self.result, rt::vm::RunOk::FatalVMError(..)) + } + + fn duplicate_preserving_fatality_of(&self, source: &Self) -> Self { + let mut output = self.duplicate(); + if source.is_fatal() { + let rt::vm::RunOk::VMError(error, _) = output.result else { + unreachable!("canonical nondeterministic errors are non-fatal") + }; + output.result = rt::vm::RunOk::FatalVMError(error, None); + } + output + } + + pub(super) fn allocation_size(&self) -> u64 { + usize_into_u64(self.encoded.as_slice().len()) + .saturating_add(memory_limiter_consts::NONDET_OUTPUT_BASE_SIZE.into()) + } + + pub(super) fn preflight_ram_size(&self) -> u64 { + self.allocation_size() + .saturating_add(usize_into_u64(self.encoded.as_slice().len())) + .saturating_add(memory_limiter_consts::FD_ALLOCATION.into()) + } +} + +pub(super) fn reserve_nondet_output( + limiter: &rt::memlimiter::Limiter, + output: NondetOutput, + memory_error: &NondetOutput, +) -> Result<(NondetOutput, rt::memlimiter::PermanentAllocation), generated::types::Error> { + match limiter.reserve_permanent(output.allocation_size()) { + Some(allocation) => Ok((output, allocation)), + None => { + let output = memory_error.duplicate_preserving_fatality_of(&output); + let allocation = reserve_permanent( + limiter, + output.allocation_size(), + "nondeterministic memory error", + )?; + Ok((output, allocation)) + } + } +} + +pub(super) fn preflight_nondet_output_ram( + limiter: &rt::memlimiter::Limiter, + memory_error: &NondetOutput, + fee_error: &NondetOutput, +) -> Result<(), generated::types::Error> { + let fallback_size = memory_error + .preflight_ram_size() + .max(fee_error.preflight_ram_size()); + drop(reserve_permanent( + limiter, + fallback_size, + "nondeterministic fallback output", + )?); + Ok(()) +} + +pub(super) async fn preflight_nondet_output_fees( + fees: &rt::fees::DataLimit, + memory_error: &NondetOutput, + fee_error: &NondetOutput, +) -> Result<(), generated::types::Error> { + for error in [memory_error, fee_error] { + if !can_consume_nondet_output(fees, error.encoded.as_slice().len().into_int_comptime()) + .await? + { + return Err(internal_trap(rt::errors::Error::vm( + abi::consts::VmError::out_of().receipt().nondet_output(), + ))); + } } Ok(()) } +pub(super) async fn charge_nondet_output( + limiter: &rt::memlimiter::Limiter, + fees: &rt::fees::DataLimit, + output: NondetOutput, + memory_error: &NondetOutput, + fee_error: &NondetOutput, +) -> Result { + let mut output = output; + if !can_consume_nondet_output(fees, output.encoded.as_slice().len().into_int_comptime()).await? + { + output = fee_error.duplicate_preserving_fatality_of(&output); + } + + let (output, allocation) = reserve_nondet_output(limiter, output, memory_error)?; + consume_preflighted_nondet_output(fees, output.encoded.as_slice().len().into_int_comptime()) + .await; + allocation.commit(); + Ok(output) +} + /// Is this leader-proposed `vm_error` code acceptable as-is? /// /// `Err` carries the code a validator derives instead -- either @@ -165,6 +291,17 @@ pub(super) fn leader_proposal_for_validation(data: &[u8]) -> LeaderProposal { } } +pub(super) fn validate_leader_output_after_caps( + output: &rt::vm::ContractResultBytes, + leader_proposed: &rt::vm::ContractResultBytes, +) -> Result<(), public_abi::VmError> { + if output == leader_proposed { + Ok(()) + } else { + Err(malformed_leader_result()) + } +} + struct RunNondetGetVMTaskArgs { child_topmost_id: runners::Id, child_limiter: rt::memlimiter::Limiter, @@ -630,6 +767,17 @@ impl ContextVFS<'_> { ) .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e)))?; + let memory_error = NondetOutput::vm_error(abi::consts::VmError::out_of().memory().val()); + let fee_error = + NondetOutput::vm_error(abi::consts::VmError::out_of().receipt().nondet_output()); + preflight_nondet_output_ram(&self.context.limiter, &memory_error, &fee_error)?; + preflight_nondet_output_fees( + &self.context.data.supervisor.shared_data.data_fees_limit, + &memory_error, + &fee_error, + ) + .await?; + let call_no = self .context .data @@ -643,36 +791,40 @@ impl ContextVFS<'_> { ))); } - // The child gets its own budget, seeded with what the caller has left at - // this point: charges never flow back, and a queued nondet VM keeps a - // usable budget after its parent has died. - let child_limiter = self.context.limiter.derived(); - - let storage_checkpoint = self - .context - .data - .storage - .fork(child_limiter.clone()) - .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; - - let run_nondet_get_vm_task_args = RunNondetGetVMTaskArgs { - child_topmost_id, - storage_checkpoint, - child_limiter, - child_custom, - call_no, - }; - let is_leader = self.context.data.supervisor.shared_data.run_mode == rt::RunMode::Leader; - - let (result_to_return, encoded) = if is_leader { + let mut child_resources = Some((child_topmost_id, child_custom)); + // The child gets the caller's budget before this block's output charge. + // The snapshot also keeps queued validator work independent of its parent. + let mut child_limiter = Some(self.context.limiter.derived()); + let mut validator_proposal = None; + + let output = if is_leader { + let (child_topmost_id, child_custom) = child_resources + .take() + .expect("nondeterministic child resources are available"); + let child_limiter = child_limiter + .take() + .expect("nondeterministic child limiter is available"); + let storage_checkpoint = self + .context + .data + .storage + .fork(child_limiter.clone()) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; + let task_args = RunNondetGetVMTaskArgs { + child_topmost_id, + storage_checkpoint, + child_limiter, + child_custom, + call_no, + }; let vm_ext_msg = self.context.data.message_data.fork_leader( public_abi::EntryKind::ConsensusStage, data_leader, None, ); - let task = self.run_nondet_get_vm_task(vm_ext_msg, run_nondet_get_vm_task_args); + let task = self.run_nondet_get_vm_task(vm_ext_msg, task_args); let computed_result = task .run_now(&self.context.data.supervisor) @@ -681,9 +833,7 @@ impl ContextVFS<'_> { let computed_result = leader_outcome_for_publication(computed_result) .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; - let encoded = computed_result.encode(); - - (computed_result.into(), encoded) + NondetOutput::from_outcome(computed_result) } else { let leaders_res_bytes = self .context @@ -713,47 +863,76 @@ impl ContextVFS<'_> { if self.context.data.supervisor.shared_data.run_mode == rt::RunMode::Validator => { - let vm_ext_msg = self.context.data.message_data.fork_leader( - public_abi::EntryKind::ConsensusStage, - data_validator, - Some(leaders_res.duplicate()), - ); - - let task = self.run_nondet_get_vm_task(vm_ext_msg, run_nondet_get_vm_task_args); - - rt::supervisor::submit_nondet_vm_task(&self.context.data.supervisor, task) - .await; + validator_proposal = Some(leaders_res.duplicate()); } LeaderProposal::Accepted(_) => {} } - proposal.into_result_and_encoding() + let (result, encoded) = proposal.into_result_and_encoding(); + NondetOutput { result, encoded } }; - // Retention precedes the fee charge, so a validator replaying a run that - // ran out of fee here sees the same result the leader charged for. + let leader_proposed_encoding = output.encoded.clone(); + let output = charge_nondet_output( + &self.context.limiter, + &self.context.data.supervisor.shared_data.data_fees_limit, + output, + &memory_error, + &fee_error, + ) + .await?; + + if let Some(leaders_res) = validator_proposal { + if let Err(error) = + validate_leader_output_after_caps(&output.encoded, &leader_proposed_encoding) + { + rt::supervisor::mark_nondet_disagreement(&self.context.data.supervisor, call_no); + return Err(generated::types::Error::trap(crate::anyhow_to_wasmtime( + rt::errors::Error::fatal_vm(error).into(), + ))); + } + + let (child_topmost_id, child_custom) = child_resources + .take() + .expect("nondeterministic child resources are available"); + let child_limiter = child_limiter + .take() + .expect("nondeterministic child limiter is available"); + let storage_checkpoint = self + .context + .data + .storage + .fork(child_limiter.clone()) + .map_err(|e| generated::types::Error::trap(crate::anyhow_to_wasmtime(e.into())))?; + let task_args = RunNondetGetVMTaskArgs { + child_topmost_id, + storage_checkpoint, + child_limiter, + child_custom, + call_no, + }; + let vm_ext_msg = self.context.data.message_data.fork_leader( + public_abi::EntryKind::ConsensusStage, + data_validator, + Some(leaders_res), + ); + let task = self.run_nondet_get_vm_task(vm_ext_msg, task_args); + rt::supervisor::submit_nondet_vm_task(&self.context.data.supervisor, task).await; + } + if is_leader { - let allocation = reserve_permanent( - &self.context.limiter, - usize_into_u64(encoded.as_slice().len()) - .saturating_add(memory_limiter_consts::NONDET_OUTPUT_BASE_SIZE.into()), - "nondeterministic output", - )?; self.context .data .supervisor - .push_nondet_result(call_no, encoded.clone()) + .push_nondet_result(call_no, output.encoded.clone()) .await; - allocation.commit(); } - consume_nondet_output( - &self.context.data.supervisor.shared_data, - encoded.as_slice().len().into_int_comptime(), + self.publish_sub_vm_result_encoded( + output.result, + output.encoded.into_bytes(), + catch_vm_error, ) - .await?; - - self.publish_sub_vm_result_encoded(result_to_return, encoded.into_bytes(), catch_vm_error) } pub(super) async fn sandbox( diff --git a/executor/src/wasi/genlayer_sdk/tests.rs b/executor/src/wasi/genlayer_sdk/tests.rs index 6476647c..952d5eb4 100644 --- a/executor/src/wasi/genlayer_sdk/tests.rs +++ b/executor/src/wasi/genlayer_sdk/tests.rs @@ -1,10 +1,13 @@ use super::message::{validate_balance_fee, FEE_PARAM_COUNT_BITS, FEE_PARAM_PRICE_BITS}; use super::run::{ - call_contract_route, derive_call_contract_permissions, leader_outcome_for_publication, - leader_proposal_for_validation, nested_run_ok, parse_leader_result, strip_vm_error_detail, - CallContractRoute, LeaderProposal, + call_contract_route, charge_nondet_output, derive_call_contract_permissions, + leader_outcome_for_publication, leader_proposal_for_validation, nested_run_ok, + parse_leader_result, preflight_nondet_output_fees, preflight_nondet_output_ram, + reserve_nondet_output, strip_vm_error_detail, validate_leader_output_after_caps, + CallContractRoute, LeaderProposal, NondetOutput, }; use super::*; +use genvm_common::Bytes32Hash; use primitive_types::U256; fn valid_params() -> abi::fees::InternalMessageParams { @@ -23,6 +26,417 @@ fn errno(e: generated::types::Error) -> generated::types::Errno { e.downcast().expect("expected a plain errno, got a trap") } +fn nondet_fees_with_delta(total: u64, nondet_delta: &str) -> rt::fees::DataLimit { + let bucket = |delta: &str| crate::config::FeesBucketConfig { + bucket_no: vec![0], + subtract_on_start_expr: "0".to_owned(), + delta_expr: delta.to_owned(), + }; + rt::fees::DataLimit::new( + vec![U256::from(total)], + crate::config::FeesConfig { + expr_prelude: String::new(), + storage: bucket("\\attrs = 0"), + message_receipt: bucket("\\attrs = 0"), + nondet_output: bucket(nondet_delta), + message_fee: bucket("\\attrs = 0"), + event: bucket("\\attrs = 0"), + }, + Default::default(), + ) + .unwrap() +} + +fn nondet_fees(total: u64) -> rt::fees::DataLimit { + nondet_fees_with_delta(total, "\\attrs = attrs.outputLength") +} + +fn emission_fees() -> crate::config::FeesConfig { + let bucket = |delta: &str| crate::config::FeesBucketConfig { + bucket_no: vec![0], + subtract_on_start_expr: "0".to_owned(), + delta_expr: delta.to_owned(), + }; + crate::config::FeesConfig { + expr_prelude: String::new(), + storage: bucket("\\attrs = 0"), + message_receipt: bucket("\\attrs = 1"), + nondet_output: bucket("\\attrs = 0"), + message_fee: bucket("\\attrs = 1"), + event: bucket("\\attrs = 1"), + } +} + +fn external_message_allocation() -> genvm_modules_interfaces::fees::MessageAllocationNode { + genvm_modules_interfaces::fees::MessageAllocationNode { + recipient: None, + call_key: None, + budget: U256::from(100), + on: genvm_modules_interfaces::On::Finalized, + fee_params: genvm_modules_interfaces::fees::MessageAllocationNodeParams::External( + genvm_modules_interfaces::fees::ExternalMessageParams { + gas_limit: U256::zero(), + max_gas_price: U256::zero(), + }, + ), + children: Vec::new(), + } +} + +fn internal_message_allocation() -> genvm_modules_interfaces::fees::MessageAllocationNode { + genvm_modules_interfaces::fees::MessageAllocationNode { + recipient: None, + call_key: None, + budget: U256::from(100), + on: genvm_modules_interfaces::On::Finalized, + fee_params: genvm_modules_interfaces::fees::MessageAllocationNodeParams::Internal( + Arc::new(genvm_modules_interfaces::fees::InternalMessageParams { + leader_timeunits_allocation: U256::one(), + validator_timeunits_allocation: U256::one(), + execution_budget_per_round: U256::one(), + rotations: vec![U256::one()], + max_price_gen_per_time_unit: U256::one(), + storage_fee_max_gas_price: U256::one(), + receipt_fee_max_gas_price: U256::one(), + }), + ), + children: Vec::new(), + } +} + +struct TestDir(std::path::PathBuf); + +impl TestDir { + fn new() -> Self { + static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + loop { + let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir() + .join(format!("genvm-emission-test-{}-{id}", std::process::id())); + match std::fs::create_dir(&root) { + Ok(()) => return Self(root), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => panic!("creating emission test directory: {error}"), + } + } + } +} + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[derive(Clone, Copy)] +enum MessageEmission { + External, + InternalAllocation, + InternalBalance, + DeployAllocation, + DeployBalance, +} + +impl MessageEmission { + const ALL: [Self; 5] = [ + Self::External, + Self::InternalAllocation, + Self::InternalBalance, + Self::DeployAllocation, + Self::DeployBalance, + ]; + + fn name(self) -> &'static str { + match self { + Self::External => "external message", + Self::InternalAllocation => "allocation-funded internal message", + Self::InternalBalance => "balance-funded internal message", + Self::DeployAllocation => "allocation-funded deploy message", + Self::DeployBalance => "balance-funded deploy message", + } + } + + fn fee_error(self) -> &'static str { + match self { + Self::External => "out_of message_fee total # external", + Self::InternalAllocation | Self::DeployAllocation => { + "out_of message_fee total # internal" + } + Self::InternalBalance | Self::DeployBalance => "out_of receipt message # internal", + } + } +} + +struct EmissionTestContext { + _host_peer: std::os::unix::net::UnixStream, + vfs: vfs::VFS, + preview1: super::super::preview1::Context, + context: Context, + _root: TestDir, +} + +impl EmissionTestContext { + fn new(memory_limit: u32, fee_total: u64) -> Self { + let root = TestDir::new(); + let runners_dir = root.join("runners"); + let registry_dir = root.join("registry"); + std::fs::create_dir_all(&runners_dir).unwrap(); + std::fs::create_dir_all(®istry_dir).unwrap(); + std::fs::write(registry_dir.join("all.json"), "{}").unwrap(); + + let fees = emission_fees(); + let shared_data = sync::DArc::new(rt::SharedData { + run_mode: rt::RunMode::Leader, + genvm_id: genvm_modules_interfaces::GenVMId(0), + debug_mode: genvm_common::DebugMode::Disabled, + metrics: crate::Metrics { + hosts: vec![Default::default()].into_boxed_slice(), + ..Default::default() + }, + data_fees_limit: rt::fees::DataLimit::new( + vec![U256::from(fee_total)], + fees.clone(), + Default::default(), + ) + .unwrap(), + det_fuel_budget: rt::DetFuelBudget::new(None), + llm_consumption: tokio::sync::Mutex::new(U256::zero()), + }); + let host_data = genvm_modules_interfaces::HostData { + node_address: String::new(), + tx_id: String::new(), + rest: Default::default(), + }; + let module = |name: &str, metrics| { + Arc::new(crate::modules::Module::new( + crate::modules::ModuleNamedArgs { + name: name.to_owned(), + url: "127.0.0.1:1".to_owned(), + gas_data: Default::default(), + initial_time_units_allocation: 0, + }, + genvm_modules_interfaces::GenVMId(0), + genvm_modules_interfaces::Role::Leader, + host_data.clone(), + metrics, + )) + }; + let modules = crate::modules::All { + web: module("web", shared_data.gep(|data| &data.metrics.web_module)), + llm: module("llm", shared_data.gep(|data| &data.metrics.llm_module)), + }; + let (host_stream, host_peer) = std::os::unix::net::UnixStream::pair().unwrap(); + let host = host::Host::new( + Box::new(bufreaderwriter::seq::BufReaderWriterSeq::new_writer( + host_stream, + )), + shared_data.gep(|data| &data.metrics.hosts[0]), + ); + let config = crate::config::Config { + modules: crate::config::Modules { + llm: crate::config::Module { + address: "127.0.0.1:1".to_owned(), + }, + web: crate::config::Module { + address: "127.0.0.1:1".to_owned(), + }, + }, + fees, + cache_dir: root.join("cache").to_string_lossy().into_owned(), + runners_dir: runners_dir.to_string_lossy().into_owned(), + registry_dir: registry_dir.to_string_lossy().into_owned(), + base: genvm_common::BaseConfig { + threads: 1, + blocking_threads: 1, + log_level: genvm_common::logger::Level::Info, + log_disable: String::new(), + }, + }; + let supervisor = rt::supervisor::Supervisor::start( + &config, + rt::supervisor::Ctor { + shared_data: shared_data.clone(), + modules, + locked_slots: host::LockedSlotsSet::empty(), + leader_nondet_results: None, + emit_leader_public_data: false, + multi_host: host::MultiHost::new(vec![host], Vec::new()), + record_actions: Vec::new(), + }, + ) + .unwrap(); + supervisor + .balances + .insert(calldata::Address::zero(), U256::MAX); + + let limiter = rt::memlimiter::Limiter::with_limit(memory_limit); + let permissions = base::Permissions { + deterministic: true, + write_storage: true, + send_messages: true, + call_others: false, + spawn_nondet: false, + can_use_balance_for_message_fees: true, + }; + let conf = base::Config { + needs_error_fingerprint: false, + permissions, + execution: base::Execution { + state_mode: public_abi::StorageView::Default, + topmost_runner_id: crate::runners::Id::Custom { + hash: Bytes32Hash::ZERO, + }, + }, + }; + let address = calldata::Address::zero(); + let storage = rt::vm::storage::Storage::new( + address, + supervisor.get_storage_limiter(), + limiter.clone(), + StorageHostHolder( + supervisor.host.clone(), + ReadToken { + mode: public_abi::StorageView::Default, + account: address, + }, + ), + ); + let message_data = ExtendedMessage { + message: abi::entry::MessageData { + contract_address: address, + sender_address: address, + origin_address: address, + signer_address: address, + stack: Vec::new(), + chain_id: num_bigint::BigInt::from(0), + value: num_bigint::BigInt::from(0), + is_init: false, + datetime: chrono::DateTime::from_timestamp(0, 0).unwrap(), + }, + entry_kind: public_abi::EntryKind::Main, + entry_data: bytes::Bytes::new(), + entry_stage_data: calldata::Value::Null, + }; + let context = Context { + data: SingleVMData { + conf: conf.clone(), + limiter: limiter.clone(), + remaining_recursion: top_limits::VM_RECURSION, + spawn_kind: "test".to_owned(), + message_data, + supervisor: supervisor.clone(), + storage, + accumulator: VMDataAccumulator { + data_fees_limit: shared_data.gep(|data| &data.data_fees_limit), + messages_value_decremented: U256::zero(), + emissions: Vec::new(), + message_fee_allocation: vec![ + external_message_allocation(), + internal_message_allocation(), + ], + }, + det_subvm_hashes: Default::default(), + granted_custom: Vec::new(), + }, + loaded: Default::default(), + limiter: limiter.clone(), + start_time: std::time::Instant::now(), + prev_time: std::time::Instant::now(), + }; + + Self { + _host_peer: host_peer, + vfs: vfs::VFS::new(Vec::new(), limiter).unwrap(), + preview1: super::super::preview1::Context::new( + chrono::DateTime::from_timestamp(0, 0).unwrap(), + conf, + [0; 32], + ), + context, + _root: root, + } + } + + fn wasi(&mut self) -> ContextVFS<'_> { + ContextVFS { + vfs: &mut self.vfs, + preview1: &mut self.preview1, + context: &mut self.context, + } + } + + async fn emit_message( + &mut self, + emission: MessageEmission, + ) -> Result { + let mut wasi = self.wasi(); + match emission { + MessageEmission::External => { + wasi.gl_call_emit_external_message( + calldata::Address::zero(), + bytes::Bytes::new(), + U256::zero(), + ) + .await + } + MessageEmission::InternalAllocation | MessageEmission::InternalBalance => { + let use_balance = matches!(emission, MessageEmission::InternalBalance); + wasi.gl_call_emit_internal_message( + calldata::Address::zero(), + abi::entry::MainCallData { + name: None, + args: None, + kwargs: None, + }, + U256::zero(), + gl_call::On::Finalized, + use_balance, + use_balance.then(valid_params), + ) + .await + } + MessageEmission::DeployAllocation | MessageEmission::DeployBalance => { + let use_balance = matches!(emission, MessageEmission::DeployBalance); + wasi.gl_call_emit_internal_deploy_message( + abi::entry::MainDeployData { + args: None, + kwargs: None, + }, + gl_call::On::Finalized, + use_balance.then(valid_params), + super::message::EmitInternalDeployMessageArgs { + code: bytes::Bytes::new(), + value: U256::zero(), + salt_nonce: U256::zero(), + use_balance, + }, + ) + .await + } + } + } + + async fn shutdown(self) { + rt::supervisor::await_nondet_vms(&self.context.data.supervisor) + .await + .unwrap(); + } +} + +fn trap_message(error: generated::types::Error) -> String { + let trap: wasmtime::Error = error.downcast().expect_err("expected a trap"); + trap.to_string() +} + #[test] fn emission_allocation_includes_fixed_and_payload_costs() { assert_eq!( @@ -36,6 +450,372 @@ fn emission_allocation_overflow_cannot_fit_the_budget() { assert_eq!(emission_allocation_size(&[u64::MAX]), u64::MAX); } +#[tokio::test] +async fn messages_rejected_by_memory_are_not_appended_or_charged() { + for emission in MessageEmission::ALL { + let mut test = EmissionTestContext::new(0, 1); + + let error = test.emit_message(emission).await.unwrap_err(); + + let message = trap_message(error); + assert!( + message.contains("out_of memory"), + "unexpected error for {}: {message}", + emission.name() + ); + assert!( + test.context.data.accumulator.emissions.is_empty(), + "{} was appended", + emission.name() + ); + assert_eq!( + test.context + .data + .supervisor + .shared_data + .data_fees_limit + .remaining() + .await, + vec![U256::from(1)], + "{} was charged", + emission.name() + ); + assert!( + test.context + .data + .accumulator + .message_fee_allocation + .iter() + .all(|node| node.budget == U256::from(100)), + "{} consumed its allocation budget", + emission.name() + ); + + test.shutdown().await; + } +} + +#[tokio::test] +async fn messages_rejected_by_fee_are_not_appended_and_release_memory() { + for emission in MessageEmission::ALL { + let mut test = EmissionTestContext::new(u32::MAX, 0); + let memory_before = test.context.limiter.get_remaining_memory(); + + let error = test.emit_message(emission).await.unwrap_err(); + + let message = trap_message(error); + assert!( + message.contains(emission.fee_error()), + "unexpected error for {}: {message}", + emission.name() + ); + assert!( + test.context.data.accumulator.emissions.is_empty(), + "{} was appended", + emission.name() + ); + assert_eq!( + test.context.limiter.get_remaining_memory(), + memory_before, + "{} retained memory", + emission.name() + ); + assert_eq!( + test.context.limiter.get_new_permanent_allocations(), + 0, + "{} committed memory", + emission.name() + ); + assert!( + test.context + .data + .accumulator + .message_fee_allocation + .iter() + .all(|node| node.budget == U256::from(100)), + "{} consumed its allocation budget", + emission.name() + ); + let consumed = test + .context + .data + .supervisor + .shared_data + .data_fees_limit + .consumed() + .await; + assert_eq!( + consumed.message_fee, + U256::zero(), + "{} consumed a message fee", + emission.name() + ); + assert_eq!( + consumed.message_receipt, + U256::zero(), + "{} consumed a receipt fee", + emission.name() + ); + assert_eq!( + test.context.data.accumulator.messages_value_decremented, + U256::zero(), + "{} consumed balance", + emission.name() + ); + + test.shutdown().await; + } +} + +#[tokio::test] +async fn event_rejected_by_memory_is_not_appended_or_charged() { + let mut test = EmissionTestContext::new(0, 1); + + let error = test + .wasi() + .gl_call_emit_event(Vec::new(), calldata::Map::new().into()) + .await + .unwrap_err(); + + let message = trap_message(error); + assert!( + message.contains("out_of memory"), + "unexpected error: {message}" + ); + assert!(test.context.data.accumulator.emissions.is_empty()); + assert_eq!( + test.context + .data + .supervisor + .shared_data + .data_fees_limit + .remaining() + .await, + vec![U256::from(1)] + ); + + test.shutdown().await; +} + +#[tokio::test] +async fn event_rejected_by_fee_is_not_appended_and_releases_memory() { + let mut test = EmissionTestContext::new(u32::MAX, 0); + let memory_before = test.context.limiter.get_remaining_memory(); + + let error = test + .wasi() + .gl_call_emit_event(Vec::new(), calldata::Map::new().into()) + .await + .unwrap_err(); + + let message = trap_message(error); + assert!( + message.contains("out_of receipt event"), + "unexpected error: {message}" + ); + assert!(test.context.data.accumulator.emissions.is_empty()); + assert_eq!(test.context.limiter.get_remaining_memory(), memory_before); + assert_eq!(test.context.limiter.get_new_permanent_allocations(), 0); + assert_eq!( + test.context + .data + .supervisor + .shared_data + .data_fees_limit + .consumed() + .await + .event, + U256::zero() + ); + + test.shutdown().await; +} + +#[test] +fn nondet_ram_preflight_preserves_the_fallback_budget() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let fee_error = NondetOutput::vm_error(public_abi::VmError::out_of().receipt().nondet_output()); + let budget = memory_error + .preflight_ram_size() + .max(fee_error.preflight_ram_size()) as u32; + let limiter = rt::memlimiter::Limiter::with_limit(budget); + + preflight_nondet_output_ram(&limiter, &memory_error, &fee_error).unwrap(); + + assert_eq!(limiter.get_remaining_memory(), budget); + assert_eq!(limiter.get_new_permanent_allocations(), 0); +} + +#[test] +fn nondet_ram_preflight_rejects_a_budget_without_room_for_an_error() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let fee_error = NondetOutput::vm_error(public_abi::VmError::out_of().receipt().nondet_output()); + let required = memory_error + .preflight_ram_size() + .max(fee_error.preflight_ram_size()) as u32; + let limiter = rt::memlimiter::Limiter::with_limit(required - 1); + + assert!(preflight_nondet_output_ram(&limiter, &memory_error, &fee_error).is_err()); + assert_eq!(limiter.get_remaining_memory(), required - 1); +} + +#[test] +fn oversized_nondet_output_is_replaced_and_permanently_charged() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let oversized = NondetOutput::vm_error(public_abi::VmError(std::borrow::Cow::Owned( + "x".repeat(256), + ))); + let budget = memory_error.allocation_size() as u32; + let limiter = rt::memlimiter::Limiter::with_limit(budget); + + let (output, allocation) = reserve_nondet_output(&limiter, oversized, &memory_error).unwrap(); + assert_eq!(output.encoded, memory_error.encoded); + allocation.commit(); + + assert_eq!(limiter.get_remaining_memory(), 0); + assert_eq!(limiter.get_new_permanent_allocations(), budget); +} + +#[test] +fn nondet_cap_replacement_preserves_a_fatal_leader_rejection() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let error = public_abi::VmError(std::borrow::Cow::Owned("x".repeat(256))); + let encoded = rt::vm::ContractOutcome::VMError(error.clone(), None).encode(); + let rejected = NondetOutput { + result: rt::vm::RunOk::FatalVMError(error, None), + encoded, + }; + let limiter = rt::memlimiter::Limiter::with_limit(memory_error.allocation_size() as u32); + + let (output, _) = reserve_nondet_output(&limiter, rejected, &memory_error).unwrap(); + + assert!(matches!(output.result, rt::vm::RunOk::FatalVMError(..))); + assert_eq!(output.encoded, memory_error.encoded); +} + +#[tokio::test] +async fn nondet_fee_preflight_fails_before_consuming_the_fallback_fee() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let fee_error = NondetOutput::vm_error(public_abi::VmError::out_of().receipt().nondet_output()); + let required = fee_error.encoded.as_slice().len() as u64; + let fees = nondet_fees(required - 1); + + assert!( + preflight_nondet_output_fees(&fees, &memory_error, &fee_error) + .await + .is_err() + ); + assert_eq!(fees.remaining().await, vec![U256::from(required - 1)]); + assert_eq!(fees.consumed().await.nondet_output, U256::zero()); +} + +#[tokio::test] +async fn nondet_fee_preflight_checks_the_memory_error_with_non_monotone_fees() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let fee_error = NondetOutput::vm_error(public_abi::VmError::out_of().receipt().nondet_output()); + let fee_error_len = fee_error.encoded.as_slice().len(); + let fees = nondet_fees_with_delta( + 0, + &format!("\\attrs = if attrs.outputLength < {fee_error_len} then 1 else 0"), + ); + + assert!(fees + .can_consume_nondet_output(fee_error_len as u64) + .await + .unwrap()); + assert!( + preflight_nondet_output_fees(&fees, &memory_error, &fee_error) + .await + .is_err() + ); +} + +#[tokio::test] +async fn over_cap_nondet_payload_is_replaced_before_publication() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let fee_error = NondetOutput::vm_error(public_abi::VmError::out_of().receipt().nondet_output()); + let oversized = NondetOutput::vm_error(public_abi::VmError(std::borrow::Cow::Owned( + "x".repeat(256), + ))); + let fee_error_len = fee_error.encoded.as_slice().len() as u64; + let memory_budget = oversized.allocation_size() as u32; + let limiter = rt::memlimiter::Limiter::with_limit(memory_budget); + let fees = nondet_fees(fee_error_len); + + let output = charge_nondet_output(&limiter, &fees, oversized, &memory_error, &fee_error) + .await + .unwrap(); + + assert_eq!(output.encoded, fee_error.encoded); + assert_eq!( + limiter.get_remaining_memory(), + memory_budget - fee_error.allocation_size() as u32 + ); + assert_eq!(fees.remaining().await, vec![U256::zero()]); + assert_eq!( + fees.consumed().await.nondet_output, + U256::from(fee_error_len) + ); +} + +#[tokio::test] +async fn fee_capped_nondet_output_still_fits_a_readable_fd() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let fee_error = NondetOutput::vm_error(public_abi::VmError::out_of().receipt().nondet_output()); + let oversized = NondetOutput::vm_error(public_abi::VmError(std::borrow::Cow::Owned( + "x".repeat(256), + ))); + let budget = memory_error + .preflight_ram_size() + .max(fee_error.preflight_ram_size()) as u32; + let limiter = rt::memlimiter::Limiter::with_limit(budget); + let fees = nondet_fees(fee_error.encoded.as_slice().len() as u64); + + preflight_nondet_output_ram(&limiter, &memory_error, &fee_error).unwrap(); + let output = charge_nondet_output(&limiter, &fees, oversized, &memory_error, &fee_error) + .await + .unwrap(); + assert_eq!(output.encoded, fee_error.encoded); + + let mut vfs = vfs::VFS::new(Vec::new(), limiter.clone()).unwrap(); + vfs.place_content(vfs::FileContents::from(output.encoded.into_bytes())) + .unwrap(); + assert_eq!(limiter.get_remaining_memory(), 0); +} + +#[tokio::test] +async fn nondet_fee_cap_takes_precedence_when_both_caps_are_exceeded() { + let memory_error = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + let fee_error = NondetOutput::vm_error(public_abi::VmError::out_of().receipt().nondet_output()); + let oversized = NondetOutput::vm_error(public_abi::VmError(std::borrow::Cow::Owned( + "x".repeat(256), + ))); + let limiter = rt::memlimiter::Limiter::with_limit( + memory_error + .preflight_ram_size() + .max(fee_error.preflight_ram_size()) as u32, + ); + let fees = nondet_fees(fee_error.encoded.as_slice().len() as u64); + + let output = charge_nondet_output(&limiter, &fees, oversized, &memory_error, &fee_error) + .await + .unwrap(); + + assert_eq!(output.encoded, fee_error.encoded); +} + +#[test] +fn validator_treats_a_post_cap_leader_mismatch_as_a_leader_fault() { + let proposed = NondetOutput::vm_error(public_abi::VmError::timeout()); + let capped = NondetOutput::vm_error(public_abi::VmError::out_of().memory().val()); + + assert!(validate_leader_output_after_caps(&proposed.encoded, &proposed.encoded).is_ok()); + assert_eq!( + validate_leader_output_after_caps(&capped.encoded, &proposed.encoded).unwrap_err(), + malformed() + ); +} + #[test] fn balance_no_permission_is_forbidden() { let err = validate_balance_fee(false, true, Some(valid_params())).unwrap_err(); diff --git a/executor/tests/nondet_output_fees.rs b/executor/tests/nondet_output_fees.rs new file mode 100644 index 00000000..7cdfbf04 --- /dev/null +++ b/executor/tests/nondet_output_fees.rs @@ -0,0 +1,50 @@ +use genvm::config::{FeesBucketConfig, FeesConfig}; +use genvm::rt::fees::DataLimit; + +fn nondet_fees(total: u64) -> DataLimit { + let bucket = |delta: &str| FeesBucketConfig { + bucket_no: vec![0], + subtract_on_start_expr: "0".to_owned(), + delta_expr: delta.to_owned(), + }; + let fees = FeesConfig { + expr_prelude: String::new(), + storage: bucket("\\attrs = 0"), + message_receipt: bucket("\\attrs = 0"), + nondet_output: bucket("\\attrs = attrs.outputLength"), + message_fee: bucket("\\attrs = 0"), + event: bucket("\\attrs = 0"), + }; + DataLimit::new( + vec![primitive_types::U256::from(total)], + fees, + Default::default(), + ) + .unwrap() +} + +#[tokio::test] +async fn nondet_fee_preflight_checks_without_consuming() { + let fees = nondet_fees(5); + + assert!(fees.can_consume_nondet_output(5).await.unwrap()); + assert!(!fees.can_consume_nondet_output(6).await.unwrap()); + assert_eq!(fees.remaining().await, vec![primitive_types::U256::from(5)]); + assert_eq!( + fees.consumed().await.nondet_output, + primitive_types::U256::zero() + ); +} + +#[tokio::test] +async fn nondet_fee_preflight_leaves_the_checked_charge_available() { + let fees = nondet_fees(5); + + assert!(fees.can_consume_nondet_output(5).await.unwrap()); + assert!(fees.consume_nondet_output(5).await.unwrap()); + assert_eq!(fees.remaining().await, vec![primitive_types::U256::zero()]); + assert_eq!( + fees.consumed().await.nondet_output, + primitive_types::U256::from(5) + ); +} diff --git a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0.stdout b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0.stdout new file mode 100644 index 00000000..7d73027c --- /dev/null +++ b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0.stdout @@ -0,0 +1 @@ +executed with `Return(null)` diff --git a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_0.stdout b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_0.stdout new file mode 100644 index 00000000..7a6f6a0b --- /dev/null +++ b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_0.stdout @@ -0,0 +1,2 @@ +out_of receipt nondet_output +executed with `Return(null)` diff --git a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet new file mode 100644 index 00000000..1a2b86a7 --- /dev/null +++ b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet @@ -0,0 +1,17 @@ +local simple = import 'templates/simple_deploy_then_write.jsonnet'; +local util = import 'templates/util.jsonnet'; + +{ + tags: util.features([['nondet', 'consensus', 'leader'], ['fees']], 'stable') + ['python'], + entry: util.addPaths([ + simple.run('${jsonnetDir}/${fileBaseName}.py', 'main') { + next: [ + super.next[0] { + modes: 'lvs', + // VMError byte + "out_of receipt nondet_output" + bucket_totals: [1000000000, 1000000000, 29, 1000000000], + }, + ], + }, + ]), +} diff --git a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.py b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.py new file mode 100644 index 00000000..68b44fc8 --- /dev/null +++ b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.py @@ -0,0 +1,20 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl + + +class Contract(gl.contract.Contract): + @gl.public.write + def main(self): + def validate(result): + return ( + isinstance(result, gl.vm.VMError) + and result.public_code == 'out_of receipt nondet_output' + ) + + result = gl.vm.run_nondet( + lambda: 'x' * 1024, + validate, + catch_vm_error=True, + ) + assert isinstance(result, gl.vm.VMError) + print(result.public_code) From ec0f2779252c51e38b5c7d1d9fb87fdd6e0cd666 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 3 Sep 2026 21:37:18 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(executor):=20adopt=20consensus-safe=20?= =?UTF-8?q?named=20fee=20accounting=20=E2=9C=A8=F0=9F=94=92=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry opaque leader outputs and complete allocation subtrees so validators can apply versioned decoding without trusting proposed fee accounting. --- .../src/python-sdk/migration-guide.rst | 6 +- executor/crates/common/tests/fees_abi.rs | 63 +-- executor/fuzz/genvm-storage.rs | 12 +- executor/install/config/genvm.yaml | 178 +++++---- executor/src/config.rs | 37 +- executor/src/exe/run.rs | 53 +-- executor/src/host/mod.rs | 10 +- executor/src/leader_public_data.rs | 211 +++++----- executor/src/lib.rs | 6 +- executor/src/rt/fees.rs | 198 ++++++--- executor/src/wasi/genlayer_sdk/message.rs | 376 +++++++++++++----- executor/src/wasi/genlayer_sdk/mod.rs | 1 + executor/src/wasi/genlayer_sdk/run.rs | 3 + executor/src/wasi/genlayer_sdk/tests.rs | 372 ++++++++++++++++- executor/tests/code_and_major_reads.rs | 7 +- executor/tests/fee_bucket_accounting.rs | 67 ++++ executor/tests/fee_bucket_config.rs | 71 ++++ executor/tests/message_fee_overlay.rs | 76 ++++ executor/tests/message_fee_time_units.rs | 116 ++++++ executor/tests/message_receipt_fees.rs | 91 +++++ executor/tests/nondet_output_fees.rs | 14 +- executor/tests/storage_page_accounting.rs | 7 +- .../balance/balance/balance.0_0.stdout | 2 +- .../balance_eth/balance_eth.0_0.stdout | 2 +- .../sandbox_overspend.0.stdout | 2 +- .../sandbox_overspend_2.0.stdout | 2 +- .../storage_distinct_pages.jsonnet | 7 +- .../storage_page_limit.jsonnet | 12 +- .../subtract_on_start_underflow.jsonnet | 10 +- .../message/deploy/deploy.0.stdout | 2 +- .../message/deploy_salt/deploy_salt.0.stdout | 2 +- .../internal_below_min_timeunits.jsonnet | 9 +- .../internal_below_min_timeunits.py | 5 +- .../message_count_cap.0.stdout | 2 + .../message_count_cap.1.stdout | 1 + .../message_count_cap.2.stdout | 2 + .../message_count_cap.3.stdout | 1 + .../message_count_cap.4.stdout | 2 + .../message_count_cap.5.stdout | 1 + .../message_count_cap.jsonnet | 16 + .../nested_allocation_budget.0.stdout | 2 + .../nested_allocation_budget.jsonnet | 58 +++ .../nested_allocation_budget.py | 7 + .../send_message/send_message.0.stdout | 2 +- .../send_message_eth.0.stdout | 2 +- .../send_message_on.0_0.stdout | 2 +- .../use_balance_below_min.jsonnet | 11 +- .../use_balance_below_min.py | 7 +- .../use_balance_budget_too_low.jsonnet | 7 +- .../use_balance_no_alloc.0_0_0.stdout | 2 +- .../use_balance_ok/use_balance_ok.0_0.stdout | 2 +- .../use_balance_sandbox.0_0.stdout | 2 +- .../use_balance_scaled.0_0.stdout | 2 +- .../use_balance_scaled.jsonnet | 7 +- .../use_balance_scaled/use_balance_scaled.py | 13 +- .../use_balance_zero_budget.0_0.stdout | 2 +- .../use_balance_zero_budget.jsonnet | 7 +- .../output_fee_cap/output_fee_cap.0_1.stdout | 1 + .../output_fee_cap/output_fee_cap.0_2.stdout | 1 + .../output_fee_cap/output_fee_cap.jsonnet | 20 +- .../sandbox_fold_limit.jsonnet | 2 +- .../zero_fee_ram_bound.jsonnet | 2 +- 62 files changed, 1721 insertions(+), 495 deletions(-) create mode 100644 executor/tests/fee_bucket_accounting.rs create mode 100644 executor/tests/fee_bucket_config.rs create mode 100644 executor/tests/message_fee_overlay.rs create mode 100644 executor/tests/message_fee_time_units.rs create mode 100644 executor/tests/message_receipt_fees.rs create mode 100644 tests/integration/message/message_count_cap/message_count_cap.0.stdout create mode 100644 tests/integration/message/message_count_cap/message_count_cap.1.stdout create mode 100644 tests/integration/message/message_count_cap/message_count_cap.2.stdout create mode 100644 tests/integration/message/message_count_cap/message_count_cap.3.stdout create mode 100644 tests/integration/message/message_count_cap/message_count_cap.4.stdout create mode 100644 tests/integration/message/message_count_cap/message_count_cap.5.stdout create mode 100644 tests/integration/message/message_count_cap/message_count_cap.jsonnet create mode 100644 tests/integration/message/nested_allocation_budget/nested_allocation_budget.0.stdout create mode 100644 tests/integration/message/nested_allocation_budget/nested_allocation_budget.jsonnet create mode 100644 tests/integration/message/nested_allocation_budget/nested_allocation_budget.py create mode 100644 tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_1.stdout create mode 100644 tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_2.stdout diff --git a/docs/website/src/python-sdk/migration-guide.rst b/docs/website/src/python-sdk/migration-guide.rst index 5731cfdd..c423520d 100644 --- a/docs/website/src/python-sdk/migration-guide.rst +++ b/docs/website/src/python-sdk/migration-guide.rst @@ -91,7 +91,11 @@ VM Error Codes * - ``invalid_contract malformed_runner`` - ``invalid_contract runner malformed`` -``malformed_entry`` is new, ``out_of receipt message``, ``out_of message_fee total``, ``out_of message_fee allocation_budget`` and ``fee no_matching_allocation`` gained ``internal``/``external`` variants, and ``ResultCode.INTERNAL_ERROR`` is gone. The ``memory_limiter_consts`` and ``top_limits`` tables were removed from ``public_abi``. +``malformed_entry`` is new, ``out_of receipt message``, ``out_of message_fee total``, +``out_of message_fee allocation_budget`` and ``fee no_matching_allocation`` +gained ``internal``/``external`` variants, and ``ResultCode.INTERNAL_ERROR`` is +gone. The ``memory_limiter_consts`` and ``top_limits`` tables were removed from +``public_abi``. Storage ~~~~~~~ diff --git a/executor/crates/common/tests/fees_abi.rs b/executor/crates/common/tests/fees_abi.rs index 2c4214c6..1c31f659 100644 --- a/executor/crates/common/tests/fees_abi.rs +++ b/executor/crates/common/tests/fees_abi.rs @@ -71,11 +71,11 @@ fn internal_node( #[test] fn external_root_node_matches_exact_encoding() { let recipient = [0x11u8; 20]; - let encoded = - MessageAllocationNode::abi_encode(&[external_node(Some(recipient), None, 5, 7, 9, vec![])]); + let root = external_node(Some(recipient), None, 5, 7, 9, vec![]); + let encoded = root.abi_encode(); - // `abi.encode(MessageAllocationNode[])` of a single external root node: - // array offset, length, element offset, then the 10-word element tuple + // `abi.encode(MessageAllocationNode[])`: array offset, length, element + // offset, then the 10-word element tuple // (messageType=External, onAcceptance=false, parent=sentinel, recipient, // callKey wildcard, budget, feeParams offset, feeParams len, gasLimit, maxGasPrice). let expected = words(&[ @@ -86,12 +86,12 @@ fn external_root_node_matches_exact_encoding() { U256::from(0), // onAcceptance = false U256::MAX, // parentIndex = NODE_ROOT_SENTINEL U256::from_big_endian(&recipient), // recipient (left-padded) - U256::from(0), // callKey = CALL_KEY_WILDCARD - U256::from(5), // budget - U256::from(0xE0), // feeParams offset (7 head words) - U256::from(64), // feeParams bytes length - U256::from(7), // gasLimit - U256::from(9), // maxGasPrice + U256::from_big_endian(&genvm_modules_interfaces::fees::CALL_KEY_WILDCARD.0), + U256::from(5), // budget + U256::from(0xE0), // feeParams offset (7 head words) + U256::from(64), // feeParams bytes length + U256::from(7), // gasLimit + U256::from(9), // maxGasPrice ]); assert_eq!(encoded, expected); @@ -101,25 +101,29 @@ fn external_root_node_matches_exact_encoding() { #[test] fn nested_internal_flattens_with_parent_pointers() { - // root (internal, accepted) with a single external child. - let child = external_node(Some([0x22u8; 20]), None, 1, 100, 200, vec![]); + let grandchild = external_node(Some([0x44u8; 20]), None, 1, 100, 200, vec![]); + let first_child = external_node(Some([0x22u8; 20]), None, 2, 100, 200, vec![grandchild]); + let second_child = external_node(Some([0x33u8; 20]), None, 3, 100, 200, vec![]); let root = internal_node( genvm_modules_interfaces::On::Decided, 10, &[2, 3], - vec![child], + vec![first_child, second_child], ); - let encoded = MessageAllocationNode::abi_encode(&[root]); + let encoded = root.abi_encode(); assert_eq!(word(&encoded, 0), U256::from(0x20)); - assert_eq!(word(&encoded, 1), U256::from(2), "two flattened nodes"); + assert_eq!(word(&encoded, 1), U256::from(4), "four flattened nodes"); - // Heads region begins right after the length word (word index 2), and the + // Heads region begins right after the array length word, and the // per-element offsets there are relative to it. let heads_base = 2 * 32; - let root_idx = (heads_base + word(&encoded, 2).as_usize()) / 32; - let child_idx = (heads_base + word(&encoded, 3).as_usize()) / 32; + let element_idx = |index: usize| (heads_base + word(&encoded, 2 + index).as_usize()) / 32; + let root_idx = element_idx(0); + let first_child_idx = element_idx(1); + let second_child_idx = element_idx(2); + let grandchild_idx = element_idx(3); // Root: messageType Internal (1), onAcceptance true, parent = sentinel. assert_eq!( @@ -138,21 +142,21 @@ fn nested_internal_flattens_with_parent_pointers() { "root parent = sentinel" ); - // Child: messageType External (0), parent index = 0 (root is first flattened node). + // Both children precede the grandchild in BFS order. assert_eq!( - word(&encoded, child_idx), - U256::from(0), - "child messageType External" + word(&encoded, first_child_idx + 2), + U256::zero(), + "first child parent index 0" ); assert_eq!( - word(&encoded, child_idx + 1), + word(&encoded, second_child_idx + 2), U256::zero(), - "child onAcceptance false" + "second child parent index 0" ); assert_eq!( - word(&encoded, child_idx + 2), - U256::zero(), - "child parent index 0" + word(&encoded, grandchild_idx + 2), + U256::one(), + "grandchild parent index 1" ); } @@ -162,12 +166,13 @@ fn nested_internal_flattens_with_parent_pointers() { fn internal_params_encode_derived_appeal_rounds() { // appealRounds is not stored on the Rust side; it is reconstructed as // len(rotations) - 1 when encoding. - let encoded = MessageAllocationNode::abi_encode(&[internal_node( + let root = internal_node( genvm_modules_interfaces::On::Finalized, 10, &[2, 3, 4], vec![], - )]); + ); + let encoded = root.abi_encode(); // Walk to the feeParams bytes inside the single element. let heads_base = 2 * 32; diff --git a/executor/fuzz/genvm-storage.rs b/executor/fuzz/genvm-storage.rs index 9b76730f..7b0ec6b0 100644 --- a/executor/fuzz/genvm-storage.rs +++ b/executor/fuzz/genvm-storage.rs @@ -140,31 +140,31 @@ async fn run_storage_fuzz(input: FuzzInput) -> anyhow::Result<()> { address, genvm::rt::vm::storage::Limiter::new(sync::DArc::new( rt::fees::DataLimit::new( - vec![primitive_types::U256::MAX], + std::collections::HashMap::from([("test".to_owned(), primitive_types::U256::MAX)]), genvm::config::FeesConfig { expr_prelude: String::new(), storage: genvm::config::FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".into(), delta_expr: r"\attrs = 0".into(), }, message_receipt: genvm::config::FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".into(), delta_expr: r"\attrs = 0".into(), }, nondet_output: genvm::config::FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".into(), delta_expr: r"\attrs = 0".into(), }, message_fee: genvm::config::FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".into(), delta_expr: r"\attrs = 0".into(), }, event: genvm::config::FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".into(), delta_expr: r"\attrs = 0".into(), }, diff --git a/executor/install/config/genvm.yaml b/executor/install/config/genvm.yaml index a51f70a5..ae313855 100644 --- a/executor/install/config/genvm.yaml +++ b/executor/install/config/genvm.yaml @@ -9,11 +9,12 @@ modules: log_level: info fees: - # Host bucket indices (bucket_totals[] supplied by node/consensus): - # 0 - execution_data_gas budget (shared by storage, message_receipt, nondet_output, event) - # 1 - message_fee budget - # 2 - eq_outputs byte cap (node.maxEqOutputsBytes; enforced via nondet_output) - # 3 - submitted_messages byte cap (node.maxSubmittedMessagesBytes; enforced via message_receipt) + # Host buckets (bucket_totals supplied by node/consensus): + # execution_data_gas - shared by storage, message_receipt, nondet_output and event + # message_fee - outbound message fee budget + # nondet_outputs - byte cap enforced via nondet_output + # submitted_messages - byte cap enforced via message_receipt + # submitted_messages_count - message count cap enforced via message_receipt # # # Required host-provided `node` fields (from gas_data): @@ -24,18 +25,17 @@ fees: # node.bootloaderOverhead - per-tx bootloader overhead # node.fixedProposeReceiptGas - fixed cost of a propose receipt # node.fixedMessageRevealGas - fixed cost of a message reveal - # node.genPerTimeUnit - GEN per time unit (0 disables the time term) - # node.maxEqOutputsBytes - hard byte cap for eqBlocksOutputs (bucket 2) - # node.maxSubmittedMessagesBytes - hard byte cap for SubmittedMessage[] (bucket 3) + # node.lockedReceiptGasPrice - transaction's locked receipt gas price + # node.overlaySplitBps - combined developer + DAO share of the + # time-unit fee pool, in basis points + # node.receiptWrapperBytes - propose-receipt wrapper byte allowance + # node.minProposeTimeout - minimum leader timeunits allocation + # node.maxProposeTimeout - maximum leader timeunits allocation + # node.minCommitTimeout - minimum validator timeunits allocation + # node.maxCommitTimeout - maximum validator timeunits allocation # Optional: # node.validatorsPerRound[] - validator count per round # (defaults to defaultValidatorsPerRound below) - # node.minTimeUnitsPerPhase - minimum per-phase timeunits an emitted - # internal message's allocation must fund - # (leader->propose, validator->commit); a - # matched message below it is rejected at - # emission with `fee below_minimum` - # (defaults to 0 = no floor) # node.messageBudgetFloor - minimum non-zero per-round execution # budget a balance-funded internal message # may declare; the chain reverts @@ -46,7 +46,7 @@ fees: # (defaults to 0 = no floor) # # GenVM consumption points can consume multiple buckets, but as a fee only first is reported to the node - # i.e. message_receipt consumes from execution_data_gas & submitted_messages + # i.e. message_receipt consumes from execution_data_gas and both submitted message caps # to message only execution_data_gas gets attached expr_prelude: | let Y = \f = (\x = f (\v = x x v)) (\x = f (\v = x x v)) in @@ -72,16 +72,14 @@ fees: let validatorsPerRound = if hasKey node "validatorsPerRound" then node.validatorsPerRound else defaultValidatorsPerRound in - # leaf-node message fee floor = minPrimaryFees * lifecycleMultiplier + # leaf-node message fee floor = minPrimaryFees # feeParams: object { leaderTimeunitsAllocation, # validatorTimeunitsAllocation, executionBudgetPerRound, rotations[], # maxPriceGenPerTimeUnit } # appealRounds = len(rotations) - 1 - # onAcceptance: per-message lifecycle flag (bool) - # balanceFunded: true for use_balance messages (bool) - # uses validatorsPerRound[] (node or default) and, as the consensus-term - # multiplier, either the guest cap (balance-funded) or node.genPerTimeUnit. - let messageFeeFloor = \feeParams onAcceptance balanceFunded = + # uses validatorsPerRound[] (node or default) and maxPriceGenPerTimeUnit as + # the consensus-term multiplier. + let messageFeeFloor = \feeParams = let rotations = feeParams.rotations in let appealRounds = arrayLen rotations - 1 in # rounds span 0 .. 2*appealRounds and index validatorsPerRound[round]; a @@ -111,74 +109,111 @@ fees: + appealRounds in # 4. execution-budget term let executionTerm = execBudgetPerRound * leaderRounds in - # 5. consensus-term multiplier. Chain `_calculateRoundFees` charges the - # consensus term at maxPriceGenPerTimeUnit (the funding cap) for - # balance-funded messages; the allocation path keeps the historical - # node.genPerTimeUnit behaviour. Storage/receipt caps stay out of the - # floor: `feeParamsToFeesDistribution` zeroes them so their - # revert-on-exceed guards never fire during this calc. - let multiplier = if balanceFunded then feeParams.maxPriceGenPerTimeUnit else node.genPerTimeUnit in - # 6. minimum primary fees - let minPrimaryFees = - (if multiplier > 0 then multiplier * consensusTerm else consensusTerm) - + executionTerm in - # 7. lifecycle multiplier - let lifecycleMultiplier = if onAcceptance then appealRounds + 1 else 1 in - minPrimaryFees * lifecycleMultiplier + # 5. consensus-term multiplier: the chain's `_calculateRoundFees` charges + # the consensus term at maxPriceGenPerTimeUnit (the funding cap) on + # every path. Storage/receipt caps stay out of the floor: + # `feeParamsToFeesDistribution` zeroes them so their revert-on-exceed + # guards never fire during this calc. + let multiplier = feeParams.maxPriceGenPerTimeUnit in + # 6. time-unit pool and developer + DAO overlay. The overlay is grossed + # up on the time-unit pool only; integer division matches Solidity + let timeUnitPool = + if multiplier > 0 then multiplier * consensusTerm else consensusTerm in + let overlaySplit = + idiv (timeUnitPool * node.overlaySplitBps) (10000 - node.overlaySplitBps) in + # 7. minimum primary fees + let minPrimaryFees = timeUnitPool + overlaySplit + executionTerm in + minPrimaryFees in storage: - bucket_no: 0 + buckets: execution_data_gas subtract_on_start_expr: | 0 delta_expr: | \a = a.pages * node.storageUnitPrice message_receipt: - # bucket 0: gas cost; bucket 3: canonical ABI byte size of this SubmittedMessage - bucket_no: [0, 3] + # Gas cost and conservative ABI byte size of this SubmittedMessage + buckets: [execution_data_gas, submitted_messages, submitted_messages_count] subtract_on_start_expr: | - [node.fixedProposeReceiptGas # for propose + [ # execution_data_gas + node.fixedProposeReceiptGas # for propose + node.intrinsicGas + node.bootloaderOverhead + 7 * node.gasPerChangedSlot - + node.fixedMessageRevealGas # for reveal - + node.intrinsicGas - + node.bootloaderOverhead - + 32 * node.gasPerChangedSlot, # for 32 bytes of length - 0] + # submitted_messages + , 0 + # submitted_messages_count + , 0 + ] delta_expr: | \a = - # each `bytes` field is indirection + length + data, rounded up to 32 bytes + let revealGas = if a.isFirstMessage then + node.fixedMessageRevealGas + + node.intrinsicGas + + node.bootloaderOverhead + + 64 * node.receiptGasPerByte # outer offset and array length + else 0 in + let revealBytes = if a.isFirstMessage then 64 else 0 in + # 32-byte element offset plus the 11-word SubmittedMessage head + let fixedBytes = 32 + 11 * 32 in + # Each `bytes` field is length + data, rounded up to 32 bytes. The extra + # calldata word conservatively covers the internal RLP wrapper let calldataBytes = 32 + 32 + ceilDiv a.calldataLength 32 * 32 in - # allocationSubtree the leader carries in the receipt under commitment modes - # (empty for external messages -> just the 64-byte indirection + length) + # allocationSubtree is empty for external and balance-funded messages let subtreeBytes = 32 + 32 + ceilDiv a.subtreeLength 32 * 32 in - # deployed contract code carried in the receipt (deploys only) + # Internal fee params have an outer offset, 8-word head, and rotations + # array; external fee params are a static 2-word tuple + let feeParamsLength = if a.isInternal then 32 + 8 * 32 + 32 + 32 * a.rotationsCount else 2 * 32 in + let feeParamsBytes = 32 + ceilDiv feeParamsLength 32 * 32 in + # Deploy code shares the internal RLP data field; charging it separately + # is conservative and avoids depending on RLP prefix lengths let codeBytes = if a.isDeploy then 32 + 32 + ceilDiv a.codeLength 32 * 32 else 0 in - let abiBytes = calldataBytes + subtreeBytes + codeBytes in - [(calldataBytes + subtreeBytes + codeBytes) * node.receiptGasPerByte + 1 * node.gasPerChangedSlot, - abiBytes] + let abiBytes = fixedBytes + calldataBytes + feeParamsBytes + subtreeBytes + codeBytes in + [ # execution_data_gas + revealGas + abiBytes * node.receiptGasPerByte + node.gasPerChangedSlot + # submitted_messages + , revealBytes + abiBytes + # submitted_messages_count + , 1] nondet_output: - # bucket 0: gas cost; bucket 2: raw output byte count - bucket_no: [0, 2] + # Gas cost and conservative compact LeaderPublicData size + buckets: [execution_data_gas, nondet_outputs] subtract_on_start_expr: | - [32 * node.receiptGasPerByte, # for 32 bytes of length - 0] + # Conservative fixed allowance for the LeaderPublicData envelope + let nondet_outputs_header_bytes = 64 in + [ # execution_data_gas + (node.receiptWrapperBytes + nondet_outputs_header_bytes) * node.receiptGasPerByte + # nondet_outputs + , nondet_outputs_header_bytes + ] delta_expr: | \a = - # bytes are indirection + length + data, rounded up to 32 bytes - let abiBytes = 32 + 32 + ceilDiv a.outputLength 32 * 32 in - [abiBytes * node.receiptGasPerByte, a.outputLength] + # Raw output bytes plus conservative compact-encoding overhead + let encodedBytes = 5 + a.outputLength in + [ # execution_data_gas + encodedBytes * node.receiptGasPerByte + # nondet_outputs + , encodedBytes + ] message_fee: - bucket_no: 1 + buckets: message_fee subtract_on_start_expr: | 0 delta_expr: | \a = - # per-phase timeunit floor (leader->propose, validator->commit): a matched - # internal message whose child timeunits fall below the node minimum would - # revert PhaseTimeoutOutOfBounds at child creation on-chain, so reject it at - # emission instead of silently dropping it. Absent constant => no floor. - let minTimeUnits = if hasKey node "minTimeUnitsPerPhase" then node.minTimeUnitsPerPhase else 0 in + # Both-zero is the chain's explicit phase-timeout opt-out. Otherwise each + # allocation must fit its phase's current Idleness bounds or child creation + # reverts PhaseTimeoutOutOfBounds. + let leaderTimeUnits = a.matchedFeeParams.leaderTimeunitsAllocation in + let validatorTimeUnits = a.matchedFeeParams.validatorTimeunitsAllocation in + let phaseTimeoutsDisabled = + if leaderTimeUnits == 0 then validatorTimeUnits == 0 else false in + let phaseTimeoutsOutOfBounds = + if phaseTimeoutsDisabled then false + else if leaderTimeUnits < node.minProposeTimeout then true + else if leaderTimeUnits > node.maxProposeTimeout then true + else if validatorTimeUnits < node.minCommitTimeout then true + else validatorTimeUnits > node.maxCommitTimeout in # a non-zero per-round budget below the node floor reverts `BudgetTooLow` # at reveal on-chain; reject it at emission instead. Absent constant => no # floor. @@ -191,15 +226,16 @@ fees: else false in if a.isInternal then - if a.matchedFeeParams.leaderTimeunitsAllocation < minTimeUnits then vmError "fee below_minimum" - else if a.matchedFeeParams.validatorTimeunitsAllocation < minTimeUnits then vmError "fee below_minimum" + if phaseTimeoutsOutOfBounds then vmError "fee below_minimum" else if budgetTooLow then vmError "fee below_minimum" - else messageFeeFloor a.matchedFeeParams a.onAcceptance a.balanceFunded - # external messages reserve the worst-case L1 gas cost: gasLimit * maxGasPrice - # (matches the chain's `budget == k * (gasLimit * maxGasPrice)` requirement) - else a.matchedFeeParams.gasLimit * a.matchedFeeParams.maxGasPrice + else messageFeeFloor a.matchedFeeParams + # external messages reserve gasLimit at the effective chain price + else a.matchedFeeParams.gasLimit + * (if node.lockedReceiptGasPrice < a.matchedFeeParams.maxGasPrice + then node.lockedReceiptGasPrice + else a.matchedFeeParams.maxGasPrice) event: - bucket_no: 0 + buckets: execution_data_gas subtract_on_start_expr: | 0 delta_expr: | diff --git a/executor/src/config.rs b/executor/src/config.rs index 6483f3d5..f75f5e0a 100644 --- a/executor/src/config.rs +++ b/executor/src/config.rs @@ -15,7 +15,7 @@ fn default_fee_expr_zero() -> String { "0".to_owned() } -fn deserialize_bucket_nos<'de, D>(d: D) -> Result, D::Error> +fn deserialize_bucket_names<'de, D>(d: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { @@ -23,24 +23,26 @@ where struct Visitor; impl<'de> de::Visitor<'de> for Visitor { - type Value = Vec; + type Value = Vec; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("an integer or array of integers") + f.write_str("a non-empty string or array of non-empty strings") } - fn visit_u64(self, v: u64) -> Result, E> { - u8::try_from(v) - .map(|b| vec![b]) - .map_err(|_| E::custom(format!("bucket_no {v} exceeds u8 range"))) - } - fn visit_i64(self, v: i64) -> Result, E> { - u8::try_from(v) - .map(|b| vec![b]) - .map_err(|_| E::custom(format!("bucket_no {v} out of u8 range"))) + fn visit_str(self, v: &str) -> Result { + if v.is_empty() { + return Err(E::custom("bucket name must not be empty")); + } + Ok(vec![symbol_table::GlobalSymbol::from(v)]) } - fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + fn visit_seq>(self, mut seq: A) -> Result { let mut v = Vec::new(); - while let Some(n) = seq.next_element::()? { - v.push(n); + while let Some(name) = seq.next_element::()? { + if name.is_empty() { + return Err(de::Error::custom("bucket name must not be empty")); + } + v.push(symbol_table::GlobalSymbol::from(name)); + } + if v.is_empty() { + return Err(de::Error::custom("buckets must have at least one entry")); } Ok(v) } @@ -49,9 +51,10 @@ where } #[derive(Clone, Deserialize, Debug)] +#[serde(deny_unknown_fields)] pub struct FeesBucketConfig { - #[serde(deserialize_with = "deserialize_bucket_nos")] - pub bucket_no: Vec, + #[serde(deserialize_with = "deserialize_bucket_names")] + pub buckets: Vec, /// Cost charged once, up-front, when the bucket is created /// (the fixed part of `start + sum of per-change`). #[serde(default = "default_fee_expr_zero")] diff --git a/executor/src/exe/run.rs b/executor/src/exe/run.rs index bc2376c4..f247e499 100644 --- a/executor/src/exe/run.rs +++ b/executor/src/exe/run.rs @@ -15,11 +15,16 @@ const EXECUTION_DATA_HELP: &str = "path to file containing encoded execution dat fn fill_nested_fee_buckets( is_nested: bool, - max_bucket_no: usize, - bucket_totals: &mut Vec, + bucket_names: &[symbol_table::GlobalSymbol], + bucket_totals: &mut std::collections::HashMap, ) { if is_nested && bucket_totals.is_empty() { - bucket_totals.resize(max_bucket_no + 1, primitive_types::U256::zero()); + bucket_totals.extend( + bucket_names + .iter() + .copied() + .map(|name| (name.as_str().to_owned(), primitive_types::U256::zero())), + ); } } @@ -177,7 +182,8 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { let mut bucket_totals = execution_data .bucket_totals .iter() - .map(|bi| { + .map(|(name, bi)| { + anyhow::ensure!(!name.is_empty(), "bucket name must not be empty"); let (sign, bytes) = bi.to_bytes_be(); anyhow::ensure!( sign != num_bigint::Sign::Minus, @@ -187,30 +193,31 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { let mut buf = [0u8; 32]; let start = 32usize.saturating_sub(bytes.len()); buf[start..].copy_from_slice(&bytes); - Ok(primitive_types::U256::from_big_endian(&buf)) + Ok((name.clone(), primitive_types::U256::from_big_endian(&buf))) }) - .collect::>>()?; - - let max_bucket_no = [ - &config.fees.storage.bucket_no, - &config.fees.message_receipt.bucket_no, - &config.fees.nondet_output.bucket_no, - &config.fees.message_fee.bucket_no, - &config.fees.event.bucket_no, + .collect::>>()?; + + let bucket_names = [ + &config.fees.storage.buckets, + &config.fees.message_receipt.buckets, + &config.fees.nondet_output.buckets, + &config.fees.message_fee.buckets, + &config.fees.event.buckets, ] .into_iter() .flat_map(|v| v.iter().copied()) - .max() - .unwrap_or(0); + .collect::>(); // 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.into(), &mut bucket_totals); - anyhow::ensure!( - usize::from(max_bucket_no) < bucket_totals.len(), - "fees config references bucket {max_bucket_no} but only {} bucket(s) provided", - bucket_totals.len(), - ); + fill_nested_fee_buckets(is_nested, &bucket_names, &mut bucket_totals); + for name in bucket_names { + anyhow::ensure!( + bucket_totals.contains_key(name.as_str()), + "fees config references missing bucket `{}`", + name.as_str(), + ); + } let emit_leader_public_data = !args.sync && !is_nested && execution_data.leader_public_data.is_none(); @@ -218,7 +225,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { match execution_data.leader_public_data.as_ref() { None => (None, false), Some(encoded) => match genvm::leader_public_data::LeaderPublicData::decode(encoded) { - Ok(data) => (Some(data.nondet_block_outputs), false), + Ok(data) => (Some(data.nd_outs), false), Err(_) => (Some(Vec::new()), true), }, }; @@ -307,7 +314,7 @@ pub fn handle(args: Args, mut config: config::Config) -> Result<()> { let data_fees_consumed = runtime.block_on(shared_data.data_fees_limit.consumed()); let leader_public_data = if emit_leader_public_data { genvm::leader_public_data::LeaderPublicData { - nondet_block_outputs: Vec::new(), + nd_outs: Vec::new(), } .encode() } else { diff --git a/executor/src/host/mod.rs b/executor/src/host/mod.rs index 2b64265f..93eec875 100644 --- a/executor/src/host/mod.rs +++ b/executor/src/host/mod.rs @@ -244,7 +244,7 @@ impl FullResult { emissions: Vec::new(), nondet_disagreement: None, leader_public_data: bytes::Bytes::new(), - data_fees_remaining: Vec::new(), + data_fees_remaining: std::collections::BTreeMap::new(), data_fees_consumed: genvm_modules_interfaces::BucketsConsumed::default(), llm_consumed_gen_wei: primitive_types::U256::zero(), }, @@ -258,7 +258,7 @@ impl FullResult { rt_result: rt::vm::FullResult, leader_public_data: bytes::Bytes, nondet_disagreement: Option, - data_fees_remaining: Vec, + data_fees_remaining: std::collections::BTreeMap, data_fees_consumed: rt::fees::BucketsConsumed, llm_consumption: primitive_types::U256, recorded_actions: Vec, @@ -267,7 +267,7 @@ impl FullResult { backtrace: &'a Option, data: &'a calldata::unparsed::Maybe, data_fees_consumed: &'a rt::fees::BucketsConsumed, - data_fees_remaining: &'a Vec, + data_fees_remaining: &'a std::collections::BTreeMap, emissions: &'a Vec, kind: &'a host_fns::ResultCode, wasm_store_hashes: &'a rt::errors::WasmStoreHashes, @@ -1068,7 +1068,7 @@ mod tests { rt_result, bytes::Bytes::new(), None, - Vec::new(), + std::collections::BTreeMap::new(), rt::fees::BucketsConsumed::default(), primitive_types::U256::zero(), Vec::new(), @@ -1110,7 +1110,7 @@ mod tests { rt_result, bytes::Bytes::new(), None, - Vec::new(), + std::collections::BTreeMap::new(), rt::fees::BucketsConsumed::default(), primitive_types::U256::zero(), Vec::new(), diff --git a/executor/src/leader_public_data.rs b/executor/src/leader_public_data.rs index d3c0168b..ca371544 100644 --- a/executor/src/leader_public_data.rs +++ b/executor/src/leader_public_data.rs @@ -1,126 +1,84 @@ use bytes::Bytes; +use genvm_common::internal_constants::top_limits; -const PADDING: &[u8] = b"padded"; - -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, genlayer_calldata::Encode)] pub struct LeaderPublicData { - pub nondet_block_outputs: Vec, + pub nd_outs: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DecodeError; - -impl LeaderPublicData { - pub fn encode(&self) -> Bytes { - let mut payload = Vec::new(); - for output in self - .nondet_block_outputs - .iter() - .map(Bytes::as_ref) - .chain(std::iter::once(PADDING)) - { - encode_bytes(&mut payload, output); - } - - let mut encoded = Vec::new(); - encode_len(&mut encoded, payload.len(), 0xc0, 0xf7); - encoded.extend_from_slice(&payload); - encoded.into() - } - - pub fn decode(encoded: &[u8]) -> Result { - if encoded.is_empty() { - return Ok(Self { - nondet_block_outputs: Vec::new(), - }); - } - - let (payload_start, payload_len) = decode_len(encoded, 0, true)?; - let payload_end = payload_start.checked_add(payload_len).ok_or(DecodeError)?; - if payload_end != encoded.len() { - return Err(DecodeError); - } - - let mut cursor = payload_start; - let mut outputs = Vec::new(); - while cursor < payload_end { - let (data_start, data_len) = decode_len(encoded, cursor, false)?; - let data_end = data_start.checked_add(data_len).ok_or(DecodeError)?; - if data_end > payload_end { - return Err(DecodeError); +impl genlayer_calldata::codec::Decode for LeaderPublicData { + fn decode( + deserializer: D, + ) -> Result { + use genlayer_calldata::codec::{DecodeError, MapAccess, SeqAccess, Visitor}; + + struct OutputsVisitor; + impl Visitor for OutputsVisitor { + type Value = Vec; + + fn visit_seq( + self, + len: u64, + mut seq: A, + ) -> Result { + if len > u64::from(top_limits::NONDET_BLOCKS) { + return Err(DecodeError::Custom( + "too many nondeterministic outputs".to_owned(), + )); + } + + let mut outputs = Vec::with_capacity(len as usize); + while let Some(output) = seq.next_element::()? { + outputs.push(output); + } + debug_assert_eq!(outputs.len(), len as usize); + Ok(outputs) } - outputs.push(Bytes::copy_from_slice(&encoded[data_start..data_end])); - cursor = data_end; } - if outputs.last().is_none_or(|last| last.as_ref() != PADDING) { - return Err(DecodeError); + struct LeaderPublicDataVisitor; + impl Visitor for LeaderPublicDataVisitor { + type Value = LeaderPublicData; + + fn visit_map( + self, + len: u64, + mut map: A, + ) -> Result { + if len != 1 { + return Err(DecodeError::LengthMismatch { + expected: 1, + got: usize::try_from(len).unwrap_or(usize::MAX), + }); + } + let Some(key) = map.next_key()? else { + return Err(DecodeError::FieldMissing("nd_outs")); + }; + if key != "nd_outs" { + return Err(DecodeError::UnknownField(key.to_owned())); + } + + let nd_outs = map.next_value_visit(OutputsVisitor)?; + debug_assert!(map.next_key()?.is_none()); + Ok(LeaderPublicData { nd_outs }) + } } - outputs.pop(); - Ok(Self { - nondet_block_outputs: outputs, - }) + deserializer.deserialize(LeaderPublicDataVisitor) } } -fn encode_bytes(output: &mut Vec, value: &[u8]) { - if value.len() == 1 && value[0] < 0x80 { - output.push(value[0]); - return; - } - - encode_len(output, value.len(), 0x80, 0xb7); - output.extend_from_slice(value); -} - -fn encode_len(output: &mut Vec, len: usize, short_base: u8, long_base: u8) { - if len <= 55 { - output.push(short_base + len as u8); - return; - } - - let bytes = len.to_be_bytes(); - let first = bytes.iter().position(|byte| *byte != 0).unwrap(); - let len_bytes = &bytes[first..]; - output.push(long_base + len_bytes.len() as u8); - output.extend_from_slice(len_bytes); -} - -fn decode_len(encoded: &[u8], offset: usize, list: bool) -> Result<(usize, usize), DecodeError> { - let prefix = *encoded.get(offset).ok_or(DecodeError)?; - let short_base: u8 = if list { 0xc0 } else { 0x80 }; - let long_base: u8 = if list { 0xf7 } else { 0xb7 }; - - if !list && prefix < 0x80 { - return Ok((offset, 1)); - } - if prefix < short_base || prefix > long_base.saturating_add(size_of::() as u8) { - return Err(DecodeError); - } - if prefix <= long_base { - if !list && prefix == 0x81 && encoded.get(offset + 1).is_some_and(|byte| *byte < 0x80) { - return Err(DecodeError); - } - return Ok((offset + 1, usize::from(prefix - short_base))); - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecodeError; - let len_len = usize::from(prefix - long_base); - let len_start = offset.checked_add(1).ok_or(DecodeError)?; - let len_end = len_start.checked_add(len_len).ok_or(DecodeError)?; - let len_bytes = encoded.get(len_start..len_end).ok_or(DecodeError)?; - if len_bytes.first() == Some(&0) { - return Err(DecodeError); +impl LeaderPublicData { + pub fn encode(&self) -> Bytes { + genlayer_calldata::encode_obj(self).into() } - let mut buf = [0; size_of::()]; - buf[size_of::() - len_len..].copy_from_slice(len_bytes); - let len = usize::from_be_bytes(buf); - if len <= 55 { - return Err(DecodeError); + pub fn decode(encoded: &[u8]) -> Result { + genlayer_calldata::decode_obj(encoded).map_err(|_| DecodeError) } - - Ok((len_end, len)) } #[cfg(test)] @@ -128,40 +86,53 @@ mod tests { use super::*; #[test] - fn rlp_round_trip() { + fn calldata_round_trip() { let data = LeaderPublicData { - nondet_block_outputs: vec![Bytes::from_static(b"a"), Bytes::from_static(b"bc")], + nd_outs: vec![Bytes::from_static(b"a"), Bytes::from_static(b"bc")], }; assert_eq!(LeaderPublicData::decode(&data.encode()), Ok(data)); } #[test] - fn preserves_legacy_encoding() { + fn has_stable_calldata_encoding() { let data = LeaderPublicData { - nondet_block_outputs: vec![Bytes::from_static(b"test")], + nd_outs: vec![Bytes::from_static(b"a"), Bytes::from_static(b"bc")], }; - assert_eq!(data.encode().as_ref(), b"\xcc\x84test\x86padded"); + assert_eq!(data.encode().as_ref(), b"\x0e\x07nd_outs\x15\x0ba\x13bc"); } #[test] - fn empty_timeout_decodes_as_no_outputs() { + fn rejects_empty_legacy_and_trailing_data() { + assert_eq!(LeaderPublicData::decode(&[]), Err(DecodeError)); assert_eq!( - LeaderPublicData::decode(&[]), - Ok(LeaderPublicData { - nondet_block_outputs: Vec::new() - }) + LeaderPublicData::decode(b"\xcc\x84test\x86padded"), + Err(DecodeError) ); + + let mut encoded = LeaderPublicData { + nd_outs: Vec::new(), + } + .encode() + .to_vec(); + encoded.push(0); + assert_eq!(LeaderPublicData::decode(&encoded), Err(DecodeError)); } #[test] - fn rejects_noncanonical_rlp() { - assert_eq!(LeaderPublicData::decode(b"\xc0"), Err(DecodeError)); + fn bounds_output_count_while_decoding() { + let at_limit = LeaderPublicData { + nd_outs: vec![Bytes::new(); top_limits::NONDET_BLOCKS as usize], + }; + assert_eq!(LeaderPublicData::decode(&at_limit.encode()), Ok(at_limit)); + + let above_limit = LeaderPublicData { + nd_outs: vec![Bytes::new(); top_limits::NONDET_BLOCKS as usize + 1], + }; assert_eq!( - LeaderPublicData::decode(b"\xc7\x86padded\x00"), + LeaderPublicData::decode(&above_limit.encode()), Err(DecodeError) ); - assert_eq!(LeaderPublicData::decode(b"\xc2\x81\x01"), Err(DecodeError)); } } diff --git a/executor/src/lib.rs b/executor/src/lib.rs index be63b0ac..440814a4 100644 --- a/executor/src/lib.rs +++ b/executor/src/lib.rs @@ -487,6 +487,10 @@ pub async fn run_with_impl( data_fees_limit, messages_value_decremented: primitive_types::U256::zero(), emissions: Vec::new(), + message_fee_allocation_consumed: vec![ + primitive_types::U256::zero(); + entry_data.message_fee_allocation.len() + ], message_fee_allocation: entry_data.message_fee_allocation, }, det_subvm_hashes: Default::default(), @@ -622,7 +626,7 @@ pub async fn run_with( && supervisor.emit_leader_public_data { leader_public_data::LeaderPublicData { - nondet_block_outputs: nondet_results, + nd_outs: nondet_results, } .encode() } else { diff --git a/executor/src/rt/fees.rs b/executor/src/rt/fees.rs index 2f56dc62..31fa0264 100644 --- a/executor/src/rt/fees.rs +++ b/executor/src/rt/fees.rs @@ -58,9 +58,8 @@ pub fn fee_params_value_internal( let rotations: Vec = p.rotations.iter().map(|r| num_u256(*r)).collect(); m.insert("rotations".to_owned(), Value::Array(Arc::new(rotations))); // v0.6-dev (CON-549) price caps. `maxPriceGenPerTimeUnit` is the funding - // multiplier for balance-funded messages (chain `_calculateRoundFees`); the - // storage/receipt caps are exposed for completeness but stay out of the floor - // calc, mirroring `feeParamsToFeesDistribution` which zeroes them there. + // multiplier for all internal messages (chain `_calculateRoundFees`); the + // storage/receipt caps stay out of the floor calculation. m.insert( "maxPriceGenPerTimeUnit".to_owned(), num_u256(p.max_price_gen_per_time_unit), @@ -120,7 +119,7 @@ fn value_to_u256_vec( genvm_common::expr::Value::Array(arr) => { rt::errors::internal_ensure!( arr.len() == bucket_count, - "fee expression returned array of length {} but bucket_no has {bucket_count} entries", + "fee expression returned array of length {} but buckets has {bucket_count} entries", arr.len(), ); arr.iter() @@ -161,12 +160,12 @@ fn eval_with_node( /// [`DataLimit::consume_initial`]) + Σ `delta(attrs)`. `delta` is a function /// closing over `node`/the prelude. /// -/// `bucket_nos` can target multiple on-chain buckets. When the delta expression +/// `bucket_names` can target multiple on-chain buckets. When the delta expression /// returns a scalar it is charged identically against every bucket; when it /// returns an array the lengths must match and each element is charged to the /// corresponding bucket. All subtractions are atomic (all-or-nothing). struct Bucket { - bucket_nos: Vec, + bucket_names: Vec, subtract_on_start: Vec, delta: genvm_common::expr::Value, oom_error: abi::consts::VmError, @@ -177,7 +176,14 @@ struct Bucket { impl std::fmt::Debug for Bucket { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Bucket") - .field("bucket_nos", &self.bucket_nos) + .field( + "bucket_names", + &self + .bucket_names + .iter() + .map(symbol_table::GlobalSymbol::as_str) + .collect::>(), + ) .field("subtract_on_start", &self.subtract_on_start) .finish() } @@ -189,8 +195,8 @@ fn build_bucket( node: &genvm_common::expr::Value, oom_error: abi::consts::VmError, ) -> rt::errors::Result { - let n = cfg.bucket_no.len(); - rt::errors::internal_ensure!(n > 0, "bucket_no must have at least one entry"); + let n = cfg.buckets.len(); + rt::errors::internal_ensure!(n > 0, "buckets must have at least one entry"); let subtract_on_start = value_to_u256_vec( eval_with_node( @@ -203,8 +209,9 @@ fn build_bucket( )?; let delta = eval_with_node(prelude, "delta", &cfg.delta_expr, node)?; + debug_assert_eq!(subtract_on_start.len(), n); Ok(Bucket { - bucket_nos: cfg.bucket_no.clone(), + bucket_names: cfg.buckets.clone(), subtract_on_start, delta, oom_error, @@ -246,8 +253,10 @@ pub struct BucketsConsumed { #[derive(Debug, Clone, Copy)] pub struct MessageReceiptParams { + pub is_first_message: bool, pub is_internal: bool, pub is_deploy: bool, + pub rotations_count: u64, pub calldata_length: u64, pub code_length: u64, pub subtree_length: u64, @@ -255,7 +264,7 @@ pub struct MessageReceiptParams { #[derive(Debug)] pub struct DataLimit { - buckets: tokio::sync::Mutex>, + buckets: tokio::sync::Mutex>, storage: Bucket, message_receipt: Bucket, nondet_output: Bucket, @@ -265,7 +274,7 @@ pub struct DataLimit { impl DataLimit { pub fn new( - bucket_totals: Vec, + bucket_totals: std::collections::HashMap, fees: crate::config::FeesConfig, gas_data: std::collections::BTreeMap, ) -> rt::errors::Result { @@ -315,6 +324,24 @@ impl DataLimit { abi::consts::VmError::out_of().receipt().event(), )?; + for bucket in [ + &storage, + &message_receipt, + &nondet_output, + &message_fee, + &event, + ] { + debug_assert_eq!(bucket.bucket_names.len(), bucket.subtract_on_start.len()); + debug_assert_eq!(bucket.bucket_names.len(), bucket.total_consumed.len()); + for &name in &bucket.bucket_names { + rt::errors::internal_ensure!( + bucket_totals.contains_key(name.as_str()), + "fees config references missing bucket `{}`", + name.as_str(), + ); + } + } + Ok(Self { buckets: tokio::sync::Mutex::new(bucket_totals), storage, @@ -338,8 +365,11 @@ impl DataLimit { } Err(e) => Err(rt::errors::Error::internal(format!("{}", e))), }; - match res.and_then(|v| value_to_u256_vec(v, bucket.bucket_nos.len())) { - Ok(costs) => Ok(CostVec(costs)), + match res.and_then(|v| value_to_u256_vec(v, bucket.bucket_names.len())) { + Ok(costs) => { + debug_assert_eq!(costs.len(), bucket.bucket_names.len()); + Ok(CostVec(costs)) + } Err(e) => { log_error!(error:err = e; "failed to evaluate fee expression"); Err(e).ctx("failed to evaluate fee expression") @@ -357,16 +387,26 @@ impl DataLimit { } async fn consume_bucket_raw(&self, bucket: &Bucket, costs: &[primitive_types::U256]) -> bool { + debug_assert_eq!(bucket.bucket_names.len(), costs.len()); + debug_assert_eq!(bucket.bucket_names.len(), bucket.total_consumed.len()); let mut buckets = self.buckets.lock().await; if !Self::bucket_costs_fit(&buckets, bucket, costs) { return false; } - for (i, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() { - buckets[usize::from(bno)] -= cost; + for (i, (&name, &cost)) in bucket.bucket_names.iter().zip(costs.iter()).enumerate() { + let Some(remaining) = buckets.get_mut(name.as_str()) else { + debug_assert!( + buckets.contains_key(name.as_str()), + "validated bucket disappeared: {}", + name.as_str() + ); + return false; + }; + *remaining -= cost; log_debug!( - bucket = bno, + bucket = name.as_str(), cost:display = cost, - remaining:display = buckets[usize::from(bno)]; + remaining:display = *remaining; "consume_bucket: ok" ); *bucket.total_consumed[i].lock().await += cost; @@ -376,36 +416,50 @@ impl DataLimit { } fn bucket_costs_fit( - buckets: &[primitive_types::U256], + buckets: &std::collections::HashMap, bucket: &Bucket, costs: &[primitive_types::U256], ) -> bool { - for (idx, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() { - let Some(remaining) = buckets.get(usize::from(bno)) else { - log_warn!(bucket = bno; "consume_bucket: bucket index out of range"); + debug_assert_eq!(bucket.bucket_names.len(), costs.len()); + for (idx, (&name, &cost)) in bucket.bucket_names.iter().zip(costs.iter()).enumerate() { + let Some(remaining) = buckets.get(name.as_str()) else { + debug_assert!( + buckets.contains_key(name.as_str()), + "validated bucket disappeared: {}", + name.as_str() + ); + log_warn!(bucket = name.as_str(); "consume_bucket: bucket missing"); return false; }; if *remaining < cost { log_warn!( - bucket = bno, + bucket = name.as_str(), cost:display = cost, remaining:display = *remaining; "consume_bucket: insufficient funds" ); return false; } - // when the same bucket_no appears more than once, verify + // When the same bucket appears more than once, verify // cumulative cost fits let mut cumulative = cost; - for (&prev_bno, &prev_cost) in bucket.bucket_nos[..idx].iter().zip(costs[..idx].iter()) + for (&prev_name, &prev_cost) in + bucket.bucket_names[..idx].iter().zip(costs[..idx].iter()) { - if prev_bno == bno { - cumulative += prev_cost; + if prev_name == name { + let Some(total) = cumulative.checked_add(prev_cost) else { + log_warn!( + bucket = name.as_str(); + "consume_bucket: cumulative cost overflow" + ); + return false; + }; + cumulative = total; } } if *remaining < cumulative { log_warn!( - bucket = bno, + bucket = name.as_str(), cumulative:display = cumulative, remaining:display = *remaining; "consume_bucket: insufficient funds (cumulative)" @@ -426,8 +480,13 @@ impl DataLimit { Ok(Self::bucket_costs_fit(&buckets, bucket, &costs.0)) } - pub async fn remaining(&self) -> Vec { - self.buckets.lock().await.clone() + pub async fn remaining(&self) -> std::collections::BTreeMap { + self.buckets + .lock() + .await + .iter() + .map(|(name, total)| (name.clone(), *total)) + .collect() } async fn sum_consumed(bucket: &Bucket) -> primitive_types::U256 { @@ -468,7 +527,11 @@ impl DataLimit { bucket.oom_error.clone(), rt::errors::internal!( "subtract_on_start exceeds bucket {:?} total", - bucket.bucket_nos + bucket + .bucket_names + .iter() + .map(symbol_table::GlobalSymbol::as_str) + .collect::>() ), )); } @@ -488,8 +551,10 @@ impl DataLimit { self.calculate_bucket( &self.message_receipt, &[ + ("isFirstMessage", params.is_first_message.into()), ("isInternal", params.is_internal.into()), ("isDeploy", params.is_deploy.into()), + ("rotationsCount", num(params.rotations_count)), ("calldataLength", num(params.calldata_length)), ("codeLength", num(params.code_length)), ("subtreeLength", num(params.subtree_length)), @@ -535,22 +600,14 @@ impl DataLimit { } } - /// `balance_funded` selects the chain's `minMessagePrimaryFees` multiplier: - /// balance-funded (`useBalance`) messages charge the consensus term at the - /// guest's `maxPriceGenPerTimeUnit` cap (per `_calculateRoundFees`), whereas - /// allocation-matched messages use the node's live `genPerTimeUnit`. pub fn calculate_message_fee_internal( &self, - on: abi::gl_call::On, - balance_funded: bool, matched_fee_params: &genlayer_sdk::abi::fees::InternalMessageParams, ) -> rt::errors::Result { self.calculate_bucket( &self.message_fee, &[ ("isInternal", true.into()), - ("onAcceptance", (on == abi::gl_call::On::Decided).into()), - ("balanceFunded", balance_funded.into()), ( "matchedFeeParams", fee_params_value_internal(matched_fee_params), @@ -563,31 +620,61 @@ impl DataLimit { pub async fn consume_message_fee(&self, cost_fee: &CostVec, cost_receipt: &CostVec) -> bool { let mut buckets = self.buckets.lock().await; - // Build a cumulative deduction map: bucket_index -> total to subtract. - let mut deductions: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - - for (&bno, &cost) in self.message_fee.bucket_nos.iter().zip(cost_fee.0.iter()) { - *deductions.entry(bno).or_default() += cost; + debug_assert_eq!(self.message_fee.bucket_names.len(), cost_fee.0.len()); + debug_assert_eq!( + self.message_receipt.bucket_names.len(), + cost_receipt.0.len() + ); + let mut deductions: Vec<(symbol_table::GlobalSymbol, primitive_types::U256)> = Vec::new(); + + for (&name, &cost) in self.message_fee.bucket_names.iter().zip(cost_fee.0.iter()) { + if let Some((_, total)) = deductions + .iter_mut() + .find(|(existing, _)| *existing == name) + { + let Some(sum) = total.checked_add(cost) else { + log_warn!(bucket = name.as_str(); "consume_message_fee: cost overflow"); + return false; + }; + *total = sum; + } else { + deductions.push((name, cost)); + } } - for (&bno, &cost) in self + for (&name, &cost) in self .message_receipt - .bucket_nos + .bucket_names .iter() .zip(cost_receipt.0.iter()) { - *deductions.entry(bno).or_default() += cost; + if let Some((_, total)) = deductions + .iter_mut() + .find(|(existing, _)| *existing == name) + { + let Some(sum) = total.checked_add(cost) else { + log_warn!(bucket = name.as_str(); "consume_message_fee: cost overflow"); + return false; + }; + *total = sum; + } else { + deductions.push((name, cost)); + } } // Check all buckets first (atomic: all-or-nothing). - for (&bno, &total) in &deductions { - let Some(remaining) = buckets.get(usize::from(bno)) else { - log_warn!(bucket = bno; "consume_message_fee: bucket index out of range"); + for &(name, total) in &deductions { + let Some(remaining) = buckets.get(name.as_str()) else { + debug_assert!( + buckets.contains_key(name.as_str()), + "validated bucket disappeared: {}", + name.as_str() + ); + log_warn!(bucket = name.as_str(); "consume_message_fee: bucket missing"); return false; }; if *remaining < total { log_warn!( - bucket = bno, + bucket = name.as_str(), cost:display = total, remaining:display = *remaining; "consume_message_fee: insufficient funds" @@ -597,8 +684,11 @@ impl DataLimit { } // Apply all deductions. - for (&bno, &total) in &deductions { - buckets[usize::from(bno)] -= total; + for &(name, total) in &deductions { + let remaining = buckets + .get_mut(name.as_str()) + .expect("validated fee bucket must remain present"); + *remaining -= total; } std::mem::drop(buckets); diff --git a/executor/src/wasi/genlayer_sdk/message.rs b/executor/src/wasi/genlayer_sdk/message.rs index 9742f7a7..a0970ec7 100644 --- a/executor/src/wasi/genlayer_sdk/message.rs +++ b/executor/src/wasi/genlayer_sdk/message.rs @@ -1,9 +1,96 @@ use super::*; + +fn allocation_match_priority( + node: &genvm_modules_interfaces::fees::MessageAllocationNode, + recipient: calldata::Address, + call_key: genvm_modules_interfaces::abi_stub::CallKey, +) -> Option { + let recipient_priority = match node.recipient { + Some(candidate) if candidate == recipient => 0, + Some(_) => return None, + None => 2, + }; + let call_key_priority = match node.call_key { + Some(candidate) if candidate == call_key => 0, + Some(_) => return None, + None => 1, + }; + + Some(recipient_priority + call_key_priority) +} + +fn internal_allocation_match_priority( + node: &genvm_modules_interfaces::fees::MessageAllocationNode, + recipient: calldata::Address, + call_key: genvm_modules_interfaces::abi_stub::CallKey, +) -> Option { + if node.budget.is_zero() { + return None; + } + + allocation_match_priority(node, recipient, call_key) +} + +pub(super) fn resolve_internal_allocation( + nodes: &[genvm_modules_interfaces::fees::MessageAllocationNode], + on: genvm_modules_interfaces::On, + recipient: calldata::Address, + call_key: genvm_modules_interfaces::abi_stub::CallKey, +) -> Option<( + usize, + std::sync::Arc, +)> { + let priority = nodes + .iter() + .filter(|node| { + matches!( + &node.fee_params, + genvm_modules_interfaces::fees::MessageAllocationNodeParams::Internal(_) + ) + }) + .filter_map(|node| internal_allocation_match_priority(node, recipient, call_key)) + .min()?; + let index = nodes.iter().position(|node| { + internal_allocation_match_priority(node, recipient, call_key) == Some(priority) + && node.on == on + && matches!( + &node.fee_params, + genvm_modules_interfaces::fees::MessageAllocationNodeParams::Internal(_) + ) + })?; + let node = &nodes[index]; + let params = node.matches_internal(on, recipient, call_key)?; + + Some((index, params)) +} + +pub(super) fn external_allocation_candidates( + nodes: &[genvm_modules_interfaces::fees::MessageAllocationNode], + recipient: calldata::Address, + call_key: genvm_modules_interfaces::abi_stub::CallKey, +) -> Vec { + let mut candidates = nodes + .iter() + .enumerate() + .filter(|(_, node)| { + matches!( + &node.fee_params, + genvm_modules_interfaces::fees::MessageAllocationNodeParams::External(_) + ) + }) + .filter_map(|(index, node)| { + allocation_match_priority(node, recipient, call_key).map(|priority| (priority, index)) + }) + .collect::>(); + candidates.sort_by_key(|(priority, _)| *priority); + candidates.into_iter().map(|(_, index)| index).collect() +} use crate::rt::errors::ResultExt as _; use genlayer_calldata::codec::Encode; /// Named arguments for [`consume_message_fee_internal`]. struct ConsumeInternalArgs { + is_first_message: bool, is_deploy: bool, calldata_length: u64, code_length: u64, @@ -14,7 +101,10 @@ struct ConsumeInternalArgs { enum FeeFunding<'a> { /// Sender-pool allocation: fee capped by `node.budget`; consumes the /// message-fee and receipt buckets. - Allocation(&'a mut genvm_modules_interfaces::fees::MessageAllocationNode), + Allocation { + node: &'a genvm_modules_interfaces::fees::MessageAllocationNode, + consumed: &'a mut primitive_types::U256, + }, /// Balance-funded (`useBalance`): the metered fee is the `declaredBudget`, /// reserved from the contract balance. On-chain such messages are excluded /// from the sender pool, so the message-fee bucket is skipped and only the @@ -38,6 +128,12 @@ fn convert_call_key_to_modules(call_key: abi::CallKey) -> genvm_modules_interfac genvm_modules_interfaces::CallKey(call_key.0) } +pub(super) fn next_message_is_first(emissions: &[domain::ExecutionEmission]) -> bool { + emissions + .iter() + .all(|emission| matches!(emission, domain::ExecutionEmission::Event { .. })) +} + fn convert_internal_message_params_to_sdk( params: &genvm_modules_interfaces::fees::InternalMessageParams, ) -> abi::fees::InternalMessageParams { @@ -65,21 +161,35 @@ async fn consume_message_fee_internal( shared_data: &rt::SharedData, funding: FeeFunding<'_>, fee_params: Arc, - on: gl_call::On, args: ConsumeInternalArgs, ) -> Result { - let balance_funded = matches!(funding, FeeFunding::Balance { .. }); - let fee_cost = shared_data + let mut fee_cost = shared_data .data_fees_limit - .calculate_message_fee_internal(on, balance_funded, &fee_params) + .calculate_message_fee_internal(&fee_params) .map_err(internal_trap)?; + + if let FeeFunding::Allocation { node, .. } = &funding { + let declared_budget = node + .children + .iter() + .try_fold(fee_cost.reported_fee(), |total, child| { + total.checked_add(child.budget) + }) + .ok_or_else(|| { + internal_trap(rt::errors::internal!("message declared budget overflow")) + })?; + fee_cost.0[0] = declared_budget; + } + let fee_total = fee_cost.reported_fee(); let receipt_cost = shared_data .data_fees_limit .calculate_message_receipt(rt::fees::MessageReceiptParams { + is_first_message: args.is_first_message, is_internal: true, is_deploy: args.is_deploy, + rotations_count: usize_into_u64(fee_params.rotations.len()), calldata_length: args.calldata_length, code_length: args.code_length, subtree_length: args.subtree_length, @@ -88,12 +198,17 @@ async fn consume_message_fee_internal( .map_err(internal_trap)?; match funding { - FeeFunding::Allocation(node) => { - if fee_total > node.budget { + FeeFunding::Allocation { node, consumed } => { + let remaining_budget = node.budget.checked_sub(*consumed).ok_or_else(|| { + internal_trap(rt::errors::internal!( + "message allocation consumed budget exceeds its total" + )) + })?; + if fee_total > remaining_budget { log_warn!( node:cd = *node, fee_cost:cd = fee_total, - budget: cd = node.budget; + budget: cd = remaining_budget; "message fee cost exceeds node budget" ); return Err(internal_trap(rt::errors::Error::vm( @@ -123,7 +238,7 @@ async fn consume_message_fee_internal( ))); } - node.budget -= fee_total; + *consumed += fee_total; } FeeFunding::Balance { value, @@ -172,13 +287,15 @@ async fn consume_message_fee_internal( /// Named arguments for [`consume_message_fee_external`]. struct ConsumeExternalArgs { + is_first_message: bool, is_deploy: bool, calldata_length: u64, } async fn consume_message_fee_external( shared_data: &rt::SharedData, - node: &mut genvm_modules_interfaces::fees::MessageAllocationNode, + node: &genvm_modules_interfaces::fees::MessageAllocationNode, + consumed: &mut primitive_types::U256, params: abi::fees::ExternalMessageParams, // External messages are always emitted on finalization; carried for signature // symmetry with the internal path. @@ -191,7 +308,12 @@ async fn consume_message_fee_external( .map_err(internal_trap)?; let fee_total = fee_cost.reported_fee(); - if fee_total > node.budget { + let remaining_budget = node.budget.checked_sub(*consumed).ok_or_else(|| { + internal_trap(rt::errors::internal!( + "message allocation consumed budget exceeds its total" + )) + })?; + if fee_total > remaining_budget { return Err(internal_trap(rt::errors::Error::vm( abi::consts::VmError::out_of() .message_fee() @@ -203,8 +325,10 @@ async fn consume_message_fee_external( let receipt_cost = shared_data .data_fees_limit .calculate_message_receipt(rt::fees::MessageReceiptParams { + is_first_message: args.is_first_message, is_internal: false, is_deploy: args.is_deploy, + rotations_count: 0, calldata_length: args.calldata_length, code_length: 0, subtree_length: 0, @@ -224,7 +348,7 @@ async fn consume_message_fee_external( ))); } - node.budget -= fee_total; + *consumed += fee_total; Ok(rt::fees::MessageFeeConsumption { message_fee: fee_cost, @@ -232,16 +356,49 @@ async fn consume_message_fee_external( }) } +async fn consume_external_receipt_only( + shared_data: &rt::SharedData, + args: ConsumeExternalArgs, +) -> Result { + let receipt_cost = shared_data + .data_fees_limit + .calculate_message_receipt(rt::fees::MessageReceiptParams { + is_first_message: args.is_first_message, + is_internal: false, + is_deploy: args.is_deploy, + rotations_count: 0, + calldata_length: args.calldata_length, + code_length: 0, + subtree_length: 0, + }) + .map_err(internal_trap)?; + + if !shared_data + .data_fees_limit + .consume_message_receipt_only(&receipt_cost) + .await + { + return Err(internal_trap(rt::errors::Error::vm( + abi::consts::VmError::out_of().receipt().message().val(), + ))); + } + + Ok(rt::fees::MessageFeeConsumption { + message_fee: rt::fees::CostVec(vec![primitive_types::U256::zero()]), + receipt_fee: receipt_cost, + }) +} + /// Magnitude bounds (in significant bits; a larger field is rejected) on /// guest-supplied fee params. Invariant the code cannot express: the worst-case -/// `messageFeeFloor` product must stay within U256, since the fee evaluator's +/// `messageFeeFloor` result must stay within U256, since the fee evaluator's /// `rational_to_u256` treats overflow as an internal abort. Three guest fields /// multiply into one floor term (`maxPrice × rotations entry × validatorTU`), /// so with the default 18-round validator table (counts ≤ 1537 < 2^11) the -/// accepted-lifecycle worst case is -/// lifecycle(<2^4) × [price(<2^96) × rounds(<2^5) × rot(<2^33) +/// worst case is +/// price(<2^96) × rounds(<2^5) × rot(<2^33) /// × (leaderTU + vpr × validatorTU)(<2^44) -/// + price(<2^96) × leaderRounds(<2^36)] +/// + price(<2^96) × leaderRounds(<2^36) /// < 2^183 ≪ 2^256. /// Economically generous: 2^96 atto-GEN ≈ 8e10 GEN for prices/budgets; 2^32 /// for counts (time units per phase, rotations per round). @@ -357,49 +514,100 @@ impl ContextVFS<'_> { call_key.0[..4].copy_from_slice(&calldata[..4]); } - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_external(address, convert_call_key_to_modules(call_key)) - .map(|params| (node, params)) - }) - else { + let recipient = address; + let call_key_modules = convert_call_key_to_modules(call_key); + let candidates = external_allocation_candidates( + &self.context.data.accumulator.message_fee_allocation, + recipient, + call_key_modules, + ); + let has_candidates = !candidates.is_empty(); + let mut matched = None; + for index in candidates { + let node = &self.context.data.accumulator.message_fee_allocation[index]; + let Some(params) = node.matches_external(recipient, call_key_modules) else { + continue; + }; + let params = convert_external_message_params_to_sdk(params); + let fee = self + .context + .data + .supervisor + .shared_data + .data_fees_limit + .calculate_message_fee_external(¶ms) + .map_err(internal_trap)?; + let remaining_budget = node + .budget + .checked_sub( + self.context + .data + .accumulator + .message_fee_allocation_consumed[index], + ) + .ok_or_else(|| { + internal_trap(rt::errors::internal!( + "message allocation consumed budget exceeds its total" + )) + })?; + if fee.reported_fee() <= remaining_budget { + matched = Some((index, params)); + break; + } + } + if has_candidates && matched.is_none() { log_warn!( recipient = address, call_key:? = call_key; - "no matching node for message fee allocation" + "matching external allocations are exhausted" ); return Err(internal_trap(rt::errors::Error::vm( - abi::consts::VmError::fee() - .no_matching_allocation() + abi::consts::VmError::out_of() + .message_fee() + .allocation_budget() .external(), ))); - }; + } let calldata_length = calldata.len().into_int_comptime(); - let matched_params = convert_external_message_params_to_sdk(matched_params); let allocation = reserve_permanent( &self.context.limiter, emission_allocation_size(&[calldata_length]), "external message", )?; - let fees = consume_message_fee_external( - &self.context.data.supervisor.shared_data, - matched_node, - matched_params, - gl_call::On::Finalized, - ConsumeExternalArgs { - is_deploy: false, - calldata_length, - }, - ) - .await?; + let args = ConsumeExternalArgs { + is_first_message: next_message_is_first(&self.context.data.accumulator.emissions), + is_deploy: false, + calldata_length, + }; + let (fees, fee_params) = if let Some((matched_index, matched_params)) = matched { + let accumulator = &mut self.context.data.accumulator; + let matched_node = &accumulator.message_fee_allocation[matched_index]; + let consumed = &mut accumulator.message_fee_allocation_consumed[matched_index]; + let fees = consume_message_fee_external( + &self.context.data.supervisor.shared_data, + matched_node, + consumed, + matched_params, + gl_call::On::Finalized, + args, + ) + .await?; + (fees, matched_params) + } else { + let fees = + consume_external_receipt_only(&self.context.data.supervisor.shared_data, args) + .await?; + ( + fees, + abi::fees::ExternalMessageParams { + gas_limit: primitive_types::U256::zero(), + max_gas_price: primitive_types::U256::zero(), + }, + ) + }; self.context .data @@ -409,9 +617,9 @@ impl ContextVFS<'_> { address, calldata, value, - message_fee: fees.message_fee.reported_fee(), + message_fee: primitive_types::U256::zero(), receipt_fee: fees.receipt_fee.reported_fee(), - fee_params: matched_params, + fee_params, }); allocation.commit(); @@ -545,6 +753,7 @@ impl ContextVFS<'_> { use_balance, fee_params, )?; + let is_first_message = next_message_is_first(&self.context.data.accumulator.emissions); let call_key = if let Some(method_name) = &calldata.name { abi::CallKey::for_method(method_name) @@ -579,8 +788,8 @@ impl ContextVFS<'_> { my_balance, }, Arc::new(params.clone()), - on, ConsumeInternalArgs { + is_first_message, is_deploy: false, calldata_length, code_length: 0, @@ -630,21 +839,12 @@ impl ContextVFS<'_> { } } - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_internal( - convert_on_to_modules(on), - address, - convert_call_key_to_modules(call_key), - ) - .map(|params| (node, params)) - }) - else { + let Some((matched_index, matched_params)) = resolve_internal_allocation( + &self.context.data.accumulator.message_fee_allocation, + convert_on_to_modules(on), + address, + convert_call_key_to_modules(call_key), + ) else { log_warn!( recipient = address, call_key:? = call_key, @@ -671,11 +871,9 @@ impl ContextVFS<'_> { let calldata_length = enc.into_inner().0; let fee_params = convert_internal_message_params_to_sdk(matched_params.as_ref()); - let subtree = bytes::Bytes::from( - genvm_modules_interfaces::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - ), - ); + let accumulator = &mut self.context.data.accumulator; + let matched_node = &accumulator.message_fee_allocation[matched_index]; + let subtree = bytes::Bytes::from(matched_node.abi_encode()); let rotations_size = usize_into_u64(fee_params.rotations.len()) .saturating_mul(memory_limiter_consts::MESSAGE_FEE_ROTATION_ELEMENT_SIZE.into()); let allocation = reserve_permanent( @@ -690,10 +888,13 @@ impl ContextVFS<'_> { let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, - FeeFunding::Allocation(matched_node), + FeeFunding::Allocation { + node: matched_node, + consumed: &mut accumulator.message_fee_allocation_consumed[matched_index], + }, Arc::new(fee_params.clone()), - on, ConsumeInternalArgs { + is_first_message, is_deploy: false, calldata_length, code_length: 0, @@ -765,6 +966,7 @@ impl ContextVFS<'_> { use_balance, fee_params, )?; + let is_first_message = next_message_is_first(&self.context.data.accumulator.emissions); if let Some(params) = balance_params { let code_length = code.len().into_int_comptime(); @@ -794,8 +996,8 @@ impl ContextVFS<'_> { my_balance, }, Arc::new(params.clone()), - on, ConsumeInternalArgs { + is_first_message, is_deploy: true, calldata_length, code_length, @@ -844,21 +1046,12 @@ impl ContextVFS<'_> { } } - let Some((matched_node, matched_params)) = self - .context - .data - .accumulator - .message_fee_allocation - .iter_mut() - .find_map(|node| { - node.matches_internal( - convert_on_to_modules(on), - calldata::Address::zero(), - convert_call_key_to_modules(abi::CallKey::DEPLOY), - ) - .map(|params| (node, params)) - }) - else { + let Some((matched_index, matched_params)) = resolve_internal_allocation( + &self.context.data.accumulator.message_fee_allocation, + convert_on_to_modules(on), + calldata::Address::zero(), + convert_call_key_to_modules(abi::CallKey::DEPLOY), + ) else { log_warn!( recipient = calldata::Address::zero(), call_key:? = abi::CallKey::DEPLOY, @@ -879,11 +1072,9 @@ impl ContextVFS<'_> { let calldata_length = enc.into_inner().0; let fee_params = convert_internal_message_params_to_sdk(matched_params.as_ref()); - let subtree = bytes::Bytes::from( - genvm_modules_interfaces::fees::MessageAllocationNode::abi_encode( - &matched_node.children, - ), - ); + let accumulator = &mut self.context.data.accumulator; + let matched_node = &accumulator.message_fee_allocation[matched_index]; + let subtree = bytes::Bytes::from(matched_node.abi_encode()); let rotations_size = usize_into_u64(fee_params.rotations.len()) .saturating_mul(memory_limiter_consts::MESSAGE_FEE_ROTATION_ELEMENT_SIZE.into()); let allocation = reserve_permanent( @@ -899,10 +1090,13 @@ impl ContextVFS<'_> { let fees = consume_message_fee_internal( &self.context.data.supervisor.shared_data, - FeeFunding::Allocation(matched_node), + FeeFunding::Allocation { + node: matched_node, + consumed: &mut accumulator.message_fee_allocation_consumed[matched_index], + }, Arc::new(fee_params.clone()), - on, ConsumeInternalArgs { + is_first_message, is_deploy: true, calldata_length, code_length, diff --git a/executor/src/wasi/genlayer_sdk/mod.rs b/executor/src/wasi/genlayer_sdk/mod.rs index cf5fd34f..1f15cefd 100644 --- a/executor/src/wasi/genlayer_sdk/mod.rs +++ b/executor/src/wasi/genlayer_sdk/mod.rs @@ -145,6 +145,7 @@ pub struct VMDataAccumulator { pub messages_value_decremented: primitive_types::U256, pub emissions: Vec, pub message_fee_allocation: Vec, + pub message_fee_allocation_consumed: Vec, } impl VMDataAccumulator { diff --git a/executor/src/wasi/genlayer_sdk/run.rs b/executor/src/wasi/genlayer_sdk/run.rs index 8cdb1816..af4d87d8 100644 --- a/executor/src/wasi/genlayer_sdk/run.rs +++ b/executor/src/wasi/genlayer_sdk/run.rs @@ -438,6 +438,7 @@ impl ContextVFS<'_> { messages_value_decremented: self.context.data.accumulator.messages_value_decremented, emissions: Vec::new(), message_fee_allocation: Vec::new(), + message_fee_allocation_consumed: Vec::new(), }; let vm_data = Box::new(SingleVMData { @@ -539,6 +540,7 @@ impl ContextVFS<'_> { messages_value_decremented: primitive_types::U256::zero(), emissions: Vec::new(), message_fee_allocation: Vec::new(), + message_fee_allocation_consumed: Vec::new(), }, det_subvm_hashes: Default::default(), // A CallContract child is granted the caller's full custom set; @@ -987,6 +989,7 @@ impl ContextVFS<'_> { messages_value_decremented: primitive_types::U256::max_value(), emissions: Vec::new(), message_fee_allocation: Vec::new(), + message_fee_allocation_consumed: Vec::new(), }; std::mem::swap(&mut self.context.data.accumulator, &mut fake_my_data); diff --git a/executor/src/wasi/genlayer_sdk/tests.rs b/executor/src/wasi/genlayer_sdk/tests.rs index 952d5eb4..1637c3e1 100644 --- a/executor/src/wasi/genlayer_sdk/tests.rs +++ b/executor/src/wasi/genlayer_sdk/tests.rs @@ -1,4 +1,7 @@ -use super::message::{validate_balance_fee, FEE_PARAM_COUNT_BITS, FEE_PARAM_PRICE_BITS}; +use super::message::{ + external_allocation_candidates, next_message_is_first, resolve_internal_allocation, + validate_balance_fee, FEE_PARAM_COUNT_BITS, FEE_PARAM_PRICE_BITS, +}; use super::run::{ call_contract_route, charge_nondet_output, derive_call_contract_permissions, leader_outcome_for_publication, leader_proposal_for_validation, nested_run_ok, @@ -28,12 +31,12 @@ fn errno(e: generated::types::Error) -> generated::types::Errno { fn nondet_fees_with_delta(total: u64, nondet_delta: &str) -> rt::fees::DataLimit { let bucket = |delta: &str| crate::config::FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".to_owned(), delta_expr: delta.to_owned(), }; rt::fees::DataLimit::new( - vec![U256::from(total)], + std::collections::HashMap::from([("test".to_owned(), U256::from(total))]), crate::config::FeesConfig { expr_prelude: String::new(), storage: bucket("\\attrs = 0"), @@ -53,7 +56,7 @@ fn nondet_fees(total: u64) -> rt::fees::DataLimit { fn emission_fees() -> crate::config::FeesConfig { let bucket = |delta: &str| crate::config::FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".to_owned(), delta_expr: delta.to_owned(), }; @@ -104,6 +107,143 @@ fn internal_message_allocation() -> genvm_modules_interfaces::fees::MessageAlloc } } +fn allocation_child( + budget: U256, + children: Vec, +) -> genvm_modules_interfaces::fees::MessageAllocationNode { + let mut node = internal_message_allocation(); + node.budget = budget; + node.children = children; + node +} + +#[test] +fn internal_allocation_prefers_exact_key_over_earlier_wildcard() { + let recipient = calldata::Address::from([7; 20]); + let call_key = genvm_modules_interfaces::abi_stub::CallKey([8; 32]); + let mut wildcard = internal_message_allocation(); + wildcard.recipient = Some(recipient); + wildcard.budget = U256::one(); + let mut exact = wildcard.clone(); + exact.call_key = Some(call_key); + exact.budget = U256::from(2); + let nodes = vec![wildcard, exact]; + + let (matched, _) = resolve_internal_allocation( + &nodes, + genvm_modules_interfaces::On::Finalized, + recipient, + call_key, + ) + .expect("exact allocation should match"); + + assert_eq!(nodes[matched].budget, U256::from(2)); +} + +#[test] +fn internal_allocation_skips_zero_budget_exact_key() { + let recipient = calldata::Address::from([7; 20]); + let call_key = genvm_modules_interfaces::abi_stub::CallKey([8; 32]); + let mut wildcard = internal_message_allocation(); + wildcard.recipient = Some(recipient); + wildcard.budget = U256::one(); + let mut exact = wildcard.clone(); + exact.call_key = Some(call_key); + exact.budget = U256::zero(); + let nodes = vec![wildcard, exact]; + + let (matched, _) = resolve_internal_allocation( + &nodes, + genvm_modules_interfaces::On::Finalized, + recipient, + call_key, + ) + .expect("wildcard allocation should match"); + + assert_eq!(nodes[matched].budget, U256::one()); +} + +#[test] +fn internal_allocation_phase_is_checked_after_key_resolution() { + let recipient = calldata::Address::from([7; 20]); + let call_key = genvm_modules_interfaces::abi_stub::CallKey([8; 32]); + let mut wildcard = internal_message_allocation(); + wildcard.recipient = Some(recipient); + let mut exact = wildcard.clone(); + exact.call_key = Some(call_key); + exact.on = genvm_modules_interfaces::On::Decided; + let nodes = vec![wildcard, exact]; + + assert!(resolve_internal_allocation( + &nodes, + genvm_modules_interfaces::On::Finalized, + recipient, + call_key, + ) + .is_none()); +} + +#[test] +fn internal_allocation_selects_phase_within_equal_keys() { + let recipient = calldata::Address::from([7; 20]); + let call_key = genvm_modules_interfaces::abi_stub::CallKey([8; 32]); + let mut finalized = internal_message_allocation(); + finalized.budget = U256::one(); + let mut decided = finalized.clone(); + decided.on = genvm_modules_interfaces::On::Decided; + decided.budget = U256::from(2); + let nodes = vec![finalized, decided]; + + let (matched, _) = resolve_internal_allocation( + &nodes, + genvm_modules_interfaces::On::Decided, + recipient, + call_key, + ) + .expect("decided allocation should match"); + + assert_eq!(nodes[matched].budget, U256::from(2)); +} + +#[test] +fn external_allocation_candidates_follow_consensus_precedence() { + let recipient = calldata::Address::from([7; 20]); + let call_key = genvm_modules_interfaces::abi_stub::CallKey([8; 32]); + let mut global_wildcard = external_message_allocation(); + global_wildcard.budget = U256::one(); + let mut recipient_wildcard = global_wildcard.clone(); + recipient_wildcard.recipient = Some(recipient); + recipient_wildcard.budget = U256::from(2); + let mut exact = recipient_wildcard.clone(); + exact.call_key = Some(call_key); + exact.budget = U256::from(3); + let nodes = vec![global_wildcard, recipient_wildcard, exact]; + + let candidates = external_allocation_candidates(&nodes, recipient, call_key); + let budgets = candidates + .into_iter() + .map(|index| nodes[index].budget) + .collect::>(); + + assert_eq!(budgets, vec![U256::from(3), U256::from(2), U256::one()]); +} + +#[test] +fn external_allocation_candidates_include_zero_budget_nodes() { + let recipient = calldata::Address::from([7; 20]); + let call_key = genvm_modules_interfaces::abi_stub::CallKey([8; 32]); + let mut node = external_message_allocation(); + node.recipient = Some(recipient); + node.call_key = Some(call_key); + node.budget = U256::zero(); + let nodes = vec![node]; + + assert_eq!( + external_allocation_candidates(&nodes, recipient, call_key), + vec![0] + ); +} + struct TestDir(std::path::PathBuf); impl TestDir { @@ -203,7 +343,7 @@ impl EmissionTestContext { ..Default::default() }, data_fees_limit: rt::fees::DataLimit::new( - vec![U256::from(fee_total)], + std::collections::HashMap::from([("test".to_owned(), U256::from(fee_total))]), fees.clone(), Default::default(), ) @@ -343,6 +483,7 @@ impl EmissionTestContext { external_message_allocation(), internal_message_allocation(), ], + message_fee_allocation_consumed: vec![U256::zero(); 2], }, det_subvm_hashes: Default::default(), granted_custom: Vec::new(), @@ -450,6 +591,29 @@ fn emission_allocation_overflow_cannot_fit_the_budget() { assert_eq!(emission_allocation_size(&[u64::MAX]), u64::MAX); } +#[test] +fn first_message_flag_ignores_events_and_flips_after_a_message() { + let event = domain::ExecutionEmission::Event { + topics: Vec::new(), + blob: calldata::Map::new().into(), + storage_fee: U256::zero(), + }; + assert!(next_message_is_first(&[event])); + + let message = domain::ExecutionEmission::ExternalMessage { + address: calldata::Address::zero(), + calldata: bytes::Bytes::new(), + value: U256::zero(), + message_fee: U256::zero(), + receipt_fee: U256::zero(), + fee_params: abi::fees::ExternalMessageParams { + gas_limit: U256::zero(), + max_gas_price: U256::zero(), + }, + }; + assert!(!next_message_is_first(&[message])); +} + #[tokio::test] async fn messages_rejected_by_memory_are_not_appended_or_charged() { for emission in MessageEmission::ALL { @@ -476,7 +640,7 @@ async fn messages_rejected_by_memory_are_not_appended_or_charged() { .data_fees_limit .remaining() .await, - vec![U256::from(1)], + std::collections::BTreeMap::from([("test".to_owned(), U256::from(1))]), "{} was charged", emission.name() ); @@ -567,6 +731,186 @@ async fn messages_rejected_by_fee_are_not_appended_and_release_memory() { } } +#[tokio::test] +async fn unallocated_external_receipt_exhaustion_is_classified_as_receipt() { + let mut test = EmissionTestContext::new(u32::MAX, 0); + test.context + .data + .accumulator + .message_fee_allocation + .retain(|node| { + matches!( + &node.fee_params, + genvm_modules_interfaces::fees::MessageAllocationNodeParams::Internal(_) + ) + }); + + let error = test + .emit_message(MessageEmission::External) + .await + .unwrap_err(); + + assert!(trap_message(error).contains("out_of receipt message")); + test.shutdown().await; +} + +#[tokio::test] +async fn repeated_internal_messages_preserve_canonical_subtree_and_charge_budgets() { + for emission in [ + MessageEmission::InternalAllocation, + MessageEmission::DeployAllocation, + ] { + let mut test = EmissionTestContext::new(u32::MAX, 14); + let grandchild = allocation_child(U256::one(), Vec::new()); + let first_child = allocation_child(U256::from(2), vec![grandchild]); + let second_child = allocation_child(U256::from(3), Vec::new()); + test.context.data.accumulator.message_fee_allocation[1].children = + vec![first_child, second_child]; + + test.emit_message(emission).await.unwrap(); + test.emit_message(emission).await.unwrap(); + + let emission_data = |emission: &domain::ExecutionEmission| match emission { + domain::ExecutionEmission::InternalMessage { + message_fee, + subtree, + .. + } + | domain::ExecutionEmission::InternalDeployMessage { + message_fee, + subtree, + .. + } => (*message_fee, subtree.clone()), + other => panic!("unexpected emission: {other:?}"), + }; + let first = emission_data(&test.context.data.accumulator.emissions[0]); + let second = emission_data(&test.context.data.accumulator.emissions[1]); + assert_eq!(first.0, U256::from(6), "{}", emission.name()); + assert_eq!(second.0, U256::from(6), "{}", emission.name()); + assert_eq!(first.1, second.1, "{} subtree changed", emission.name()); + assert_eq!( + test.context.data.accumulator.message_fee_allocation[1].budget, + U256::from(100), + "{}", + emission.name() + ); + assert_eq!( + test.context + .data + .accumulator + .message_fee_allocation_consumed[1], + U256::from(12), + "{}", + emission.name() + ); + let consumed = test + .context + .data + .supervisor + .shared_data + .data_fees_limit + .consumed() + .await; + assert_eq!(consumed.message_fee, U256::from(12), "{}", emission.name()); + assert_eq!( + consumed.message_receipt, + U256::from(2), + "{}", + emission.name() + ); + + test.shutdown().await; + } +} + +#[tokio::test] +async fn child_budget_fee_failure_is_atomic() { + let mut test = EmissionTestContext::new(u32::MAX, 6); + test.context.data.accumulator.message_fee_allocation[1].children = vec![ + allocation_child(U256::from(2), Vec::new()), + allocation_child(U256::from(3), Vec::new()), + ]; + let memory_before = test.context.limiter.get_remaining_memory(); + + let error = test + .emit_message(MessageEmission::InternalAllocation) + .await + .unwrap_err(); + + assert!( + trap_message(error).contains("out_of message_fee total # internal"), + "unexpected fee error" + ); + assert!(test.context.data.accumulator.emissions.is_empty()); + assert_eq!( + test.context.data.accumulator.message_fee_allocation[1].budget, + U256::from(100) + ); + assert_eq!( + test.context + .data + .accumulator + .message_fee_allocation_consumed[1], + U256::zero() + ); + assert_eq!(test.context.limiter.get_remaining_memory(), memory_before); + let consumed = test + .context + .data + .supervisor + .shared_data + .data_fees_limit + .consumed() + .await; + assert_eq!(consumed.message_fee, U256::zero()); + assert_eq!(consumed.message_receipt, U256::zero()); + + test.shutdown().await; +} + +#[tokio::test] +async fn child_budget_overflow_is_internal_and_has_no_effect() { + let mut test = EmissionTestContext::new(u32::MAX, 1); + test.context.data.accumulator.message_fee_allocation[1].children = + vec![allocation_child(U256::MAX, Vec::new())]; + let memory_before = test.context.limiter.get_remaining_memory(); + + let error = test + .emit_message(MessageEmission::InternalAllocation) + .await + .unwrap_err(); + + assert!( + trap_message(error).contains("message declared budget overflow"), + "unexpected overflow error" + ); + assert!(test.context.data.accumulator.emissions.is_empty()); + assert_eq!( + test.context.data.accumulator.message_fee_allocation[1].budget, + U256::from(100) + ); + assert_eq!( + test.context + .data + .accumulator + .message_fee_allocation_consumed[1], + U256::zero() + ); + assert_eq!(test.context.limiter.get_remaining_memory(), memory_before); + let consumed = test + .context + .data + .supervisor + .shared_data + .data_fees_limit + .consumed() + .await; + assert_eq!(consumed.message_fee, U256::zero()); + assert_eq!(consumed.message_receipt, U256::zero()); + + test.shutdown().await; +} + #[tokio::test] async fn event_rejected_by_memory_is_not_appended_or_charged() { let mut test = EmissionTestContext::new(0, 1); @@ -591,7 +935,7 @@ async fn event_rejected_by_memory_is_not_appended_or_charged() { .data_fees_limit .remaining() .await, - vec![U256::from(1)] + std::collections::BTreeMap::from([("test".to_owned(), U256::from(1))]) ); test.shutdown().await; @@ -705,7 +1049,10 @@ async fn nondet_fee_preflight_fails_before_consuming_the_fallback_fee() { .await .is_err() ); - assert_eq!(fees.remaining().await, vec![U256::from(required - 1)]); + assert_eq!( + fees.remaining().await, + std::collections::BTreeMap::from([("test".to_owned(), U256::from(required - 1))]) + ); assert_eq!(fees.consumed().await.nondet_output, U256::zero()); } @@ -751,7 +1098,10 @@ async fn over_cap_nondet_payload_is_replaced_before_publication() { limiter.get_remaining_memory(), memory_budget - fee_error.allocation_size() as u32 ); - assert_eq!(fees.remaining().await, vec![U256::zero()]); + assert_eq!( + fees.remaining().await, + std::collections::BTreeMap::from([("test".to_owned(), U256::zero())]) + ); assert_eq!( fees.consumed().await.nondet_output, U256::from(fee_error_len) @@ -860,8 +1210,8 @@ fn zero_price_caps_are_inval() { #[test] fn huge_magnitude_params_are_inval() { // Security-review N1 repro: passes the emptiness/zero checks, but the - // 2^250 magnitudes would push messageFeeFloor past U256 and trip the - // evaluator's internal `fee cost exceeds U256 range` abort. + // 2^250 magnitudes would push messageFeeFloor past U256 and saturate the + // evaluator's result to U256::MAX. let p = abi::fees::InternalMessageParams { leader_time_units_allocation: U256::one() << 250, validator_time_units_allocation: U256::zero(), diff --git a/executor/tests/code_and_major_reads.rs b/executor/tests/code_and_major_reads.rs index 7592b919..a0b05ad9 100644 --- a/executor/tests/code_and_major_reads.rs +++ b/executor/tests/code_and_major_reads.rs @@ -38,7 +38,7 @@ impl HostStorageLocking for FakeHost { /// A minimal `DataLimit` whose storage bucket charges one unit per page. fn data_fees(total_pages: u64) -> Limiter { let bucket = |delta: &str| FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".to_owned(), delta_expr: delta.to_owned(), }; @@ -51,7 +51,10 @@ fn data_fees(total_pages: u64) -> Limiter { event: bucket("\\attrs = 0"), }; let dl = rt::fees::DataLimit::new( - vec![primitive_types::U256::from(total_pages)], + std::collections::HashMap::from([( + "test".to_owned(), + primitive_types::U256::from(total_pages), + )]), fees, Default::default(), ) diff --git a/executor/tests/fee_bucket_accounting.rs b/executor/tests/fee_bucket_accounting.rs new file mode 100644 index 00000000..aacb9a9e --- /dev/null +++ b/executor/tests/fee_bucket_accounting.rs @@ -0,0 +1,67 @@ +use genvm::config::{FeesBucketConfig, FeesConfig}; +use genvm::rt::fees::{CostVec, DataLimit}; +use primitive_types::U256; + +fn config(event: FeesBucketConfig) -> FeesConfig { + let bucket = || FeesBucketConfig { + buckets: vec![symbol_table::GlobalSymbol::from("test")], + subtract_on_start_expr: "0".to_owned(), + delta_expr: "\\attrs = 0".to_owned(), + }; + FeesConfig { + expr_prelude: String::new(), + storage: bucket(), + message_receipt: bucket(), + nondet_output: bucket(), + message_fee: bucket(), + event, + } +} + +fn data_limit(fees: FeesConfig) -> DataLimit { + DataLimit::new( + std::collections::HashMap::from([("test".to_owned(), U256::MAX)]), + fees, + Default::default(), + ) + .unwrap() +} + +#[tokio::test] +async fn duplicate_bucket_cost_overflow_is_rejected_atomically() { + let event = FeesBucketConfig { + buckets: vec![ + symbol_table::GlobalSymbol::from("test"), + symbol_table::GlobalSymbol::from("test"), + ], + subtract_on_start_expr: "0".to_owned(), + delta_expr: format!("\\attrs = [{}, 1]", U256::MAX), + }; + let fees = data_limit(config(event)); + + assert_eq!(fees.consume_event(0, 0).await.unwrap(), None); + assert_eq!( + fees.remaining().await, + std::collections::BTreeMap::from([("test".to_owned(), U256::MAX)]) + ); +} + +#[tokio::test] +async fn shared_message_bucket_cost_overflow_is_rejected_atomically() { + let event = FeesBucketConfig { + buckets: vec![symbol_table::GlobalSymbol::from("test")], + subtract_on_start_expr: "0".to_owned(), + delta_expr: "\\attrs = 0".to_owned(), + }; + let fees = data_limit(config(event)); + + assert!( + !fees + .consume_message_fee(&CostVec(vec![U256::MAX]), &CostVec(vec![U256::one()])) + .await + ); + assert_eq!( + fees.remaining().await, + std::collections::BTreeMap::from([("test".to_owned(), U256::MAX)]) + ); +} diff --git a/executor/tests/fee_bucket_config.rs b/executor/tests/fee_bucket_config.rs new file mode 100644 index 00000000..929a9212 --- /dev/null +++ b/executor/tests/fee_bucket_config.rs @@ -0,0 +1,71 @@ +use genvm::config::FeesBucketConfig; + +fn parse(input: &str) -> Result { + serde_yaml::from_str(input) +} + +#[test] +fn bucket_config_accepts_one_named_bucket() { + let config = parse("buckets: execution_data_gas\ndelta_expr: '\\a = 0'").unwrap(); + + assert_eq!(config.buckets.len(), 1); + assert_eq!(config.buckets[0].as_str(), "execution_data_gas"); +} + +#[test] +fn bucket_config_accepts_multiple_named_buckets() { + let config = + parse("buckets: [execution_data_gas, submitted_messages]\ndelta_expr: '\\a = 0'").unwrap(); + + let names = config + .buckets + .iter() + .map(symbol_table::GlobalSymbol::as_str) + .collect::>(); + assert_eq!(names, ["execution_data_gas", "submitted_messages"]); +} + +#[test] +fn bucket_config_rejects_numeric_buckets() { + let error = parse("buckets: 0\ndelta_expr: '\\a = 0'").unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("non-empty string"), + "unexpected error: {message}" + ); +} + +#[test] +fn bucket_config_rejects_empty_names() { + let error = parse("buckets: ''\ndelta_expr: '\\a = 0'").unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("must not be empty"), + "unexpected error: {message}" + ); +} + +#[test] +fn bucket_config_rejects_an_empty_list() { + let error = parse("buckets: []\ndelta_expr: '\\a = 0'").unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("at least one entry"), + "unexpected error: {message}" + ); +} + +#[test] +fn bucket_config_rejects_legacy_bucket_number() { + let error = + parse("buckets: execution_data_gas\nbucket_no: 0\ndelta_expr: '\\a = 0'").unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("unknown field `bucket_no`"), + "unexpected error: {message}" + ); +} diff --git a/executor/tests/message_fee_overlay.rs b/executor/tests/message_fee_overlay.rs new file mode 100644 index 00000000..b4489e5d --- /dev/null +++ b/executor/tests/message_fee_overlay.rs @@ -0,0 +1,76 @@ +use genvm::config::FeesConfig; +use genvm::rt::fees::DataLimit; +use primitive_types::U256; + +fn default_fees() -> FeesConfig { + let config: serde_yaml::Value = + serde_yaml::from_str(include_str!("../install/config/genvm.yaml")).unwrap(); + serde_yaml::from_value(config["fees"].clone()).unwrap() +} + +fn bucket_totals() -> std::collections::HashMap { + [ + "execution_data_gas", + "message_fee", + "nondet_outputs", + "submitted_messages", + "submitted_messages_count", + ] + .into_iter() + .map(|name| (name.to_owned(), U256::MAX)) + .collect() +} + +fn gas_data_without_overlay() -> std::collections::BTreeMap { + [ + ("storageUnitPrice", "1"), + ("lockedReceiptGasPrice", "1"), + ("receiptGasPerByte", "1"), + ("gasPerChangedSlot", "1"), + ("intrinsicGas", "0"), + ("bootloaderOverhead", "0"), + ("fixedProposeReceiptGas", "0"), + ("fixedMessageRevealGas", "0"), + ("receiptWrapperBytes", "1024"), + ("minProposeTimeout", "1"), + ( + "maxProposeTimeout", + "340282366920938463463374607431768211455", + ), + ("minCommitTimeout", "1"), + ( + "maxCommitTimeout", + "340282366920938463463374607431768211455", + ), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value.to_owned())) + .collect() +} + +fn fee_params() -> genlayer_sdk::abi::fees::InternalMessageParams { + genlayer_sdk::abi::fees::InternalMessageParams { + leader_time_units_allocation: U256::one(), + validator_time_units_allocation: U256::one(), + execution_budget_per_round: U256::one(), + rotations: vec![U256::zero()], + max_price_gen_per_time_unit: U256::one(), + storage_fee_max_gas_price: U256::one(), + receipt_fee_max_gas_price: U256::one(), + } +} + +#[test] +fn missing_overlay_split_is_not_treated_as_zero() { + let fees = DataLimit::new(bucket_totals(), default_fees(), gas_data_without_overlay()).unwrap(); + + let error = fees + .calculate_message_fee_internal(&fee_params()) + .unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("overlaySplitBps"), + "unexpected error: {message}" + ); +} diff --git a/executor/tests/message_fee_time_units.rs b/executor/tests/message_fee_time_units.rs new file mode 100644 index 00000000..5eb23e0b --- /dev/null +++ b/executor/tests/message_fee_time_units.rs @@ -0,0 +1,116 @@ +use genvm::config::FeesConfig; +use genvm::rt::fees::DataLimit; +use primitive_types::U256; + +fn default_fees() -> FeesConfig { + let config: serde_yaml::Value = + serde_yaml::from_str(include_str!("../install/config/genvm.yaml")).unwrap(); + serde_yaml::from_value(config["fees"].clone()).unwrap() +} + +fn bucket_totals() -> std::collections::HashMap { + [ + "execution_data_gas", + "message_fee", + "nondet_outputs", + "submitted_messages", + "submitted_messages_count", + ] + .into_iter() + .map(|name| (name.to_owned(), U256::MAX)) + .collect() +} + +fn gas_data( + min_propose: u64, + max_propose: u64, + min_commit: u64, + max_commit: u64, +) -> std::collections::BTreeMap { + [ + ("storageUnitPrice", "1".to_owned()), + ("lockedReceiptGasPrice", "1".to_owned()), + ("receiptGasPerByte", "1".to_owned()), + ("gasPerChangedSlot", "1".to_owned()), + ("intrinsicGas", "0".to_owned()), + ("bootloaderOverhead", "0".to_owned()), + ("fixedProposeReceiptGas", "0".to_owned()), + ("fixedMessageRevealGas", "0".to_owned()), + ("overlaySplitBps", "0".to_owned()), + ("receiptWrapperBytes", "1024".to_owned()), + ("minProposeTimeout", min_propose.to_string()), + ("maxProposeTimeout", max_propose.to_string()), + ("minCommitTimeout", min_commit.to_string()), + ("maxCommitTimeout", max_commit.to_string()), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect() +} + +fn fee_params( + leader_time_units: u64, + validator_time_units: u64, +) -> genlayer_sdk::abi::fees::InternalMessageParams { + genlayer_sdk::abi::fees::InternalMessageParams { + leader_time_units_allocation: leader_time_units.into(), + validator_time_units_allocation: validator_time_units.into(), + execution_budget_per_round: U256::one(), + rotations: vec![U256::zero()], + max_price_gen_per_time_unit: U256::one(), + storage_fee_max_gas_price: U256::one(), + receipt_fee_max_gas_price: U256::one(), + } +} + +#[test] +fn both_zero_disables_phase_timeout_validation() { + let fees = DataLimit::new(bucket_totals(), default_fees(), gas_data(5, 10, 20, 30)).unwrap(); + + fees.calculate_message_fee_internal(&fee_params(0, 0)) + .unwrap(); +} + +#[test] +fn phase_specific_bounds_are_inclusive() { + let fees = DataLimit::new(bucket_totals(), default_fees(), gas_data(5, 5, 10, 10)).unwrap(); + + fees.calculate_message_fee_internal(&fee_params(5, 10)) + .unwrap(); +} + +#[test] +fn nonzero_phase_timeouts_outside_bounds_are_rejected() { + let fees = DataLimit::new(bucket_totals(), default_fees(), gas_data(5, 10, 20, 30)).unwrap(); + + for (leader, validator) in [(4, 20), (11, 20), (5, 19), (5, 31), (0, 20), (5, 0)] { + let error = fees + .calculate_message_fee_internal(&fee_params(leader, validator)) + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("fee below_minimum"), + "unexpected error for leader={leader}, validator={validator}: {message}" + ); + } +} + +#[test] +fn primary_fee_is_not_multiplied_by_appeal_lifecycle() { + let mut gas_data = gas_data(1, u64::MAX, 1, u64::MAX); + gas_data.insert("overlaySplitBps".to_owned(), "1500".to_owned()); + let fees = DataLimit::new(bucket_totals(), default_fees(), gas_data).unwrap(); + let params = genlayer_sdk::abi::fees::InternalMessageParams { + leader_time_units_allocation: U256::from(5), + validator_time_units_allocation: U256::from(5), + execution_budget_per_round: U256::from(1024), + rotations: vec![U256::from(4); 5], + max_price_gen_per_time_unit: U256::from(3), + storage_fee_max_gas_price: U256::from(20), + receipt_fee_max_gas_price: U256::from(20), + }; + + let fee = fees.calculate_message_fee_internal(¶ms).unwrap(); + + assert_eq!(fee.reported_fee(), U256::from(47_837)); +} diff --git a/executor/tests/message_receipt_fees.rs b/executor/tests/message_receipt_fees.rs new file mode 100644 index 00000000..5290087a --- /dev/null +++ b/executor/tests/message_receipt_fees.rs @@ -0,0 +1,91 @@ +use genvm::config::FeesConfig; +use genvm::rt::fees::{DataLimit, MessageReceiptParams}; +use primitive_types::U256; + +fn default_fees() -> FeesConfig { + let config: genvm::config::Config = + serde_yaml::from_str(include_str!("../install/config/genvm.yaml")).unwrap(); + config.fees +} + +fn gas_data() -> std::collections::BTreeMap { + [ + ("bootloaderOverhead", 60_000), + ("fixedMessageRevealGas", 100_000), + ("fixedProposeReceiptGas", 210_000), + ("gasPerChangedSlot", 1_000), + ("intrinsicGas", 21_000), + ("receiptGasPerByte", 16), + ("receiptWrapperBytes", 1_024), + ] + .map(|(name, value)| (name.to_owned(), value.to_string())) + .into() +} + +fn data_limit( + execution_data_gas: u64, + submitted_messages: u64, + submitted_messages_count: u64, +) -> DataLimit { + DataLimit::new( + std::collections::HashMap::from([ + ( + "execution_data_gas".to_owned(), + U256::from(execution_data_gas), + ), + ("message_fee".to_owned(), U256::zero()), + ("nondet_outputs".to_owned(), U256::from(64)), + ( + "submitted_messages".to_owned(), + U256::from(submitted_messages), + ), + ( + "submitted_messages_count".to_owned(), + U256::from(submitted_messages_count), + ), + ]), + default_fees(), + gas_data(), + ) + .unwrap() +} + +fn empty_external_message(is_first_message: bool) -> MessageReceiptParams { + MessageReceiptParams { + is_first_message, + is_internal: false, + is_deploy: false, + rotations_count: 0, + calldata_length: 0, + code_length: 0, + subtree_length: 0, + } +} + +#[tokio::test] +async fn message_free_initial_charge_excludes_reveal_cost() { + let fees = data_limit(315_408, 0, 0); + + assert!(fees.consume_initial().await.is_none()); + assert_eq!(fees.remaining().await["execution_data_gas"], U256::zero()); +} + +#[tokio::test] +async fn reveal_cost_is_charged_with_only_the_first_message() { + let fees = data_limit(518_888, 1_280, 2); + assert!(fees.consume_initial().await.is_none()); + let message = fees + .calculate_message_receipt(empty_external_message(true)) + .unwrap(); + let next_message = fees + .calculate_message_receipt(empty_external_message(false)) + .unwrap(); + + assert!(fees.consume_message_receipt_only(&message).await); + assert!(fees.consume_message_receipt_only(&next_message).await); + + let remaining = fees.remaining().await; + assert_eq!(remaining["execution_data_gas"], U256::zero()); + assert_eq!(remaining["submitted_messages"], U256::zero()); + assert_eq!(remaining["submitted_messages_count"], U256::zero()); +} diff --git a/executor/tests/nondet_output_fees.rs b/executor/tests/nondet_output_fees.rs index 7cdfbf04..d5ce8bce 100644 --- a/executor/tests/nondet_output_fees.rs +++ b/executor/tests/nondet_output_fees.rs @@ -3,7 +3,7 @@ use genvm::rt::fees::DataLimit; fn nondet_fees(total: u64) -> DataLimit { let bucket = |delta: &str| FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".to_owned(), delta_expr: delta.to_owned(), }; @@ -16,7 +16,7 @@ fn nondet_fees(total: u64) -> DataLimit { event: bucket("\\attrs = 0"), }; DataLimit::new( - vec![primitive_types::U256::from(total)], + std::collections::HashMap::from([("test".to_owned(), primitive_types::U256::from(total))]), fees, Default::default(), ) @@ -29,7 +29,10 @@ async fn nondet_fee_preflight_checks_without_consuming() { assert!(fees.can_consume_nondet_output(5).await.unwrap()); assert!(!fees.can_consume_nondet_output(6).await.unwrap()); - assert_eq!(fees.remaining().await, vec![primitive_types::U256::from(5)]); + assert_eq!( + fees.remaining().await, + std::collections::BTreeMap::from([("test".to_owned(), primitive_types::U256::from(5),)]) + ); assert_eq!( fees.consumed().await.nondet_output, primitive_types::U256::zero() @@ -42,7 +45,10 @@ async fn nondet_fee_preflight_leaves_the_checked_charge_available() { assert!(fees.can_consume_nondet_output(5).await.unwrap()); assert!(fees.consume_nondet_output(5).await.unwrap()); - assert_eq!(fees.remaining().await, vec![primitive_types::U256::zero()]); + assert_eq!( + fees.remaining().await, + std::collections::BTreeMap::from([("test".to_owned(), primitive_types::U256::zero(),)]) + ); assert_eq!( fees.consumed().await.nondet_output, primitive_types::U256::from(5) diff --git a/executor/tests/storage_page_accounting.rs b/executor/tests/storage_page_accounting.rs index 87424be5..402fa214 100644 --- a/executor/tests/storage_page_accounting.rs +++ b/executor/tests/storage_page_accounting.rs @@ -31,7 +31,7 @@ impl HostStorageLocking for FakeHost { /// A minimal `DataLimit` whose storage bucket charges one unit per page. fn data_fees(total_pages: u64) -> Limiter { let bucket = |delta: &str| FeesBucketConfig { - bucket_no: vec![0], + buckets: vec![symbol_table::GlobalSymbol::from("test")], subtract_on_start_expr: "0".to_owned(), delta_expr: delta.to_owned(), }; @@ -44,7 +44,10 @@ fn data_fees(total_pages: u64) -> Limiter { event: bucket("\\attrs = 0"), }; let dl = rt::fees::DataLimit::new( - vec![primitive_types::U256::from(total_pages)], + std::collections::HashMap::from([( + "test".to_owned(), + primitive_types::U256::from(total_pages), + )]), fees, Default::default(), ) diff --git a/tests/integration/balance/balance/balance.0_0.stdout b/tests/integration/balance/balance/balance.0_0.stdout index d0085600..122131f6 100644 --- a/tests/integration/balance/balance/balance.0_0.stdout +++ b/tests/integration/balance/balance/balance.0_0.stdout @@ -7,4 +7,4 @@ main At(self) 10 nested self 10 nested At(self) 10 executed with `Return(null)` -{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"InternalMessage","use_balance":false,"value":5} +{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":5} diff --git a/tests/integration/balance/balance_eth/balance_eth.0_0.stdout b/tests/integration/balance/balance_eth/balance_eth.0_0.stdout index a9d5932a..4a0e75cb 100644 --- a/tests/integration/balance/balance_eth/balance_eth.0_0.stdout +++ b/tests/integration/balance/balance_eth/balance_eth.0_0.stdout @@ -7,4 +7,4 @@ main At(self) 10 nested self 10 nested At(self) 10 executed with `Return(null)` -{"address":addr#0200000000000000000000000000000000000000,"calldata":b#,"fee_params":{"gas_limit":1606938044258990275541962092341162602522202993782792835301376,"max_gas_price":0},"message_fee":0,"receipt_fee":129,"type":"ExternalMessage","value":5} +{"address":addr#0200000000000000000000000000000000000000,"calldata":b#,"fee_params":{"gas_limit":1606938044258990275541962092341162602522202993782792835301376,"max_gas_price":0},"message_fee":0,"receipt_fee":673,"type":"ExternalMessage","value":5} diff --git a/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout b/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout index 4dde3633..d3ebd911 100644 --- a/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout +++ b/tests/integration/balance/sandbox_overspend/sandbox_overspend.0.stdout @@ -3,4 +3,4 @@ sandbox transfer failed: 7: insufficient_balance sandbox result=Return(calldata=40) balance final=40 executed with `Return(null)` -{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"InternalMessage","use_balance":false,"value":60} +{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":60} diff --git a/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout b/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout index f80f8c14..0c5ed332 100644 --- a/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout +++ b/tests/integration/balance/sandbox_overspend_2/sandbox_overspend_2.0.stdout @@ -3,4 +3,4 @@ sandbox result=Return(calldata=40) balance after sandbox=40 transfer failed with error: 7: insufficient_balance balance final=40 executed with `Return(null)` -{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"InternalMessage","use_balance":false,"value":60} +{"address":addr#0200000000000000000000000000000000000000,"call_key":b#0000000000000000000000000000000000000000000000000000000000000000,"calldata":{},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":60} diff --git a/tests/integration/exploit/storage_distinct_pages/storage_distinct_pages.jsonnet b/tests/integration/exploit/storage_distinct_pages/storage_distinct_pages.jsonnet index 950b7bd7..ebeb031d 100644 --- a/tests/integration/exploit/storage_distinct_pages/storage_distinct_pages.jsonnet +++ b/tests/integration/exploit/storage_distinct_pages/storage_distinct_pages.jsonnet @@ -1,7 +1,7 @@ local msg = import 'templates/message.json'; local util = import 'templates/util.jsonnet'; -// bucket 0 is shared with the receipt buckets, so zero the receipt prices out to +// execution_data_gas is shared with the receipt buckets, so zero their prices to // leave changed pages as its only consumer local storageOnlyGasData = { storageUnitPrice: '1', @@ -11,16 +11,17 @@ local storageOnlyGasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', }; -// bucket 0 = storage, capped at 2 pages; the rest are unconstrained +// Storage is capped at 2 pages; the other buckets retain harness defaults local twoPages(calldata) = { "vars": {}, "code": null, "message": msg, "calldata": calldata, - "bucket_totals": [2, 1000000, 1000000, 1000000], + "bucket_totals": {execution_data_gas: 2}, "gas_data": storageOnlyGasData, }; diff --git a/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet b/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet index bf9ac5cf..ad96ae2a 100644 --- a/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet +++ b/tests/integration/exploit/storage_page_limit/storage_page_limit.jsonnet @@ -1,9 +1,8 @@ local msg = import 'templates/message.json'; local util = import 'templates/util.jsonnet'; -// storage now shares bucket 0 with the receipt buckets (message_receipt, -// nondet_output, event). To keep this test about storage alone, zero out the -// receipt-related prices so the only consumer of bucket 0 is storage. +// Storage shares execution_data_gas with the receipt buckets. Zero their prices +// so the only consumer of that bucket is storage local storageOnlyGasData = { storageUnitPrice: '1', receiptGasPerByte: '0', @@ -12,6 +11,7 @@ local storageOnlyGasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', }; @@ -30,8 +30,7 @@ local storageOnlyGasData = { "calldata": ||| {"": "write_2_pages", "args": []} |||, - // bucket 0 = storage (limited to 2 pages); bucket 1 (message_fee) unconstrained - "bucket_totals": [2, 1000000, 1000000, 1000000], + "bucket_totals": {execution_data_gas: 2}, "gas_data": storageOnlyGasData, }, { @@ -41,8 +40,7 @@ local storageOnlyGasData = { "calldata": ||| {"": "write_3_pages", "args": []} |||, - // bucket 0 = storage (limited to 2 pages); bucket 1 (message_fee) unconstrained - "bucket_totals": [2, 1000000, 1000000, 1000000], + "bucket_totals": {execution_data_gas: 2}, "gas_data": storageOnlyGasData, }, ], diff --git a/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet b/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet index bb0dd40b..e333d143 100644 --- a/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet +++ b/tests/integration/exploit/subtract_on_start_underflow/subtract_on_start_underflow.jsonnet @@ -8,10 +8,10 @@ local util = import 'templates/util.jsonnet'; // (`oom().receipt().message().internal()`), which is delivered to the host as a // normal consume_result receipt instead of crashing during setup. // -// With default gas data, bucket 0 carries message_receipt (39) + -// nondet_output (32) = 71 of up-front cost. Funding it with less triggers the -// underflow. The expected stdout asserts the delivered -// `VMError("OOM receipt message internal")` receipt, guarding against a +// With default gas data, execution_data_gas carries message_receipt (7) + +// nondet_output (1088) = 1095 of up-front cost. Funding it below the first +// charge triggers the intended message-receipt underflow. The expected stdout +// asserts the delivered `VMError("out_of receipt message")`, guarding against a // regression back to the crash-during-setup behavior. {tags: util.features([['exploit'], ['fees']], 'stable') + ['python'], entry: util.addPaths([ @@ -21,6 +21,6 @@ local util = import 'templates/util.jsonnet'; "code": '${jsonnetDir}/${fileBaseName}.py', "message": msg + {"is_init": true}, "calldata": "{}", - "bucket_totals": [10, 1000000, 1000000, 1000000], + "bucket_totals": {execution_data_gas: 6}, }, ])} diff --git a/tests/integration/message/deploy/deploy.0.stdout b/tests/integration/message/deploy/deploy.0.stdout index da799ab2..d823fca2 100644 --- a/tests/integration/message/deploy/deploy.0.stdout +++ b/tests/integration/message/deploy/deploy.0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":321,"salt_nonce":0,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"InternalDeployMessage","use_balance":false,"value":0} +{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":2049,"salt_nonce":0,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalDeployMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/deploy_salt/deploy_salt.0.stdout b/tests/integration/message/deploy_salt/deploy_salt.0.stdout index fc9e2afe..86837ea8 100644 --- a/tests/integration/message/deploy_salt/deploy_salt.0.stdout +++ b/tests/integration/message/deploy_salt/deploy_salt.0.stdout @@ -1,3 +1,3 @@ 0xf539Cb83f077Cd01BDd1a4E002866dCC0D15D633 executed with `Return(null)` -{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":321,"salt_nonce":1,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"InternalDeployMessage","use_balance":false,"value":0} +{"calldata":{},"code":b#6e6f74207265616c6c79206120636f6e7472616374,"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":2049,"salt_nonce":1,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalDeployMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet index 5d795c9a..8ca7a96a 100644 --- a/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet +++ b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.jsonnet @@ -1,7 +1,7 @@ local simple_deploy = import 'templates/simple_deploy.jsonnet'; local util = import 'templates/util.jsonnet'; -// Pin a per-phase timeunit floor above the emitted message's child timeunits. +// Pin each phase minimum above the emitted message's child timeunits. // gas_data replaces DEFAULT_GAS_DATA wholesale, so all required node fields are // restated here (kept minimal/deterministic, matching DEFAULT_GAS_DATA). local gasData = { @@ -12,9 +12,14 @@ local gasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + overlaySplitBps: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', // leader 5 / validator 10 (below) are rejected at emission. - minTimeUnitsPerPhase: '30', + minProposeTimeout: '30', + maxProposeTimeout: '340282366920938463463374607431768211455', + minCommitTimeout: '30', + maxCommitTimeout: '340282366920938463463374607431768211455', }; // A single wildcard internal allocation that matches the emitted message, funded diff --git a/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py index b79360bf..8c6e892c 100644 --- a/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py +++ b/tests/integration/message/internal_below_min_timeunits/internal_below_min_timeunits.py @@ -5,7 +5,6 @@ class Contract(gl.contract.Contract): def __init__(self): # Emits a single internal message. Its matched allocation funds child - # timeunits (leader 5, validator 10) below the node's minTimeUnitsPerPhase - # floor (30), so emission is rejected with `fee below_minimum` and the - # message is never issued. + # timeunits (leader 5, validator 10) below their phase minima (30), so + # emission is rejected and the message is never issued. gl.contract.get_at(gl.Address(b'\x30' * 20)).emit().foo(1, 2) diff --git a/tests/integration/message/message_count_cap/message_count_cap.0.stdout b/tests/integration/message/message_count_cap/message_count_cap.0.stdout new file mode 100644 index 00000000..612704ac --- /dev/null +++ b/tests/integration/message/message_count_cap/message_count_cap.0.stdout @@ -0,0 +1,2 @@ +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/message_count_cap/message_count_cap.1.stdout b/tests/integration/message/message_count_cap/message_count_cap.1.stdout new file mode 100644 index 00000000..b7399080 --- /dev/null +++ b/tests/integration/message/message_count_cap/message_count_cap.1.stdout @@ -0,0 +1 @@ +executed with `VMError("out_of message_fee total # internal")` diff --git a/tests/integration/message/message_count_cap/message_count_cap.2.stdout b/tests/integration/message/message_count_cap/message_count_cap.2.stdout new file mode 100644 index 00000000..612704ac --- /dev/null +++ b/tests/integration/message/message_count_cap/message_count_cap.2.stdout @@ -0,0 +1,2 @@ +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/message_count_cap/message_count_cap.3.stdout b/tests/integration/message/message_count_cap/message_count_cap.3.stdout new file mode 100644 index 00000000..b7399080 --- /dev/null +++ b/tests/integration/message/message_count_cap/message_count_cap.3.stdout @@ -0,0 +1 @@ +executed with `VMError("out_of message_fee total # internal")` diff --git a/tests/integration/message/message_count_cap/message_count_cap.4.stdout b/tests/integration/message/message_count_cap/message_count_cap.4.stdout new file mode 100644 index 00000000..612704ac --- /dev/null +++ b/tests/integration/message/message_count_cap/message_count_cap.4.stdout @@ -0,0 +1,2 @@ +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/message_count_cap/message_count_cap.5.stdout b/tests/integration/message/message_count_cap/message_count_cap.5.stdout new file mode 100644 index 00000000..b7399080 --- /dev/null +++ b/tests/integration/message/message_count_cap/message_count_cap.5.stdout @@ -0,0 +1 @@ +executed with `VMError("out_of message_fee total # internal")` diff --git a/tests/integration/message/message_count_cap/message_count_cap.jsonnet b/tests/integration/message/message_count_cap/message_count_cap.jsonnet new file mode 100644 index 00000000..926d7bb9 --- /dev/null +++ b/tests/integration/message/message_count_cap/message_count_cap.jsonnet @@ -0,0 +1,16 @@ +local simpleDeploy = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; + +local base = simpleDeploy.run('${jsonnetDir}/../send_message/send_message.py'); +{tags: util.features([['message', 'send'], ['fees']], 'stable') + ['python'], + entry: util.addPaths([ + base {bucket_totals: {submitted_messages_count: 1}}, + base {bucket_totals: {submitted_messages_count: 0}}, + // 64-byte array frame + one 1888-byte conservatively encoded message + base {bucket_totals: {submitted_messages: 1952}}, + base {bucket_totals: {submitted_messages: 1951}}, + // 1095 startup + 12 storage + 1953 message receipt gas + base {bucket_totals: {execution_data_gas: 3060}}, + base {bucket_totals: {execution_data_gas: 3059}}, + ]), +} diff --git a/tests/integration/message/nested_allocation_budget/nested_allocation_budget.0.stdout b/tests/integration/message/nested_allocation_budget/nested_allocation_budget.0.stdout new file mode 100644 index 00000000..9aa08417 --- /dev/null +++ b/tests/integration/message/nested_allocation_budget/nested_allocation_budget.0.stdout @@ -0,0 +1,2 @@ +executed with `Return(null)` +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo"},"fee_params":{"execution_budget_per_round":1,"leader_timeunits_allocation":1,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":1,"rotations":[0],"storage_fee_max_gas_price":1,"validator_timeunits_allocation":1},"message_fee":75,"on":"finalized","receipt_fee":3617,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002e0000000000000000000000000000000000000000000000000000000000000054000000000000000000000000000000000000000000000000000000000000007a000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000,"type":"InternalMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/nested_allocation_budget/nested_allocation_budget.jsonnet b/tests/integration/message/nested_allocation_budget/nested_allocation_budget.jsonnet new file mode 100644 index 00000000..378d97d2 --- /dev/null +++ b/tests/integration/message/nested_allocation_budget/nested_allocation_budget.jsonnet @@ -0,0 +1,58 @@ +local simple_deploy = import 'templates/simple_deploy.jsonnet'; +local util = import 'templates/util.jsonnet'; + +// Use the deployed 15% split to prove the emitted budget covers both the +// consensus primary reserve and the carried direct-child budgets +local gasData = { + storageUnitPrice: '1', + receiptGasPerByte: '1', + gasPerChangedSlot: '1', + intrinsicGas: '0', + bootloaderOverhead: '0', + fixedProposeReceiptGas: '0', + fixedMessageRevealGas: '0', + lockedReceiptGasPrice: '1', + overlaySplitBps: '1500', + receiptWrapperBytes: '1024', + genPerTimeUnit: '0', + minProposeTimeout: '1', + maxProposeTimeout: '340282366920938463463374607431768211455', + minCommitTimeout: '1', + maxCommitTimeout: '340282366920938463463374607431768211455', + messageBudgetFloor: '0', +}; + +local params = { + execution_budget_per_round: 1, + rotations: [0], + leader_timeunits_allocation: 1, + validator_timeunits_allocation: 1, + max_price_gen_per_time_unit: 2, + storage_fee_max_gas_price: 1, + receipt_fee_max_gas_price: 1, +}; + +local child(budget, children=[], recipient=null) = { + budget: budget, + recipient: recipient, + call_key: null, + on: 'finalized', + fee_params: {Internal: params}, + children: children, +}; + +local alloc = child(100, [ + child(30, [child(13)]), + child(30, [], 'AwAAAAAAAAAAAAAAAAAAAAAAAAA='), +]); + +{ + tags: util.features([['message', 'send'], ['fees']], 'stable') + ['python'], + entry: util.addPaths([ + simple_deploy.run('${jsonnetDir}/${fileBaseName}.py') { + bucket_totals: {message_fee: 100}, + gas_data: gasData, + message_fee_allocation: [alloc], + }, + ]), +} diff --git a/tests/integration/message/nested_allocation_budget/nested_allocation_budget.py b/tests/integration/message/nested_allocation_budget/nested_allocation_budget.py new file mode 100644 index 00000000..b43bbbe9 --- /dev/null +++ b/tests/integration/message/nested_allocation_budget/nested_allocation_budget.py @@ -0,0 +1,7 @@ +# { "Depends": "py-genlayer:test" } +import genlayer as gl + + +class Contract(gl.contract.Contract): + def __init__(self): + gl.contract.get_at(gl.Address(b'\x30' * 20)).emit().foo() diff --git a/tests/integration/message/send_message/send_message.0.stdout b/tests/integration/message/send_message/send_message.0.stdout index 0bf3957b..612704ac 100644 --- a/tests/integration/message/send_message/send_message.0.stdout +++ b/tests/integration/message/send_message/send_message.0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"InternalMessage","use_balance":false,"value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":34836,"on":"finalized","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/send_message_eth/send_message_eth.0.stdout b/tests/integration/message/send_message_eth/send_message_eth.0.stdout index f3af998f..346a1645 100644 --- a/tests/integration/message/send_message_eth/send_message_eth.0.stdout +++ b/tests/integration/message/send_message_eth/send_message_eth.0.stdout @@ -1,3 +1,3 @@ 100 executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"calldata":b#29e99f07000000000000000000000000000000000000000000000000000000000000000a,"fee_params":{"gas_limit":1606938044258990275541962092341162602522202993782792835301376,"max_gas_price":0},"message_fee":0,"receipt_fee":193,"type":"ExternalMessage","value":30} +{"address":addr#3030303030303030303030303030303030303030,"calldata":b#29e99f07000000000000000000000000000000000000000000000000000000000000000a,"fee_params":{"gas_limit":1606938044258990275541962092341162602522202993782792835301376,"max_gas_price":0},"message_fee":0,"receipt_fee":737,"type":"ExternalMessage","value":30} diff --git a/tests/integration/message/send_message_on/send_message_on.0_0.stdout b/tests/integration/message/send_message_on/send_message_on.0_0.stdout index 2768d949..9950a323 100644 --- a/tests/integration/message/send_message_on/send_message_on.0_0.stdout +++ b/tests/integration/message/send_message_on/send_message_on.0_0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1606938044258990275541962092341162602522202993782792835301376,"receipt_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"validator_timeunits_allocation":5},"message_fee":174180,"on":"decided","receipt_fee":225,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000,"type":"InternalMessage","use_balance":false,"value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":1,"receipt_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":1606938044258990275541962092341162602522202993782792835301376,"validator_timeunits_allocation":5},"message_fee":34836,"on":"decided","receipt_fee":1953,"subtree":b#00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004,"type":"InternalMessage","use_balance":false,"value":0} diff --git a/tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet b/tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet index 83cb8c55..39bb7bfd 100644 --- a/tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet +++ b/tests/integration/message/use_balance_below_min/use_balance_below_min.jsonnet @@ -1,7 +1,7 @@ local deploy_then = import 'templates/simple_deploy_then_write.jsonnet'; local util = import 'templates/util.jsonnet'; -// Pin a per-phase timeunit floor (30) above the emitted message's timeunits (5). +// Pin both phase minima (30) above the emitted message's timeunits (5). // gas_data replaces DEFAULT_GAS_DATA wholesale, so all required node fields are // restated here (kept minimal/deterministic, matching DEFAULT_GAS_DATA). local gasData = { @@ -12,11 +12,16 @@ local gasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + overlaySplitBps: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', - minTimeUnitsPerPhase: '30', + minProposeTimeout: '30', + maxProposeTimeout: '340282366920938463463374607431768211455', + minCommitTimeout: '30', + maxCommitTimeout: '340282366920938463463374607431768211455', }; -// Ample balance so the rejection isolates the timeunit floor, not the balance. +// Ample balance so the rejection isolates the phase bounds, not the balance. local extra = { 'balances': { 'AQAAAAAAAAAAAAAAAAAAAAAAAAA=': 1000000, diff --git a/tests/integration/message/use_balance_below_min/use_balance_below_min.py b/tests/integration/message/use_balance_below_min/use_balance_below_min.py index f6ee0cac..8d25d5a4 100644 --- a/tests/integration/message/use_balance_below_min/use_balance_below_min.py +++ b/tests/integration/message/use_balance_below_min/use_balance_below_min.py @@ -2,8 +2,8 @@ import genlayer as gl from genlayer.vm.public_abi import Permissions -# leader/validator timeunits (5) are below the node's minTimeUnitsPerPhase floor -# (30, set in the jsonnet), so metering rejects the emission. +# Leader/validator timeunits (5) are below their phase minima (30, set in the +# jsonnet), so metering rejects the emission. _PARAMS = gl.chain.InternalMessageParams( leader_time_units_allocation=5, validator_time_units_allocation=5, @@ -23,8 +23,7 @@ def __init__(self): @gl.public.write def do_emit(self): - # The min-timeunits floor is enforced on the balance-funded path too, so - # emission aborts with the `fee below_minimum` VMError. + # Phase bounds are enforced on the balance-funded path too. gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( use_balance=True, fee_params=_PARAMS ).foo(1, 2) diff --git a/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet index dd6d8979..aa59c1b4 100644 --- a/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet +++ b/tests/integration/message/use_balance_budget_too_low/use_balance_budget_too_low.jsonnet @@ -12,8 +12,13 @@ local gasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + overlaySplitBps: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', - minTimeUnitsPerPhase: '0', + minProposeTimeout: '1', + maxProposeTimeout: '340282366920938463463374607431768211455', + minCommitTimeout: '1', + maxCommitTimeout: '340282366920938463463374607431768211455', messageBudgetFloor: '2000', }; diff --git a/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout index 3bdc724a..d527a2c5 100644 --- a/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout +++ b/tests/integration/message/use_balance_no_alloc/use_balance_no_alloc.0_0_0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":1121,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout b/tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout index 3bdc724a..d527a2c5 100644 --- a/tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout +++ b/tests/integration/message/use_balance_ok/use_balance_ok.0_0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":1121,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout index cd1659a7..033ffe17 100644 --- a/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout +++ b/tests/integration/message/use_balance_sandbox/use_balance_sandbox.0_0.stdout @@ -1,3 +1,3 @@ sandbox: emitted executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":2,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":39976,"on":"finalized","receipt_fee":1121,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout b/tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout index 531c8520..d8cd776d 100644 --- a/tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout +++ b/tests/integration/message/use_balance_scaled/use_balance_scaled.0_0.stdout @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":3,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":45116,"on":"finalized","receipt_fee":161,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} +{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":1024,"leader_timeunits_allocation":5,"max_price_gen_per_time_unit":3,"receipt_fee_max_gas_price":20,"rotations":[4,4,4,4,4],"storage_fee_max_gas_price":20,"validator_timeunits_allocation":5},"message_fee":47837,"on":"decided","receipt_fee":1121,"subtree":b#,"type":"InternalMessage","use_balance":true,"value":0} diff --git a/tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet b/tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet index 72e0c285..372bd568 100644 --- a/tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet +++ b/tests/integration/message/use_balance_scaled/use_balance_scaled.jsonnet @@ -12,8 +12,13 @@ local gasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + overlaySplitBps: '1500', + receiptWrapperBytes: '1024', genPerTimeUnit: '7', - minTimeUnitsPerPhase: '0', + minProposeTimeout: '1', + maxProposeTimeout: '340282366920938463463374607431768211455', + minCommitTimeout: '1', + maxCommitTimeout: '340282366920938463463374607431768211455', messageBudgetFloor: '0', }; diff --git a/tests/integration/message/use_balance_scaled/use_balance_scaled.py b/tests/integration/message/use_balance_scaled/use_balance_scaled.py index c71cab93..7e904574 100644 --- a/tests/integration/message/use_balance_scaled/use_balance_scaled.py +++ b/tests/integration/message/use_balance_scaled/use_balance_scaled.py @@ -2,12 +2,13 @@ import genlayer as gl from genlayer.vm.public_abi import Permissions -# Proves the balance-funded floor scales with the GUEST cap, not the node's live -# genPerTimeUnit. With consensusTerm=5140 and executionTerm=1024*29=29696: -# fee = max_price_gen_per_time_unit * consensusTerm + executionTerm -# = 3 * 5140 + 29696 = 45116 +# Proves the balance-funded floor scales with the GUEST cap and grosses up only +# the time-unit pool. With timeUnitPool=3*5140, overlay=floor(15420*1500/8500), +# and executionTerm=1024*29: +# primary = 15420 + 2721 + 29696 = 47837 +# The per-message fee is 47837 for both decided and finalized emissions # The jsonnet sets node.genPerTimeUnit=7; had the balance path used it the fee -# would be 7*5140 + 29696 = 65676. The golden's 45116 confirms the cap is used. +# would be 7*5140 + floor(35980*1500/8500) + 29696 = 72025 _PARAMS = gl.chain.InternalMessageParams( leader_time_units_allocation=5, validator_time_units_allocation=5, @@ -28,5 +29,5 @@ def __init__(self): @gl.public.write def do_emit(self): gl.contract.get_at(gl.Address(b'\x30' * 20)).emit( - use_balance=True, fee_params=_PARAMS + on='decided', use_balance=True, fee_params=_PARAMS ).foo(1, 2) 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 index 7c3b7a84..13a164ff 100644 --- 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 @@ -1,2 +1,2 @@ executed with `Return(null)` -{"address":addr#3030303030303030303030303030303030303030,"call_key":b#666f6f0000000000000000000000000000000000000000000000000000000000,"calldata":{"":"foo","args":[1,2]},"fee_params":{"execution_budget_per_round":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":"InternalMessage","use_balance":true,"value":0} +{"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":1121,"subtree":b#,"type":"InternalMessage","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 index e4f4da29..fd45c6a5 100644 --- 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 @@ -13,8 +13,13 @@ local gasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + overlaySplitBps: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', - minTimeUnitsPerPhase: '0', + minProposeTimeout: '1', + maxProposeTimeout: '340282366920938463463374607431768211455', + minCommitTimeout: '1', + maxCommitTimeout: '340282366920938463463374607431768211455', messageBudgetFloor: '2000', }; diff --git a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_1.stdout b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_1.stdout new file mode 100644 index 00000000..6420cc04 --- /dev/null +++ b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_1.stdout @@ -0,0 +1 @@ +executed with `VMError("out_of receipt nondet_output")` diff --git a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_2.stdout b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_2.stdout new file mode 100644 index 00000000..6420cc04 --- /dev/null +++ b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.0_2.stdout @@ -0,0 +1 @@ +executed with `VMError("out_of receipt nondet_output")` diff --git a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet index 1a2b86a7..1e83ad9a 100644 --- a/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet +++ b/tests/integration/nondet-consensus/output_fee_cap/output_fee_cap.jsonnet @@ -5,13 +5,21 @@ local util = import 'templates/util.jsonnet'; tags: util.features([['nondet', 'consensus', 'leader'], ['fees']], 'stable') + ['python'], entry: util.addPaths([ simple.run('${jsonnetDir}/${fileBaseName}.py', 'main') { - next: [ - super.next[0] { + next: + local exact = super.next[0] { modes: 'lvs', - // VMError byte + "out_of receipt nondet_output" - bucket_totals: [1000000000, 1000000000, 29, 1000000000], - }, - ], + // 64-byte frame + one 34-byte compact VMError output + bucket_totals: { + nondet_outputs: 98, + // 71 message startup gas + 1024 wrapper + 34 output bytes + execution_data_gas: 1129, + }, + }; + [ + exact, + exact {bucket_totals+: {nondet_outputs: 97}}, + exact {bucket_totals+: {execution_data_gas: 1128}}, + ], }, ]), } diff --git a/tests/integration/storage/sandbox_fold_limit/sandbox_fold_limit.jsonnet b/tests/integration/storage/sandbox_fold_limit/sandbox_fold_limit.jsonnet index 733a3285..92145869 100644 --- a/tests/integration/storage/sandbox_fold_limit/sandbox_fold_limit.jsonnet +++ b/tests/integration/storage/sandbox_fold_limit/sandbox_fold_limit.jsonnet @@ -9,6 +9,7 @@ local storageOnlyGasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', }; @@ -17,6 +18,5 @@ local base = simple.run('${jsonnetDir}/${fileBaseName}.py'); entry: util.addPaths([base + { // Validator and sync modes would make their multi-GiB allocations concurrently. modes: 'l', - bucket_totals: [1000000000, 1000000000, 1000000000, 1000000000], gas_data: storageOnlyGasData, }])} diff --git a/tests/integration/storage/zero_fee_ram_bound/zero_fee_ram_bound.jsonnet b/tests/integration/storage/zero_fee_ram_bound/zero_fee_ram_bound.jsonnet index dd8122e6..91ff0270 100644 --- a/tests/integration/storage/zero_fee_ram_bound/zero_fee_ram_bound.jsonnet +++ b/tests/integration/storage/zero_fee_ram_bound/zero_fee_ram_bound.jsonnet @@ -11,6 +11,7 @@ local freeStorageGasData = { bootloaderOverhead: '0', fixedProposeReceiptGas: '0', fixedMessageRevealGas: '0', + receiptWrapperBytes: '1024', genPerTimeUnit: '0', }; @@ -19,6 +20,5 @@ local base = simple.run('${jsonnetDir}/${fileBaseName}.py'); entry: util.addPaths([base + { // Validator and sync modes would make their multi-GiB allocations concurrently. modes: 'l', - bucket_totals: [1000000000, 1000000000, 1000000000, 1000000000], gas_data: freeStorageGasData, }])} From 1e33049c75801c2c5f79d279be187e2df3dd1a00 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Fri, 4 Sep 2026 17:19:56 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(expr):=20add=20internalError=20and=20k?= =?UTF-8?q?eep=20the=20failure=20class=20on=20replay=20=E2=9C=A8?= =?UTF-8?q?=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/crates/common/src/expr/evaluator.rs | 8 ++++++ executor/crates/common/src/expr/value.rs | 26 +++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/executor/crates/common/src/expr/evaluator.rs b/executor/crates/common/src/expr/evaluator.rs index 72f6634d..6cfdb90b 100644 --- a/executor/crates/common/src/expr/evaluator.rs +++ b/executor/crates/common/src/expr/evaluator.rs @@ -204,6 +204,14 @@ static BUILTINS: std::sync::LazyLock>); +enum ThunkStateFailure { + Generic(String), + VMError(String), + InternalError(String), +} + enum ThunkState { Forced(Value), - Failed(String), + Failed(ThunkStateFailure), Deferred(Box Result + Send>), InProgress, } @@ -194,7 +202,13 @@ impl Thunk { return Ok(v); } ThunkState::Failed(msg) => { - let err = EvalError::AlreadyFailed(msg.clone()); + let err = match &msg { + ThunkStateFailure::Generic(m) => EvalError::AlreadyFailed(m.clone()), + ThunkStateFailure::InternalError(m) => { + EvalError::ScriptInternalError(m.clone()) + } + ThunkStateFailure::VMError(m) => EvalError::ScriptVMError(m.clone()), + }; *state = ThunkState::Failed(msg); return Err(err); } @@ -213,7 +227,13 @@ impl Thunk { // A failed computation is not retried either: later forces report that failure *state = match &result { Ok(v) => ThunkState::Forced(v.clone()), - Err(e) => ThunkState::Failed(e.to_string()), + Err(EvalError::ScriptVMError(msg)) => { + ThunkState::Failed(ThunkStateFailure::VMError(msg.clone())) + } + Err(EvalError::ScriptInternalError(msg)) => { + ThunkState::Failed(ThunkStateFailure::InternalError(msg.clone())) + } + Err(e) => ThunkState::Failed(ThunkStateFailure::Generic(e.to_string())), }; result From 3171637f8f99f92c621a478cebafdcbb9e625f18 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Fri, 4 Sep 2026 17:20:01 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(fees):=20reject=20an=20overlay=20split?= =?UTF-8?q?=20at=20or=20above=20the=20full=20share=20=F0=9F=90=9B?= =?UTF-8?q?=F0=9F=94=92=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/install/config/genvm.yaml | 10 ++++++++++ executor/tests/message_fee_overlay.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/executor/install/config/genvm.yaml b/executor/install/config/genvm.yaml index ae313855..e9e06282 100644 --- a/executor/install/config/genvm.yaml +++ b/executor/install/config/genvm.yaml @@ -119,6 +119,12 @@ fees: # up on the time-unit pool only; integer division matches Solidity let timeUnitPool = if multiplier > 0 then multiplier * consensusTerm else consensusTerm in + # the overlay is a share of the gross pool, so bps must stay below 100%; + # at or above it the gross-up denominator is zero or negative, which would + # silently underprice the message instead of failing + if node.overlaySplitBps >= 10000 + then internalError "node.overlaySplitBps must be below 10000" + else let overlaySplit = idiv (timeUnitPool * node.overlaySplitBps) (10000 - node.overlaySplitBps) in # 7. minimum primary fees @@ -201,6 +207,10 @@ fees: 0 delta_expr: | \a = + # `a.matchedFeeParams` is shaped by the branch: internal params here, + # `{gasLimit, maxGasPrice}` on the external one. The bindings below read + # internal-only keys and stay correct because `let` is call-by-need -- + # the external branch never forces them. Do not hoist them past the `if`. # Both-zero is the chain's explicit phase-timeout opt-out. Otherwise each # allocation must fit its phase's current Idleness bounds or child creation # reverts PhaseTimeoutOutOfBounds. diff --git a/executor/tests/message_fee_overlay.rs b/executor/tests/message_fee_overlay.rs index b4489e5d..721dd9de 100644 --- a/executor/tests/message_fee_overlay.rs +++ b/executor/tests/message_fee_overlay.rs @@ -74,3 +74,22 @@ fn missing_overlay_split_is_not_treated_as_zero() { "unexpected error: {message}" ); } + +#[test] +fn overlay_split_at_or_above_full_share_is_rejected() { + for bps in ["10000", "12000"] { + let mut gas_data = gas_data_without_overlay(); + gas_data.insert("overlaySplitBps".to_owned(), bps.to_owned()); + let fees = DataLimit::new(bucket_totals(), default_fees(), gas_data).unwrap(); + + let error = fees + .calculate_message_fee_internal(&fee_params()) + .unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("overlaySplitBps must be below 10000"), + "unexpected error for {bps}: {message}" + ); + } +} From d24f91b1270dc036cddcea20711e289379140272 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Fri, 4 Sep 2026 17:20:05 +0900 Subject: [PATCH 7/7] =?UTF-8?q?chore(fees):=20cover=20the=20external=20mes?= =?UTF-8?q?sage=20fee=20price=20selection=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/tests/message_fee_external.rs | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 executor/tests/message_fee_external.rs diff --git a/executor/tests/message_fee_external.rs b/executor/tests/message_fee_external.rs new file mode 100644 index 00000000..57497232 --- /dev/null +++ b/executor/tests/message_fee_external.rs @@ -0,0 +1,76 @@ +use genvm::config::FeesConfig; +use genvm::rt::fees::DataLimit; +use primitive_types::U256; + +fn default_fees() -> FeesConfig { + let config: serde_yaml::Value = + serde_yaml::from_str(include_str!("../install/config/genvm.yaml")).unwrap(); + serde_yaml::from_value(config["fees"].clone()).unwrap() +} + +fn bucket_totals() -> std::collections::HashMap { + [ + "execution_data_gas", + "message_fee", + "nondet_outputs", + "submitted_messages", + "submitted_messages_count", + ] + .into_iter() + .map(|name| (name.to_owned(), U256::MAX)) + .collect() +} + +/// Deliberately omits every constant only the internal branch reads +/// (`overlaySplitBps`, the phase-timeout bounds): the external branch must not +/// force those bindings. +fn gas_data(locked_receipt_gas_price: u64) -> std::collections::BTreeMap { + [ + ("storageUnitPrice", "1".to_owned()), + ( + "lockedReceiptGasPrice", + locked_receipt_gas_price.to_string(), + ), + ("receiptGasPerByte", "1".to_owned()), + ("gasPerChangedSlot", "1".to_owned()), + ("intrinsicGas", "0".to_owned()), + ("bootloaderOverhead", "0".to_owned()), + ("fixedProposeReceiptGas", "0".to_owned()), + ("fixedMessageRevealGas", "0".to_owned()), + ("receiptWrapperBytes", "1024".to_owned()), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect() +} + +fn fee(locked_receipt_gas_price: u64, gas_limit: u64, max_gas_price: u64) -> U256 { + let fees = DataLimit::new( + bucket_totals(), + default_fees(), + gas_data(locked_receipt_gas_price), + ) + .unwrap(); + + fees.calculate_message_fee_external(&genlayer_sdk::abi::fees::ExternalMessageParams { + gas_limit: gas_limit.into(), + max_gas_price: max_gas_price.into(), + }) + .unwrap() + .reported_fee() +} + +#[test] +fn external_fee_uses_the_locked_price_when_it_is_lower() { + assert_eq!(fee(3, 1000, 7), U256::from(3000)); +} + +#[test] +fn external_fee_uses_the_guest_cap_when_it_is_lower() { + assert_eq!(fee(7, 1000, 3), U256::from(3000)); +} + +#[test] +fn external_fee_is_price_agnostic_when_both_agree() { + assert_eq!(fee(5, 1000, 5), U256::from(5000)); +}