From fa02df086acba387ee76bba952a4a2d92ddb4d26 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 24 Sep 2026 21:01:24 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix(fees):=20flatten=20allocation=20inputs?= =?UTF-8?q?=20and=20correct=20executor=20funding=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host supplies descendant funding and exact subtree transport bytes so pinned storage modes and proofs can be handled without executor ABI encoding. Both executor lines use the same input shape; coordinate the node producer upgrade. --- crates/modules-interfaces/src/domain.rs | 2 +- .../modules-interfaces/src/domain/fees/abi.rs | 170 ------------------ .../modules-interfaces/src/domain/fees/mod.rs | 60 ++----- docs/website/src/impl-spec/04-fees.rst | 44 ++++- .../src/impl-spec/appendix/manager-api.yaml | 31 +++- executors/v0.2.x | 2 +- executors/v0.3.x | 2 +- implementation/src/manager/run.rs | 2 +- .../runner/genvm_tool_plugins/integration.py | 5 +- tests/runner/origin/fees.py | 22 +-- 10 files changed, 103 insertions(+), 237 deletions(-) delete mode 100644 crates/modules-interfaces/src/domain/fees/abi.rs diff --git a/crates/modules-interfaces/src/domain.rs b/crates/modules-interfaces/src/domain.rs index 02e37de6..9b58650e 100644 --- a/crates/modules-interfaces/src/domain.rs +++ b/crates/modules-interfaces/src/domain.rs @@ -45,7 +45,7 @@ pub struct ExecutionData { pub bucket_totals: std::collections::BTreeMap, /// Host-provided `node` fee constants (moved off `host_data`). pub gas_data: std::collections::BTreeMap, - /// Message-fee allocation tree passed alongside the execution. + /// Flat allocations with host-encoded subtree payloads. pub message_fee_allocation: Vec, /// Initial time-unit budget for this execution. pub initial_time_units_allocation: u32, diff --git a/crates/modules-interfaces/src/domain/fees/abi.rs b/crates/modules-interfaces/src/domain/fees/abi.rs deleted file mode 100644 index 778c9cf0..00000000 --- a/crates/modules-interfaces/src/domain/fees/abi.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! EVM ABI encoding of the message-fee allocation tree into the chain's flat -//! parent-pointer `MessageAllocationNode[]` representation. - -use crate::On; -use primitive_types::U256; - -use super::{ - ExternalMessageParams, InternalMessageParams, MessageAllocationNode, - MessageAllocationNodeParams, -}; - -/// Parent pointer used by top-level (root-layer) allocation nodes in the flat -/// chain representation: `type(uint256).max`. Any other value is a 0-based index -/// into the submitted array. -const NODE_ROOT_SENTINEL: U256 = U256::MAX; - -/// Solidity `enum MessageType { External, Internal }`. -const MESSAGE_TYPE_EXTERNAL: u64 = 0; -const MESSAGE_TYPE_INTERNAL: u64 = 1; - -/// Number of head words in the Solidity `MessageAllocationNode` tuple -/// (messageType, onAcceptance, parentIndex, recipient, callKey, budget, feeParams). -const NODE_HEAD_WORDS: usize = 7; - -/// Number of head words in the Solidity `InternalMessageParams` tuple -/// (leader, validator, appealRounds, executionBudgetPerRound, rotations, -/// maxPriceGenPerTimeUnit, storageFeeMaxGasPrice, receiptFeeMaxGasPrice). -/// `rotations` is the only dynamic field; the three price caps (v0.6-dev, -/// CON-549) are static and follow it in the head region. -const INTERNAL_PARAMS_HEAD_WORDS: usize = 8; - -fn push_u256(buf: &mut Vec, value: U256) { - buf.extend_from_slice(&value.to_big_endian()); -} - -fn push_word(buf: &mut Vec, value: u64) { - push_u256(buf, U256::from(value)); -} - -fn push_bool(buf: &mut Vec, value: bool) { - push_word(buf, value as u64); -} - -/// Left-pads a 20-byte address into a 32-byte ABI word. -fn push_address(buf: &mut Vec, addr: Option<&genlayer_calldata::Address>) { - buf.extend_from_slice(&[0u8; 12]); - match addr { - Some(addr) => buf.extend_from_slice(&addr.raw()), - None => buf.extend_from_slice(&[0u8; 20]), - } -} - -/// `callKey` is already a 32-byte word; `None` is `CALL_KEY_WILDCARD`. -fn push_call_key(buf: &mut Vec, call_key: Option<&crate::abi_stub::CallKey>) { - match call_key { - Some(ck) => buf.extend_from_slice(&ck.0), - None => buf.extend_from_slice(&super::CALL_KEY_WILDCARD.0), - } -} - -/// Encodes the transport payload consumed by `decodeAllocationSubtree`. -pub(super) fn encode(root: &MessageAllocationNode) -> Vec { - let mut flat: Vec<(&MessageAllocationNode, U256)> = Vec::new(); - let mut queue = std::collections::VecDeque::from([(root, NODE_ROOT_SENTINEL)]); - while let Some((node, parent_index)) = queue.pop_front() { - let node_index = U256::from(flat.len()); - flat.push((node, parent_index)); - for child in &node.children { - queue.push_back((child, node_index)); - } - } - - let elements: Vec> = flat - .iter() - .map(|(node, parent_index)| encode_node(node, *parent_index)) - .collect(); - - let mut buf = Vec::new(); - push_word(&mut buf, 0x20); - - push_word(&mut buf, elements.len() as u64); - let mut offset = elements.len() * 32; - for element in &elements { - push_word(&mut buf, offset as u64); - offset += element.len(); - } - for element in &elements { - buf.extend_from_slice(element); - } - - buf -} - -/// Encodes a single node as the Solidity `MessageAllocationNode` dynamic -/// tuple (offsets are relative to the start of this tuple). -fn encode_node(node: &MessageAllocationNode, parent_index: U256) -> Vec { - let (message_type, on_acceptance, fee_params) = match &node.fee_params { - MessageAllocationNodeParams::Internal(params) => ( - MESSAGE_TYPE_INTERNAL, - matches!(node.on, On::Decided), - encode_internal_params(params), - ), - // External messages have no acceptance/finalize lifecycle. - MessageAllocationNodeParams::External(params) => { - (MESSAGE_TYPE_EXTERNAL, false, encode_external_params(params)) - } - }; - - let mut buf = Vec::new(); - push_word(&mut buf, message_type); - push_bool(&mut buf, on_acceptance); - push_u256(&mut buf, parent_index); - push_address(&mut buf, node.recipient.as_ref()); - push_call_key(&mut buf, node.call_key.as_ref()); - push_u256(&mut buf, node.budget); - // `feeParams` is the only dynamic field; its offset follows the head words. - push_word(&mut buf, (NODE_HEAD_WORDS * 32) as u64); - // Tail: `bytes feeParams` = length + 32-aligned data. - push_word(&mut buf, fee_params.len() as u64); - buf.extend_from_slice(&fee_params); - let pad = fee_params.len().next_multiple_of(32) - fee_params.len(); - buf.extend(std::iter::repeat_n(0u8, pad)); - - buf -} - -/// `abi.encode(InternalMessageParams)` -- a dynamic tuple (contains -/// `uint256[] rotations`), so it is prefixed with the offset word. `appealRounds` -/// is reconstructed as `len(rotations) - 1` (the chain-derived value). -/// -/// Field order matches the chain's `InternalMessageFeeParams` (v0.6-dev): -/// leader, validator, appealRounds, executionBudgetPerRound, rotations, -/// maxPriceGenPerTimeUnit, storageFeeMaxGasPrice, receiptFeeMaxGasPrice. The -/// three price caps are static and sit in the head *after* the `rotations` -/// offset word, so the rotations tail starts past all 8 head words. -fn encode_internal_params(params: &InternalMessageParams) -> Vec { - let appeal_rounds = U256::from(params.rotations.len().saturating_sub(1) as u64); - - let mut buf = Vec::new(); - // `abi.encode` of a single dynamic struct prefixes the tuple with its offset. - push_word(&mut buf, 0x20); - push_u256(&mut buf, params.leader_timeunits_allocation); - push_u256(&mut buf, params.validator_timeunits_allocation); - push_u256(&mut buf, appeal_rounds); - push_u256(&mut buf, params.execution_budget_per_round); - // `rotations` is the only dynamic field; its offset follows all head words. - push_word(&mut buf, (INTERNAL_PARAMS_HEAD_WORDS * 32) as u64); - // Static price caps occupy head words 6-8 (between the rotations offset and - // the rotations tail), mirroring the chain struct field order. - push_u256(&mut buf, params.max_price_gen_per_time_unit); - push_u256(&mut buf, params.storage_fee_max_gas_price); - push_u256(&mut buf, params.receipt_fee_max_gas_price); - // Tail: `rotations` length + data, after the 8 head words. - push_word(&mut buf, params.rotations.len() as u64); - for rotation in ¶ms.rotations { - push_u256(&mut buf, *rotation); - } - - buf -} - -/// `abi.encode(ExternalMessageParams)` -- a fully static tuple, so it is just -/// the two inline words with no leading offset. -fn encode_external_params(params: &ExternalMessageParams) -> Vec { - let mut buf = Vec::new(); - push_u256(&mut buf, params.gas_limit); - push_u256(&mut buf, params.max_gas_price); - - buf -} diff --git a/crates/modules-interfaces/src/domain/fees/mod.rs b/crates/modules-interfaces/src/domain/fees/mod.rs index bea5819b..a533d37e 100644 --- a/crates/modules-interfaces/src/domain/fees/mod.rs +++ b/crates/modules-interfaces/src/domain/fees/mod.rs @@ -1,14 +1,7 @@ -pub mod abi; - use primitive_types::U256; use crate::On; -pub const CALL_KEY_WILDCARD: crate::abi_stub::CallKey = crate::abi_stub::CallKey([ - 0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c, 0x92, 0x7e, 0x7d, 0xb2, 0xdc, 0xc7, 0x03, 0xc0, - 0xe5, 0x00, 0xb6, 0x53, 0xca, 0x82, 0x27, 0x3b, 0x7b, 0xfa, 0xd8, 0x04, 0x5d, 0x85, 0xa4, 0x70, -]); - #[derive( Debug, Clone, @@ -26,12 +19,7 @@ pub struct InternalMessageParams { /// Per-round rotation allocations; `rotations[0]` is the initial round, the /// rest are appeal rounds. Must be non-empty. /// - /// The chain's `InternalMessageFeeParams` carries an explicit `appealRounds` - /// field, but it is not stored here: the chain enforces - /// `appealRounds == rotations.length - 1` (`FeesVerifier.InvalidAppealRounds`), - /// so we derive `appeal_rounds = rotations.len() - 1` instead. The ABI - /// encoder re-inserts it so the encoded bytes (and their `keccak256` fee-param - /// pin) match the chain's field layout. + /// The fee evaluator derives `appeal_rounds = rotations.len() - 1`. pub rotations: Vec, /// Per-time-unit GEN price cap locked at activation (consensus CON-549, /// v0.6-dev). The chain charges at this cap as the funding multiplier and @@ -75,7 +63,7 @@ pub enum MessageAllocationNodeParams { External(ExternalMessageParams), } -/// One node of the message-fee allocation tree. +/// One allocation available to messages emitted by this execution. #[derive( Debug, Clone, @@ -87,25 +75,19 @@ pub enum MessageAllocationNodeParams { pub struct MessageAllocationNode { /// Target contract address; `None` means wildcard (any recipient). pub recipient: Option, - /// `None` = wildcard: all call keys for this recipient - /// (chain sentinel: `CALL_KEY_WILDCARD` = `keccak256("")`). + /// `None` means any call key; the node converts the chain's wildcard sentinel. pub call_key: Option, - /// Max budget for matching messages. - pub budget: U256, + /// Available allowance; zero is exhausted, `None` is uncapped. + pub budget: Option, pub on: On, - /// Same structure as TX-level params. pub fee_params: MessageAllocationNodeParams, - pub children: Vec, + /// Sum of the direct descendants' allocation budgets, funded per emission. + pub children_budget: U256, + /// Host-encoded matched subtree, including any proof required by the chain. + pub subtree: bytes::Bytes, } impl MessageAllocationNode { - /// ABI-encodes this matched node and its descendants for transport to the chain. - /// The matched node is element 0 and descendants are in BFS order. - pub fn abi_encode(&self) -> Vec { - abi::encode(self) - } - - #[allow(clippy::if_same_then_else)] pub fn matches_internal( &self, on: On, @@ -115,18 +97,12 @@ impl MessageAllocationNode { let MessageAllocationNodeParams::Internal(params) = &self.fee_params else { return None; }; - if on != self.on { - None - } else if self.recipient.as_ref().is_some_and(|r| *r != recipient) { - None - } else if self.call_key.as_ref().is_some_and(|ck| *ck != call_key) { - None - } else { - Some(params.clone()) - } + (on == self.on + && self.recipient.is_none_or(|r| r == recipient) + && self.call_key.is_none_or(|ck| ck == call_key)) + .then(|| params.clone()) } - #[allow(clippy::if_same_then_else)] pub fn matches_external( &self, recipient: genlayer_calldata::Address, @@ -135,12 +111,8 @@ impl MessageAllocationNode { let MessageAllocationNodeParams::External(params) = &self.fee_params else { return None; }; - if self.recipient.as_ref().is_some_and(|r| *r != recipient) { - None - } else if self.call_key.as_ref().is_some_and(|ck| *ck != call_key) { - None - } else { - Some(*params) - } + (self.recipient.is_none_or(|r| r == recipient) + && self.call_key.is_none_or(|ck| ck == call_key)) + .then_some(*params) } } diff --git a/docs/website/src/impl-spec/04-fees.rst b/docs/website/src/impl-spec/04-fees.rst index 9b2212af..f27a1898 100644 --- a/docs/website/src/impl-spec/04-fees.rst +++ b/docs/website/src/impl-spec/04-fees.rst @@ -286,21 +286,38 @@ after the exact key; executor-only open-bucket recipient wildcards are less spec than either. List order therefore cannot make a wildcard shadow an exact allocation. For internal messages, ``on`` is checked after the allocation key is resolved, so a -phase mismatch on an exact allocation does not fall through to a wildcard. For +phase mismatch on an exact allocation does not fall through to a wildcard. Chain +keys are unique across phases; only synthetic recipient-wildcard entries may +select different fee parameters by phase. For external messages, an exhausted exact allocation spills to the per-recipient ``call_key`` wildcard. If neither key has an allocation, the external message uses the legacy unallocated path and consumes only its receipt cost. Existing but exhausted candidates yield an allocation-budget error. +Entry presence determines matching, independently of ``budget``. The host supplies +the allowance available at execution start, optionally reduced by prior consumption. +Zero means an exhausted allocation; ``null`` removes the per-allocation cap while +keeping the execution's fee buckets. Local consumption is tracked separately. +An exhausted internal exact key still wins and fails its budget check; it never +falls through. An absent chain allocation (including a chain record whose original +budget is zero) must be omitted, not represented by an exhausted entry. + +The host must preserve every existing pinned key, including exhausted keys, and +must not add recipient wildcards to a pinned tree. An empty list restricts internal +pool-funded emissions. Open-pool and view executions may supply synthetic recipient +wildcards with concrete fee parameters and phase, and optionally uncapped budgets. +The executor conservatively treats each emission as novel; remaining allowances +alone do not identify previously delivered occurrences. + Funding modes ~~~~~~~~~~~~~~ An outgoing internal message is funded one of two ways: - **Allocation-matched (default).** The fee is matched against the allocation - tree as above. After the expression computes the primary reserve, the executor - adds the budgets of the matched node's direct children; deeper budgets are - already contained by their direct parent. A declared budget exceeding the + list as above. After the expression computes the primary reserve, the executor + adds the host-supplied ``children_budget``; deeper budgets are already + contained by their direct parent. A declared budget exceeding the matched node's remaining ``budget`` is rejected with an allocation-budget error; otherwise it consumes ``message_fee`` atomically with the ``message_receipt`` charge. @@ -323,6 +340,25 @@ For balance funding the sum is zero. The primary fee already covers the child's configured lifecycle, including appeals; the remainder becomes the child's message-fee bucket. External messages declare zero +The v0.3 primary reserve includes successful-appellant profit for each configured +appeal slot. The bond is the next normal round's time-unit cost, including its +rotations, multiplied by the child's price cap. Its profit reserve is +``bond + floor(bond / 2)``; the developer/DAO overlay applies only to time-unit +work, never to this profit reserve + +The manager input ``message_fee_allocation`` contains only the allocations matched +by this execution. Each carries a required ``children_budget`` and opaque +``subtree`` bytes. The host sums the direct descendants' budgets and encodes +the matched subtree with any proof required by the transaction's pinned +storage mode. Both executors forward those bytes unchanged; v0.3 charges their +full length against receipt gas, submitted-message bytes and memory. The subtree +includes the matched root; it is not merely an encoding of its descendants + +``children_budget`` funds onward messages per emission; it is +not the unspent allocation allowance or consumption from earlier generations. +All v0.3 internal emissions reject zero time-unit, storage or receipt price caps +with ``Inval`` before charging fees or appending an emission + The parent allocation's aggregate capacity is a separate invariant. If ``L = appealRounds + 1`` novel executions may each emit a child carrying budget ``C``, preserving that descendant capacity for every execution requires:: diff --git a/docs/website/src/impl-spec/appendix/manager-api.yaml b/docs/website/src/impl-spec/appendix/manager-api.yaml index b90ea207..36e5bc79 100644 --- a/docs/website/src/impl-spec/appendix/manager-api.yaml +++ b/docs/website/src/impl-spec/appendix/manager-api.yaml @@ -879,10 +879,37 @@ components: type: array items: type: object + required: [recipient, call_key, budget, on, fee_params, children_budget, subtree] + properties: + recipient: + nullable: true + description: Recipient address, or null for an open allocation. + call_key: + nullable: true + description: Call key bytes, or null for a wildcard. + budget: + nullable: true + description: Available allowance at execution start; zero is exhausted, null is uncapped. Does not affect key matching. + on: + type: string + enum: [decided, finalized] + fee_params: + type: object + description: Internal or External fee parameters. + children_budget: + description: Sum of direct descendant allocation budgets, funded per emitted child. + subtree: + description: Opaque bytes for the matched subtree, including any required Merkle proof. default: [] description: | - Message-fee allocation tree. See `MessageAllocationNode` in - `crates/modules-interfaces/src/domain/fees/mod.rs` for the node shape. + Flat funding allocations. The node supplies the exact subtree transport + bytes for the transaction's pinned storage mode. GenVM forwards these bytes + unchanged and meters their length. External and open allocations without a + committed subtree use empty bytes. children_budget and subtree are required + on every entry, including allocations without descendants. + Include exhausted allocations with budget zero to preserve internal key + precedence; omit allocations absent on-chain. Recipient wildcards are + synthetic entries for open-pool or view execution, not chain allocations. record_actions: type: array items: diff --git a/executors/v0.2.x b/executors/v0.2.x index 75e4d1fd..b08736a2 160000 --- a/executors/v0.2.x +++ b/executors/v0.2.x @@ -1 +1 @@ -Subproject commit 75e4d1fd5945aadea065f6928e60155ffca63797 +Subproject commit b08736a2d9d1292c6a5c50a2d1b4b12530986a4d diff --git a/executors/v0.3.x b/executors/v0.3.x index e813da59..624aecf9 160000 --- a/executors/v0.3.x +++ b/executors/v0.3.x @@ -1 +1 @@ -Subproject commit e813da599329bb848efd058d9e20ae660d740ebe +Subproject commit 624aecf98eb5f1c8f045201e93b88a70f71c1a8c diff --git a/implementation/src/manager/run.rs b/implementation/src/manager/run.rs index 3c2651a4..b1938ce8 100644 --- a/implementation/src/manager/run.rs +++ b/implementation/src/manager/run.rs @@ -1013,7 +1013,7 @@ pub struct Request { #[serde(default)] #[calldata(default = default_gas_data)] pub gas_data: std::collections::BTreeMap, - /// Message-fee allocation tree passed alongside the execution. + /// Flat allocations with opaque subtree payloads. #[serde(default)] #[calldata(default = default_message_fee_allocation)] pub message_fee_allocation: Vec, diff --git a/tests/runner/genvm_tool_plugins/integration.py b/tests/runner/genvm_tool_plugins/integration.py index d1173785..89d26085 100644 --- a/tests/runner/genvm_tool_plugins/integration.py +++ b/tests/runner/genvm_tool_plugins/integration.py @@ -90,9 +90,8 @@ def _normalize_message_fee_allocation( allocation = allocation.copy() if isinstance(allocation['recipient'], str): allocation['recipient'] = Address(allocation['recipient']) - allocation['children'] = [ - _normalize_message_fee_allocation(child) for child in allocation['children'] - ] + if isinstance(allocation['subtree'], list): + allocation['subtree'] = bytes(allocation['subtree']) return allocation diff --git a/tests/runner/origin/fees.py b/tests/runner/origin/fees.py index 156748f8..3345fdc4 100644 --- a/tests/runner/origin/fees.py +++ b/tests/runner/origin/fees.py @@ -1,8 +1,8 @@ """ -Message-fee allocation tree types. +Message-fee allocation types. -Mirrors the executor's `genvm_common::domain::fees` module: the fee parameters -and the nested `MessageAllocationNode` tree that is passed alongside an +Mirrors the shared `genvm_modules_interfaces::fees` module: the fee parameters +and the flat allocations that are passed alongside an execution and matched against emitted messages. """ @@ -43,13 +43,12 @@ class _ExternalParams(typing.TypedDict): class MessageAllocationNode(typing.TypedDict): recipient: Address | None call_key: bytes | None - budget: int + budget: int | None # Lifecycle the node matches against (only meaningful for internal messages). on: typing.Literal['finalized', 'decided'] fee_params: MessageAllocationNodeParams - # Nested allocation subtree; the chain receives this flattened to - # parent-pointer form. - children: list['MessageAllocationNode'] + children_budget: int + subtree: bytes DEFAULT_EXTERNAL_MESSAGE_ALLOC: MessageAllocationNode = { @@ -64,7 +63,8 @@ class MessageAllocationNode(typing.TypedDict): 'max_gas_price': 0, }, }, - 'children': [], + 'children_budget': 0, + 'subtree': b'', } DEFAULT_INTERNAL_DEC_MESSAGE_ALLOC: MessageAllocationNode = { @@ -83,7 +83,8 @@ class MessageAllocationNode(typing.TypedDict): 'receipt_fee_max_gas_price': 2**200, }, }, - 'children': [], + 'children_budget': 0, + 'subtree': b'', } DEFAULT_INTERNAL_FIN_MESSAGE_ALLOC: MessageAllocationNode = { @@ -102,5 +103,6 @@ class MessageAllocationNode(typing.TypedDict): 'receipt_fee_max_gas_price': 20, }, }, - 'children': [], + 'children_budget': 0, + 'subtree': b'', } From 9bf8e8ff214fa37fb811fda470fee42061c516de Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Sun, 27 Sep 2026 22:54:14 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(executor):=20classify=20v0.3=20leader?= =?UTF-8?q?=20nondet=20timeout=20as=20leader=20fault=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../03-wasi_genlayer_sdk/03-schemas.rst | 2 ++ executors/v0.3.x | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst b/docs/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst index 51bb6afa..4ff7213a 100644 --- a/docs/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst +++ b/docs/website/src/spec/02-execution-environment/03-wasi_genlayer_sdk/03-schemas.rst @@ -56,6 +56,8 @@ disagreement without running the comparison stage :ref:`gvm-def-str-trie-vm-error` path, including the canonical spelling of any parameter. Codes outside the trie yield :ref:`gvm-def-str-trie-value-vm-error-leader-fault-nondet-output-malformed`. +#. A proposed :ref:`gvm-def-str-trie-value-vm-error-timeout` yields + :ref:`gvm-def-str-trie-value-vm-error-leader-fault-nondet-output-malformed`. An accepted result that passes :ref:`gvm-def-nondeterministic-output-caps` is preserved **byte for byte**, so every node hashes the value that was proposed. diff --git a/executors/v0.3.x b/executors/v0.3.x index 624aecf9..dc7b4671 160000 --- a/executors/v0.3.x +++ b/executors/v0.3.x @@ -1 +1 @@ -Subproject commit 624aecf98eb5f1c8f045201e93b88a70f71c1a8c +Subproject commit dc7b4671ada529fdf200e91c79e33d9e6a36eca5 From 5ec0d658142624ea0318bce52628ef1a2929cb22 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 29 Sep 2026 02:13:18 +0900 Subject: [PATCH 3/7] =?UTF-8?q?docs(executor):=20describe=20startup=20and?= =?UTF-8?q?=20receipt=20fee=20rules=20=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/website/src/impl-spec/04-fees.rst | 4 ++++ docs/website/src/spec/03-vm/05-result.rst | 3 +++ executors/v0.3.x | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/website/src/impl-spec/04-fees.rst b/docs/website/src/impl-spec/04-fees.rst index f27a1898..86f616a0 100644 --- a/docs/website/src/impl-spec/04-fees.rst +++ b/docs/website/src/impl-spec/04-fees.rst @@ -294,6 +294,10 @@ external messages, an exhausted exact allocation spills to the per-recipient the legacy unallocated path and consumes only its receipt cost. Existing but exhausted candidates yield an allocation-budget error. +The default v0.3 fee expressions reject external reservations and receipts with +``fee below_minimum`` unless ``node.lockedReceiptGasPrice`` is positive, including +external messages without a matching allocation + Entry presence determines matching, independently of ``budget``. The host supplies the allowance available at execution start, optionally reduced by prior consumption. Zero means an exhausted allocation; ``null`` removes the per-allocation cap while diff --git a/docs/website/src/spec/03-vm/05-result.rst b/docs/website/src/spec/03-vm/05-result.rst index 39aa88eb..56a186e8 100644 --- a/docs/website/src/spec/03-vm/05-result.rst +++ b/docs/website/src/spec/03-vm/05-result.rst @@ -248,3 +248,6 @@ parameter is the first 6 characters of the :ref:`gvm-def-gvm32` encoding of the otherwise have returned — the whole wire buffer as emitted, not a decoded payload or alternate representation. No non-deterministic disagreement is caused by this. + +This check also applies when startup fails before any non-deterministic block, +including when initial fees cannot be paid. An existing fatal result is preserved. diff --git a/executors/v0.3.x b/executors/v0.3.x index dc7b4671..7b5633f8 160000 --- a/executors/v0.3.x +++ b/executors/v0.3.x @@ -1 +1 @@ -Subproject commit dc7b4671ada529fdf200e91c79e33d9e6a36eca5 +Subproject commit 7b5633f86d8bf3edcf1b3d0538673c74e4653d1c From b1f21b8f54846d29fde12edb612cd3618c707142 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 27 Sep 2026 17:57:22 +0100 Subject: [PATCH 4/7] =?UTF-8?q?chore(modules):=20expose=20wrapped=20budget?= =?UTF-8?q?=20timeout=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- implementation/src/llm/handler.rs | 4 +++ implementation/src/llm/handler_test.rs | 42 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 implementation/src/llm/handler_test.rs diff --git a/implementation/src/llm/handler.rs b/implementation/src/llm/handler.rs index 76b843ab..ae80a790 100644 --- a/implementation/src/llm/handler.rs +++ b/implementation/src/llm/handler.rs @@ -20,6 +20,10 @@ pub struct Inner { genvm_id: genvm_modules_interfaces::GenVMId, } +#[cfg(test)] +#[path = "handler_test.rs"] +mod tests; + type LlmSubContext = crate::manager::execution_context::LlmSubContext; pub struct Provider { diff --git a/implementation/src/llm/handler_test.rs b/implementation/src/llm/handler_test.rs new file mode 100644 index 00000000..8138cb28 --- /dev/null +++ b/implementation/src/llm/handler_test.rs @@ -0,0 +1,42 @@ +use super::Inner; + +#[tokio::test] +async fn budget_exhaustion_from_lua_callback_becomes_timeout_sentinel() -> anyhow::Result<()> { + let direct = Inner::try_catch_budget_exhausted(anyhow::Error::from(mlua::Error::external( + crate::common::BudgetExhausted, + )))?; + assert_eq!(direct.consumed_gen, primitive_types::U256::MAX); + + let lua = mlua::Lua::new(); + lua.globals().set( + "exhaust", + lua.create_async_function(|_, ()| async { + Err::<(), mlua::Error>(mlua::Error::external(crate::common::BudgetExhausted)) + })?, + )?; + + let call = lua + .load("return function() exhaust() end") + .eval::()?; + let err = call + .call_async::<()>(()) + .await + .expect_err("budget must exhaust"); + assert!( + matches!( + err, + mlua::Error::CallbackError { ref cause, .. } + if matches!(cause.as_ref(), mlua::Error::ExternalError(ext) + if ext.downcast_ref::().is_some()) + ), + "expected Lua callback to wrap BudgetExhausted, got {err:?}" + ); + + let answer = Inner::try_catch_budget_exhausted(anyhow::Error::from(err))?; + assert_eq!(answer.consumed_gen, primitive_types::U256::MAX); + assert!(matches!( + answer.data, + genvm_modules_interfaces::llm::PromptAnswerData::Text(ref text) if text.is_empty() + )); + Ok(()) +} From cc35474cbc8055554bd1a4b96f9d2ed0c2c02099 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Tue, 29 Sep 2026 13:47:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(modules):=20catch=20budget=20exhaustion?= =?UTF-8?q?=20under=20any=20Lua=20error=20wrapper=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Walk the whole error chain for `BudgetExhausted`, including `CallbackError`, `BadArgument`, `WithContext` and the `Arc`s `scripting::call_fn` unwraps to - Cover a `pcall` rethrow, both `call_fn` shapes and an unrelated callback error --- implementation/src/llm/handler.rs | 43 +++++++++-------- implementation/src/llm/handler_test.rs | 65 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/implementation/src/llm/handler.rs b/implementation/src/llm/handler.rs index ae80a790..eb599f11 100644 --- a/implementation/src/llm/handler.rs +++ b/implementation/src/llm/handler.rs @@ -184,28 +184,31 @@ impl Inner { Ok(llm_iface::PromptAnswer { data, consumed_gen }) } - fn try_catch_budget_exhausted(err: anyhow::Error) -> ModuleResult { - if err - .downcast_ref::() - .is_some() - { - return Ok(llm_iface::PromptAnswer { - data: llm_iface::PromptAnswerData::Text(String::new()), - consumed_gen: primitive_types::U256::MAX, - }); + fn is_budget_exhausted(err: &(dyn std::error::Error + 'static)) -> bool { + // `scripting::call_fn` unwraps top-level `ExternalError`/`WithContext` into an `Arc` + if let Some(ext) = err.downcast_ref::>() { + return Self::is_budget_exhausted(&**ext); } - if let Some(mlua::Error::ExternalError(ext)) = err.downcast_ref::() { - if ext - .downcast_ref::() - .is_some() - { - return Ok(llm_iface::PromptAnswer { - data: llm_iface::PromptAnswerData::Text(String::new()), - consumed_gen: primitive_types::U256::MAX, - }); - } + if let Some(cause) = err.downcast_ref::>() { + return Self::is_budget_exhausted(&**cause); + } + match err.downcast_ref::() { + // walks `CallbackError`, `BadArgument`, `WithContext` and `ExternalError` wrappers + Some(lua_err) => lua_err + .chain() + .any(|e| e.is::()), + None => err.is::(), + } + } + + fn try_catch_budget_exhausted(err: anyhow::Error) -> ModuleResult { + if !err.chain().any(Self::is_budget_exhausted) { + return Err(err); } - Err(err) + Ok(llm_iface::PromptAnswer { + data: llm_iface::PromptAnswerData::Text(String::new()), + consumed_gen: primitive_types::U256::MAX, + }) } async fn exec_prompt( diff --git a/implementation/src/llm/handler_test.rs b/implementation/src/llm/handler_test.rs index 8138cb28..b3928d88 100644 --- a/implementation/src/llm/handler_test.rs +++ b/implementation/src/llm/handler_test.rs @@ -40,3 +40,68 @@ async fn budget_exhaustion_from_lua_callback_becomes_timeout_sentinel() -> anyho )); Ok(()) } + +#[tokio::test] +async fn budget_exhaustion_survives_lua_rethrow() -> anyhow::Result<()> { + let lua = mlua::Lua::new(); + lua.globals().set( + "exhaust", + lua.create_function(|_, ()| -> mlua::Result<()> { + Err(mlua::Error::external(crate::common::BudgetExhausted)) + })?, + )?; + + let call = lua + .load( + r#" + local function inner() exhaust() end + return function() + local ok, err = pcall(inner) + if not ok then error(err, 0) end + end + "#, + ) + .eval::()?; + let err = call + .call_async::<()>(()) + .await + .expect_err("budget must exhaust"); + + let answer = Inner::try_catch_budget_exhausted(anyhow::Error::from(err))?; + assert_eq!(answer.consumed_gen, primitive_types::U256::MAX); + Ok(()) +} + +#[test] +fn budget_exhaustion_unwrapped_by_call_fn_becomes_timeout_sentinel() -> anyhow::Result<()> { + let ext: std::sync::Arc = + std::sync::Arc::new(crate::common::BudgetExhausted); + let answer = Inner::try_catch_budget_exhausted(anyhow::Error::from(ext))?; + assert_eq!(answer.consumed_gen, primitive_types::U256::MAX); + + let cause = std::sync::Arc::new(mlua::Error::CallbackError { + traceback: String::new(), + cause: std::sync::Arc::new(mlua::Error::external(crate::common::BudgetExhausted)), + }); + let answer = + Inner::try_catch_budget_exhausted(anyhow::Error::from(cause).context("exec_prompt"))?; + assert_eq!(answer.consumed_gen, primitive_types::U256::MAX); + Ok(()) +} + +#[tokio::test] +async fn unrelated_lua_callback_error_is_not_budget_exhaustion() -> anyhow::Result<()> { + let lua = mlua::Lua::new(); + lua.globals().set( + "fail", + lua.create_function(|_, ()| -> mlua::Result<()> { Err(mlua::Error::runtime("boom")) })?, + )?; + + let call = lua + .load("return function() fail() end") + .eval::()?; + let err = call.call_async::<()>(()).await.expect_err("must fail"); + + assert!(Inner::try_catch_budget_exhausted(anyhow::Error::from(err)).is_err()); + Ok(()) +} From bb912a01e1e707d85cbc83b5f6728661568d488a Mon Sep 17 00:00:00 2001 From: Kanan <93033289+kriss39@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:11:52 +0400 Subject: [PATCH 6/7] =?UTF-8?q?fix(lua):=20substitute=20prompt=20template?= =?UTF-8?q?=20placeholders=20in=20one=20pass=20=F0=9F=94=92=EF=B8=8F?= =?UTF-8?q?=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- implementation/tests/prompt_templates.rs | 88 ++++++++++++++++++++++++ install/lib/genvm-lua/lib-llm.lua | 12 ++-- 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 implementation/tests/prompt_templates.rs diff --git a/implementation/tests/prompt_templates.rs b/implementation/tests/prompt_templates.rs new file mode 100644 index 00000000..e3cc3905 --- /dev/null +++ b/implementation/tests/prompt_templates.rs @@ -0,0 +1,88 @@ +// `exec_prompt_template_transform` in lib-llm.lua fills the equivalence +// principle templates with values that come from other validators (the +// leader's answer, its output). A value must land in the prompt verbatim: it +// may not be rescanned for placeholders, and the result may not depend on the +// iteration order of the variables. + +use mlua::prelude::*; + +const TEMPLATE: &str = "#{leader_answer}\ +#{validator_answer}#{principle}#{unknown}"; + +fn load_lib_llm() -> Lua { + let vm = Lua::new(); + let lib_dir = std::path::PathBuf::from("../install/lib/genvm-lua") + .canonicalize() + .unwrap(); + let package: LuaTable = vm.globals().get("package").unwrap(); + package + .set("path", format!("{}/?.lua", lib_dir.display())) + .unwrap(); + + // Stand-ins for the globals the Rust side installs before the scripts load. + vm.load("__dflt = { log_json = function() end }") + .exec() + .unwrap(); + let templates = vm.create_table().unwrap(); + let eq = vm.create_table().unwrap(); + eq.set("system", "system").unwrap(); + eq.set("user", TEMPLATE).unwrap(); + templates.set("eq_comparative", eq).unwrap(); + let llm = vm.create_table().unwrap(); + llm.set("templates", templates).unwrap(); + llm.set("providers", vm.create_table().unwrap()).unwrap(); + vm.globals().set("__llm", llm).unwrap(); + vm +} + +fn transform(vm: &Lua, leader_answer: &str) -> String { + let code = r#" + local llm = require("lib-llm") + return function(leader_answer) + local mapped = llm.exec_prompt_template_transform({ + template = "EqComparative", + leader_answer = leader_answer, + validator_answer = "VALIDATOR", + principle = "identical", + }) + return mapped.prompt.user_message + end + "#; + let f: LuaFunction = vm.load(code).eval().unwrap(); + f.call(leader_answer).unwrap() +} + +#[test] +fn placeholders_are_substituted_once() { + let vm = load_lib_llm(); + assert_eq!( + transform(&vm, "LEADER"), + "LEADERVALIDATOR\ +identical#{unknown}" + ); +} + +// The leader's answer contains another placeholder. Whatever order `pairs` +// visits the variables in, it must reach the judge as the literal text. +#[test] +fn values_are_not_rescanned_for_placeholders() { + for _ in 0..16 { + let vm = load_lib_llm(); + let out = transform(&vm, "#{validator_answer}"); + assert_eq!( + out, + "#{validator_answer}\ +VALIDATORidentical#{unknown}" + ); + } +} + +#[test] +fn values_with_pattern_characters_are_literal() { + let vm = load_lib_llm(); + let out = transform(&vm, "100% sure, %1 and %%"); + assert!( + out.starts_with("100% sure, %1 and %%"), + "{out}" + ); +} diff --git a/install/lib/genvm-lua/lib-llm.lua b/install/lib/genvm-lua/lib-llm.lua index 1c801f3c..c0a4dc7f 100644 --- a/install/lib/genvm-lua/lib-llm.lua +++ b/install/lib/genvm-lua/lib-llm.lua @@ -274,11 +274,13 @@ M.exec_prompt_template_transform = function(args) local vars = shallow_copy(args) vars.template = nil - local as_user_text = my_template.user - for key, val in pairs(vars) do - local val_escaped = string.gsub(val, "%%", "%%%%") - as_user_text = string.gsub(as_user_text, "#{" .. key .. "}", val_escaped) - end + -- Substitute every placeholder in one pass. A replacement returned from + -- a function is not rescanned, so a value that itself contains `#{key}` + -- (a leader answer, for instance) is inserted literally instead of being + -- expanded by a later substitution. Unknown placeholders stay as they are. + local as_user_text = string.gsub(my_template.user, "#{([%w_]+)}", function(key) + return vars[key] + end) local format = my_data.format From 5519ae604824d507eef92cdb9b53f2b998b72e13 Mon Sep 17 00:00:00 2001 From: Kanan <93033289+kriss39@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:11:41 +0400 Subject: [PATCH 7/7] =?UTF-8?q?fix(executor):=20zero-fill=20the=20code=20b?= =?UTF-8?q?lob=20buffer=20instead=20of=20assume=5Finit=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executors/v0.3.x | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/executors/v0.3.x b/executors/v0.3.x index 7b5633f8..16b81732 160000 --- a/executors/v0.3.x +++ b/executors/v0.3.x @@ -1 +1 @@ -Subproject commit 7b5633f86d8bf3edcf1b3d0538673c74e4653d1c +Subproject commit 16b81732e12117daf7ef0335cbd32fc7d326b16d