From 6ee2453411b5c1d2692e7f9d33e7ed1ba7ce517c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 6 Aug 2026 11:33:50 +0000 Subject: [PATCH 1/2] refactor(manifest): type chain-log hex and gated WASI names at load AI Assistance: Claude (Fable 5) used for implementation and tests --- .../src/manifest/capabilities.rs | 62 +++++++++++------ crates/nexum-runtime/src/manifest/load.rs | 23 +++++++ crates/nexum-runtime/src/manifest/types.rs | 41 +++++++++--- .../nexum-runtime/src/supervisor/cursors.rs | 15 +++-- .../nexum-runtime/src/supervisor/prepass.rs | 20 +----- .../src/supervisor/subscriptions.rs | 51 +++----------- .../src/supervisor/tests/chain_gate.rs | 17 +++-- .../src/supervisor/tests/cursors.rs | 66 +++++++++++-------- .../nexum-runtime/src/test_utils/manifest.rs | 6 +- 9 files changed, 169 insertions(+), 132 deletions(-) diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index a983dbc..3e400de 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -9,6 +9,8 @@ use std::collections::HashSet; +use strum::VariantNames; + use super::error::{CapabilityError, CapabilityViolation}; use super::types::{CORE_CAPABILITIES, LoadedManifest}; @@ -54,14 +56,43 @@ const HTTP_CAPABILITY: &str = nexum_world::Cap::Http.as_str(); /// Gated WASI capability names; declaring one grants the matching `wasi:` /// interface group. See [`classify_wasi`]. -const WASI_CAPABILITIES: &[&str] = &["wasi-sockets", "wasi-filesystem"]; +const WASI_CAPABILITIES: &[&str] = WasiCap::VARIANTS; + +/// A gated WASI capability; the single source of the `wasi-*` name set. +#[derive(Clone, Copy, strum::VariantNames, strum::VariantArray)] +enum WasiCap { + #[strum(serialize = "wasi-sockets")] + Sockets, + #[strum(serialize = "wasi-filesystem")] + Filesystem, +} + +impl WasiCap { + const ALL: &'static [Self] = ::VARIANTS; + + /// The declared name; the discriminant indexes `VARIANTS`, so this is const. + const fn as_str(self) -> &'static str { + Self::VARIANTS[self as usize] + } + + /// The `wasi:` interface prefix this capability gates. + const fn gated_prefix(self) -> &'static str { + match self { + Self::Sockets => "wasi:sockets/", + Self::Filesystem => "wasi:filesystem/", + } + } +} + +/// Always-linked `wasi:` prefixes: io, clocks, random, stdio/exit/terminal. +const AMBIENT_WASI_PREFIXES: &[&str] = &["wasi:io/", "wasi:clocks/", "wasi:random/", "wasi:cli/"]; /// A `wasi:` import (other than `wasi:http`) classified against the gate. enum WasiGate { - /// Always linked, never declared: io, clocks, random, stdio/exit/terminal. + /// Always linked, never declared. Ambient, - /// Usable only when the named capability is declared. - Gated(&'static str), + /// Usable only when the capability is declared. + Gated(WasiCap), /// Unrecognised `wasi:` interface: refused fail-closed. Unknown, } @@ -69,20 +100,13 @@ enum WasiGate { /// Classify a non-http `wasi:` interface id, ignoring any `@version` suffix. fn classify_wasi(import_name: &str) -> WasiGate { let iface = import_name.split('@').next().unwrap_or(import_name); - if iface.starts_with("wasi:io/") - || iface.starts_with("wasi:clocks/") - || iface.starts_with("wasi:random/") - { - WasiGate::Ambient - } else if iface.starts_with("wasi:filesystem/") { - WasiGate::Gated("wasi-filesystem") - } else if iface.starts_with("wasi:sockets/") { - WasiGate::Gated("wasi-sockets") - } else if iface.starts_with("wasi:cli/") { - WasiGate::Ambient - } else { - WasiGate::Unknown + if AMBIENT_WASI_PREFIXES.iter().any(|p| iface.starts_with(p)) { + return WasiGate::Ambient; } + WasiCap::ALL + .iter() + .find(|cap| iface.starts_with(cap.gated_prefix())) + .map_or(WasiGate::Unknown, |&cap| WasiGate::Gated(cap)) } /// Capability namespaces recognised by enforcement: the core namespace plus @@ -181,10 +205,10 @@ pub fn enforce_capabilities<'a>( if without_version.starts_with("wasi:") && !without_version.starts_with(WASI_HTTP_PREFIX) { match classify_wasi(import_name) { WasiGate::Ambient => {} - WasiGate::Gated(cap) if declared.contains(cap) => {} + WasiGate::Gated(cap) if declared.contains(cap.as_str()) => {} WasiGate::Gated(cap) => { return Err(CapabilityViolation { - capability: cap.to_owned(), + capability: cap.as_str().to_owned(), wit_import: import_name.to_owned(), } .into()); diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 5350317..c5c62db 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -156,6 +156,29 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } } + /// Malformed chain-log hex refuses the manifest at parse, not at first + /// dispatch, with the operator wording pinned verbatim. + #[test] + fn load_refuses_malformed_chain_log_hex_at_parse() { + for (field, detail) in [ + ( + "address = \"0xabc\"", + "invalid chain-log address \"0xabc\"", + ), + ( + "event_signature = \"not-a-topic\"", + "invalid topic \"not-a-topic\"", + ), + ] { + let toml = format!( + "[module]\nname = \"bad\"\n\n[[subscription]]\nkind = \"chain-log\"\n\ + chain_id = 1\n{field}\n" + ); + let err = toml::from_str::(&toml).expect_err("malformed hex"); + assert!(err.to_string().contains(detail), "{err}"); + } + } + #[test] fn load_parses_the_retired_log_kind_as_an_extension_kind() { // The chain-event kind is `chain-log`; a stale `kind = "log"` diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 7f86b99..9348111 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; +use alloy_primitives::{Address, B256}; use serde::Deserialize; use serde::de::Error as _; @@ -51,11 +52,10 @@ pub enum Subscription { ChainLog { /// EVM chain id. chain_id: u64, - /// Contract address as `0x`-prefixed 20-byte hex. Optional. - address: Option, - /// Topic-0 filter as `0x`-prefixed 32-byte hex; absent matches - /// every event from the address(es). - event_signature: Option, + /// Contract address filter. Optional. + address: Option
, + /// Topic-0 filter; absent matches every event from the address(es). + event_signature: Option, /// Persist a durable cursor; a restart re-opens AT the cursor block /// and replays it. resume: bool, @@ -91,10 +91,10 @@ enum CoreSubscription { #[serde(rename = "chain-log")] ChainLog { chain_id: u64, - #[serde(default)] - address: Option, - #[serde(default)] - event_signature: Option, + #[serde(default, deserialize_with = "chain_log_address")] + address: Option
, + #[serde(default, deserialize_with = "chain_log_topic")] + event_signature: Option, #[serde(default)] resume: bool, #[serde(default)] @@ -105,6 +105,29 @@ enum CoreSubscription { }, } +fn chain_log_address<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + // Pinned operator wording. + hex_field(d, "invalid chain-log address") +} + +fn chain_log_topic<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + // Pinned operator wording. + hex_field(d, "invalid topic") +} + +/// Refusal lands at manifest load; `label` carries the pinned wording. +fn hex_field<'de, D, T>(d: D, label: &str) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + let raw = String::deserialize(d)?; + raw.parse() + .map(Some) + .map_err(|e| D::Error::custom(format!("{label} {raw:?}: {e}"))) +} + impl From for Subscription { fn from(sub: CoreSubscription) -> Self { match sub { diff --git a/crates/nexum-runtime/src/supervisor/cursors.rs b/crates/nexum-runtime/src/supervisor/cursors.rs index 0454461..4f40d4c 100644 --- a/crates/nexum-runtime/src/supervisor/cursors.rs +++ b/crates/nexum-runtime/src/supervisor/cursors.rs @@ -126,18 +126,21 @@ pub(super) fn persist_progress_marker( } } -/// Derived from normalized manifest inputs, not the alloy `Filter` (whose -/// hash is process-randomized), so it is stable across restarts. +/// Keyed on canonical lowercase hex, not the alloy `Filter` (whose hash is +/// process-randomized), so it is stable across restarts; matches the keys +/// derived from `0x`-prefixed manifest strings before values were typed. pub(super) fn chainlog_cursor_key( chain: Chain, - address: Option<&str>, - event_signature: Option<&str>, + address: Option, + event_signature: Option, ) -> String { let normalized = format!( "{}|{}|{}", chain.id(), - address.unwrap_or("").to_ascii_lowercase(), - event_signature.unwrap_or("").to_ascii_lowercase(), + address.map(|a| format!("{a:#x}")).unwrap_or_default(), + event_signature + .map(|t| format!("{t:#x}")) + .unwrap_or_default(), ); format!( "chainlog_cursor:{:x}", diff --git a/crates/nexum-runtime/src/supervisor/prepass.rs b/crates/nexum-runtime/src/supervisor/prepass.rs index 8d93308..ea7a15c 100644 --- a/crates/nexum-runtime/src/supervisor/prepass.rs +++ b/crates/nexum-runtime/src/supervisor/prepass.rs @@ -9,7 +9,6 @@ use anyhow::{Context, Error, Result, anyhow}; use tracing::{info, warn}; use super::role::Role; -use super::subscriptions::build_alloy_filter; use crate::engine_config::EngineConfig; use crate::manifest::{self, CapabilityRegistry, LoadedManifest, Subscription}; @@ -113,8 +112,8 @@ impl ConfiguredChains { } } -/// Refuse any subscription naming a chain absent from `[chains]` or carrying -/// an unparseable chain-log filter, before any guest code runs. +/// Refuse any subscription naming a chain absent from `[chains]`, before any +/// guest code runs; chain-log filter values are already typed at parse. pub(super) fn enforce_subscriptions( role: Role, name: &str, @@ -129,21 +128,6 @@ pub(super) fn enforce_subscriptions( if !chains.contains(*chain_id) { return Err(unconfigured_chain(role, name, *chain_id, chains)); } - if let Subscription::ChainLog { - address, - event_signature, - .. - } = sub - { - build_alloy_filter(address.as_deref(), event_signature.as_deref()).with_context( - || { - format!( - "{} {name} declares an invalid chain-log filter on chain {chain_id}", - role.claim_role(), - ) - }, - )?; - } } Ok(()) } diff --git a/crates/nexum-runtime/src/supervisor/subscriptions.rs b/crates/nexum-runtime/src/supervisor/subscriptions.rs index 58561a2..2fc5e0f 100644 --- a/crates/nexum-runtime/src/supervisor/subscriptions.rs +++ b/crates/nexum-runtime/src/supervisor/subscriptions.rs @@ -37,18 +37,12 @@ impl Supervisor { resume, max_lookback, } => { - let filter = - build_alloy_filter(address.as_deref(), event_signature.as_deref()) - .expect("chain-log filters are validated at load"); + let filter = build_alloy_filter(*address, *event_signature); let chain = Chain::from_id(*chain_id); // A `resume` subscription reads its durable cursor // once here at boot; others start at head. let (cursor_key, initial_cursor) = if *resume { - let key = chainlog_cursor_key( - chain, - address.as_deref(), - event_signature.as_deref(), - ); + let key = chainlog_cursor_key(chain, *address, *event_signature); let seed = read_chain_log_cursor( &self.shared.components.store, module.name.as_str(), @@ -157,44 +151,17 @@ impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { } } -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub(super) enum FilterError { - /// `[[subscriptions]].address` did not parse as an EVM address. - #[error("invalid chain-log address {address:?}: {source}")] - Address { - address: String, - #[source] - source: alloy_primitives::hex::FromHexError, - }, - /// `[[subscriptions]].event_signature` did not parse as a 32-byte topic. - #[error("invalid topic {topic:?}: {source}")] - Topic { - topic: String, - #[source] - source: alloy_primitives::hex::FromHexError, - }, -} - +/// Infallible: the manifest carries typed filter values. pub(super) fn build_alloy_filter( - address: Option<&str>, - event_signature: Option<&str>, -) -> std::result::Result { - use alloy_primitives::{Address, B256}; + address: Option, + event_signature: Option, +) -> alloy_rpc_types_eth::Filter { let mut filter = alloy_rpc_types_eth::Filter::new(); - if let Some(addr_hex) = address { - let addr: Address = addr_hex.parse().map_err(|source| FilterError::Address { - address: addr_hex.to_owned(), - source, - })?; + if let Some(addr) = address { filter = filter.address(addr); } - if let Some(topic_hex) = event_signature { - let topic: B256 = topic_hex.parse().map_err(|source| FilterError::Topic { - topic: topic_hex.to_owned(), - source, - })?; + if let Some(topic) = event_signature { filter = filter.event_signature(topic); } - Ok(filter) + filter } diff --git a/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs b/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs index 67c0ed9..95d597c 100644 --- a/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs +++ b/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs @@ -80,8 +80,8 @@ async fn boot_refuses_an_adapter_subscription_on_an_unconfigured_chain() { .lacks("compile"); } -/// Filter values fail closed at load: an unparseable address or topic -/// refuses the boot instead of skipping the subscription at collection. +/// Filter values fail closed at manifest parse: an unparseable address or +/// topic refuses the boot as a manifest error, before any compile. #[tokio::test] async fn boot_refuses_an_invalid_chain_log_filter() { for (manifest, detail) in [ @@ -89,28 +89,31 @@ async fn boot_refuses_an_invalid_chain_log_filter() { TestManifest::new("example") .cap("logging") .chain_log_sub_filtered(1, Some("0xabc"), None), - "invalid chain-log address", + // Pinned operator wording. + "invalid chain-log address \"0xabc\"", ), ( TestManifest::new("example") .cap("logging") .chain_log_sub_filtered(1, None, Some("not-a-topic")), - "invalid topic", + // Pinned operator wording. + "invalid topic \"not-a-topic\"", ), ] { BootScenario::new() .module(manifest) .expect_refusal() .await - .names("module example declares an invalid chain-log filter on chain 1") + .names("load module") + .names("manifest: parse") .names(detail) .lacks("read component") .lacks("compile"); } } -/// The load-time filter check and the collection-time rebuild read the same -/// manifest values, so the collection rebuild cannot fail. +/// The manifest carries typed filter values, so the collection-time filter +/// build cannot fail. #[tokio::test] async fn a_validated_chain_log_filter_survives_to_the_collected_subscription() { let Some(wasm) = example_wasm_or_skip() else { diff --git a/crates/nexum-runtime/src/supervisor/tests/cursors.rs b/crates/nexum-runtime/src/supervisor/tests/cursors.rs index 4963319..a2f70d5 100644 --- a/crates/nexum-runtime/src/supervisor/tests/cursors.rs +++ b/crates/nexum-runtime/src/supervisor/tests/cursors.rs @@ -14,7 +14,7 @@ fn progress_marker_key_uses_numeric_chain_id() { fn alloy_filter_with_address_and_topic() { let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; - let filter = build_alloy_filter(Some(addr), Some(topic)).unwrap(); + let filter = build_alloy_filter(Some(addr.parse().unwrap()), Some(topic.parse().unwrap())); // alloy `Filter` exposes no getter; assert through its serialisation. let serialised = serde_json::to_value(&filter).unwrap(); let addr_field = serialised @@ -27,7 +27,7 @@ fn alloy_filter_with_address_and_topic() { #[test] fn alloy_filter_no_address_no_topic() { - let filter = build_alloy_filter(None, None).unwrap(); + let filter = build_alloy_filter(None, None); let serialised = serde_json::to_value(&filter).unwrap(); assert!( serialised.get("address").is_none() @@ -36,19 +36,6 @@ fn alloy_filter_no_address_no_topic() { ); } -#[test] -fn alloy_filter_rejects_bad_address() { - let err = build_alloy_filter(Some("not-an-address"), None); - assert!(err.is_err()); -} - -#[test] -fn alloy_filter_rejects_bad_topic() { - let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; - let err = build_alloy_filter(Some(addr), Some("not-a-topic")); - assert!(err.is_err()); -} - /// A mined log carries every block-scoped field; the host projection must /// preserve each one so the guest rebuilds the native alloy log losslessly. #[test] @@ -129,40 +116,61 @@ fn project_chain_log_leaves_pending_fields_none() { assert!(!projected.removed); } +/// Data-compat guard: the typed derivation must reproduce the key formerly +/// keccak'd from the lowercased `0x`-prefixed manifest strings, so a resume +/// cursor written before values were typed still seeds the same stream. #[test] -fn chainlog_cursor_key_is_stable_and_case_insensitive() { - // The key must be reproducible across restarts (the alloy `Filter` hash - // is process-randomized) and must normalise hex case. - let a = chainlog_cursor_key(Chain::from_id(1), Some("0xAbC"), Some("0xDeF")); - let b = chainlog_cursor_key(Chain::from_id(1), Some("0xabc"), Some("0xdef")); - assert_eq!(a, b, "hex case must not change the key"); - assert!( - a.starts_with("chainlog_cursor:"), - "key carries the prefix: {a}" +fn chainlog_cursor_key_matches_the_legacy_string_derivation() { + let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; + let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; + let key = chainlog_cursor_key( + Chain::from_id(1), + Some(addr.parse().unwrap()), + Some(topic.parse().unwrap()), + ); + let legacy = format!("1|{}|{}", addr.to_ascii_lowercase(), topic); + assert_eq!( + key, + format!( + "chainlog_cursor:{:x}", + alloy_primitives::keccak256(legacy.as_bytes()) + ), ); } #[test] fn chainlog_cursor_key_differs_by_each_input() { - let base = chainlog_cursor_key(Chain::from_id(1), Some("0xabc"), Some("0xdef")); + use alloy_primitives::{Address, B256}; + + let addr = Address::repeat_byte(0xab); + let topic = B256::repeat_byte(0xde); + let base = chainlog_cursor_key(Chain::from_id(1), Some(addr), Some(topic)); + assert!( + base.starts_with("chainlog_cursor:"), + "key carries the prefix: {base}" + ); assert_ne!( base, - chainlog_cursor_key(Chain::from_id(10), Some("0xabc"), Some("0xdef")), + chainlog_cursor_key(Chain::from_id(10), Some(addr), Some(topic)), "chain id is part of the key", ); assert_ne!( base, - chainlog_cursor_key(Chain::from_id(1), Some("0x999"), Some("0xdef")), + chainlog_cursor_key( + Chain::from_id(1), + Some(Address::repeat_byte(0x99)), + Some(topic) + ), "address is part of the key", ); assert_ne!( base, - chainlog_cursor_key(Chain::from_id(1), Some("0xabc"), None), + chainlog_cursor_key(Chain::from_id(1), Some(addr), None), "topic presence changes the key", ); assert_ne!( base, - chainlog_cursor_key(Chain::from_id(1), None, Some("0xdef")), + chainlog_cursor_key(Chain::from_id(1), None, Some(topic)), "address presence changes the key", ); } diff --git a/crates/nexum-runtime/src/test_utils/manifest.rs b/crates/nexum-runtime/src/test_utils/manifest.rs index c4861e0..0ba19ff 100644 --- a/crates/nexum-runtime/src/test_utils/manifest.rs +++ b/crates/nexum-runtime/src/test_utils/manifest.rs @@ -306,6 +306,8 @@ mod tests { fn chain_log_filters_and_extension_kinds_reach_the_loaded_subscriptions() { const ADDRESS: &str = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC"; const TOPIC: &str = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"; + let address: alloy_primitives::Address = ADDRESS.parse().unwrap(); + let topic: alloy_primitives::B256 = TOPIC.parse().unwrap(); let loaded = load_core( &TestManifest::new("example") @@ -321,7 +323,7 @@ mod tests { matches!( &subs[0], Subscription::ChainLog { chain_id: 1, address: Some(a), event_signature: Some(t), .. } - if a == ADDRESS && t == TOPIC + if *a == address && *t == topic ), "both filters land: {subs:?}", ); @@ -329,7 +331,7 @@ mod tests { matches!( &subs[1], Subscription::ChainLog { chain_id: 2, address: Some(a), event_signature: None, .. } - if a == ADDRESS + if *a == address ), "an omitted topic stays unfiltered: {subs:?}", ); From 65e14dd2679ada67471531fe3f6b50b04419fd3f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 6 Aug 2026 11:51:06 +0000 Subject: [PATCH 2/2] refactor(manifest): derive the gated WASI name and pin the hex grammar AI Assistance: Claude Opus 4.5 used for red-team review and fixes --- .../src/manifest/capabilities.rs | 7 ++--- crates/nexum-runtime/src/manifest/load.rs | 28 +++++++++++++++++++ crates/nexum-runtime/src/manifest/types.rs | 5 ++-- .../nexum-runtime/src/supervisor/cursors.rs | 16 +++++------ .../nexum-runtime/src/supervisor/prepass.rs | 2 +- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 3e400de..fd9b361 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -59,7 +59,7 @@ const HTTP_CAPABILITY: &str = nexum_world::Cap::Http.as_str(); const WASI_CAPABILITIES: &[&str] = WasiCap::VARIANTS; /// A gated WASI capability; the single source of the `wasi-*` name set. -#[derive(Clone, Copy, strum::VariantNames, strum::VariantArray)] +#[derive(Clone, Copy, strum::IntoStaticStr, strum::VariantNames, strum::VariantArray)] enum WasiCap { #[strum(serialize = "wasi-sockets")] Sockets, @@ -70,9 +70,8 @@ enum WasiCap { impl WasiCap { const ALL: &'static [Self] = ::VARIANTS; - /// The declared name; the discriminant indexes `VARIANTS`, so this is const. - const fn as_str(self) -> &'static str { - Self::VARIANTS[self as usize] + fn as_str(self) -> &'static str { + self.into() } /// The `wasi:` interface prefix this capability gates. diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index c5c62db..8e37971 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -179,6 +179,34 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } } + /// Typing the field must neither widen nor narrow the accepted spelling: + /// `0x`-prefixed or bare, any case, no checksum requirement. + #[test] + fn load_accepts_every_hex_spelling_of_a_chain_log_address() { + let expected: alloy_primitives::Address = "0xc92e8bdf79f0507f65a392b0ab4667716bfe0110" + .parse() + .expect("canonical address"); + for spelling in [ + "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110", + "0xc92e8bdf79f0507f65a392b0ab4667716bfe0110", + "0xC92E8BDF79F0507F65A392B0AB4667716BFE0110", + "c92e8bdf79f0507f65a392b0ab4667716bfe0110", + ] { + let toml = format!( + "[module]\nname = \"ok\"\n\n[[subscription]]\nkind = \"chain-log\"\n\ + chain_id = 1\naddress = \"{spelling}\"\n" + ); + let manifest: Manifest = toml::from_str(&toml).expect(spelling); + assert!( + matches!( + &manifest.subscriptions[0], + Subscription::ChainLog { address: Some(a), .. } if *a == expected + ), + "{spelling} must parse to the canonical address", + ); + } + } + #[test] fn load_parses_the_retired_log_kind_as_an_extension_kind() { // The chain-event kind is `chain-log`; a stale `kind = "log"` diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 9348111..b7c9636 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -52,9 +52,10 @@ pub enum Subscription { ChainLog { /// EVM chain id. chain_id: u64, - /// Contract address filter. Optional. + /// Contract address filter, declared as 20-byte hex. address: Option
, - /// Topic-0 filter; absent matches every event from the address(es). + /// Topic-0 filter, declared as 32-byte hex; absent matches every + /// event from the address(es). event_signature: Option, /// Persist a durable cursor; a restart re-opens AT the cursor block /// and replays it. diff --git a/crates/nexum-runtime/src/supervisor/cursors.rs b/crates/nexum-runtime/src/supervisor/cursors.rs index 4f40d4c..c2879d4 100644 --- a/crates/nexum-runtime/src/supervisor/cursors.rs +++ b/crates/nexum-runtime/src/supervisor/cursors.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use alloy_chains::Chain; +use alloy_primitives::{Address, B256, keccak256}; use tracing::warn; use crate::host::component::{StateHandle, StateStore}; @@ -126,13 +127,13 @@ pub(super) fn persist_progress_marker( } } -/// Keyed on canonical lowercase hex, not the alloy `Filter` (whose hash is -/// process-randomized), so it is stable across restarts; matches the keys -/// derived from `0x`-prefixed manifest strings before values were typed. +/// Keyed on `0x`-prefixed lowercase hex, not the alloy `Filter` (whose hash +/// is process-randomized), so it is stable across a restart and across the +/// typing of the manifest values it was formerly derived from. pub(super) fn chainlog_cursor_key( chain: Chain, - address: Option, - event_signature: Option, + address: Option
, + event_signature: Option, ) -> String { let normalized = format!( "{}|{}|{}", @@ -142,8 +143,5 @@ pub(super) fn chainlog_cursor_key( .map(|t| format!("{t:#x}")) .unwrap_or_default(), ); - format!( - "chainlog_cursor:{:x}", - alloy_primitives::keccak256(normalized.as_bytes()) - ) + format!("chainlog_cursor:{:x}", keccak256(normalized.as_bytes())) } diff --git a/crates/nexum-runtime/src/supervisor/prepass.rs b/crates/nexum-runtime/src/supervisor/prepass.rs index ea7a15c..ba27c9a 100644 --- a/crates/nexum-runtime/src/supervisor/prepass.rs +++ b/crates/nexum-runtime/src/supervisor/prepass.rs @@ -113,7 +113,7 @@ impl ConfiguredChains { } /// Refuse any subscription naming a chain absent from `[chains]`, before any -/// guest code runs; chain-log filter values are already typed at parse. +/// guest code runs. pub(super) fn enforce_subscriptions( role: Role, name: &str,