diff --git a/crates/modules-interfaces/src/domain.rs b/crates/modules-interfaces/src/domain.rs index 02e37de..9b58650 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 778c9cf..0000000 --- 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 bea5819..a533d37 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 9b2212a..86f616a 100644 --- a/docs/website/src/impl-spec/04-fees.rst +++ b/docs/website/src/impl-spec/04-fees.rst @@ -286,21 +286,42 @@ 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. +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 +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 +344,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 b90ea20..36e5bc7 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/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 51bb6af..4ff7213 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/docs/website/src/spec/03-vm/05-result.rst b/docs/website/src/spec/03-vm/05-result.rst index 39aa88e..56a186e 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.2.x b/executors/v0.2.x index 75e4d1f..b08736a 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 e813da5..16b8173 160000 --- a/executors/v0.3.x +++ b/executors/v0.3.x @@ -1 +1 @@ -Subproject commit e813da599329bb848efd058d9e20ae660d740ebe +Subproject commit 16b81732e12117daf7ef0335cbd32fc7d326b16d diff --git a/implementation/src/llm/handler.rs b/implementation/src/llm/handler.rs index 76b843a..eb599f1 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 { @@ -180,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 new file mode 100644 index 0000000..b3928d8 --- /dev/null +++ b/implementation/src/llm/handler_test.rs @@ -0,0 +1,107 @@ +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(()) +} + +#[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(()) +} diff --git a/implementation/src/manager/run.rs b/implementation/src/manager/run.rs index 3c2651a..b1938ce 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/implementation/tests/prompt_templates.rs b/implementation/tests/prompt_templates.rs new file mode 100644 index 0000000..e3cc390 --- /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 1c801f3..c0a4dc7 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 diff --git a/tests/runner/genvm_tool_plugins/integration.py b/tests/runner/genvm_tool_plugins/integration.py index d117378..89d2608 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 156748f..3345fdc 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'', }