diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 327820f..b84084b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 10 guest wasms ONCE (release/wasm32-wasip2): the single source of + # Build all 11 guest wasms ONCE (release/wasm32-wasip2): the single source of # truth for guest buildability and the artifacts the integration tests load. # Per-module size report folded in; a compile break still names the offending # crate and -D warnings still applies. @@ -77,7 +77,7 @@ jobs: cargo build --release --target wasm32-wasip2 --locked \ -p example -p price-alert -p balance-tracker -p http-probe \ -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb \ - -p panic-bomb -p slow-host + -p panic-bomb -p slow-host -p topic-parity { echo "### module .wasm sizes" echo "| module | bytes |" diff --git a/Cargo.lock b/Cargo.lock index 37f3e71..61223be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3296,10 +3296,12 @@ dependencies = [ name = "nexum-module-macros" version = "0.1.0" dependencies = [ + "alloy-primitives", "nexum-world", "proc-macro2", "quote", "syn 2.0.118", + "tempfile", ] [[package]] @@ -3396,6 +3398,7 @@ dependencies = [ name = "nexum-world" version = "0.1.0" dependencies = [ + "alloy-primitives", "strum", "syn 2.0.118", "tempfile", @@ -5267,6 +5270,15 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "topic-parity" +version = "0.1.0" +dependencies = [ + "alloy-sol-types", + "nexum-sdk", + "wit-bindgen 0.59.0", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 13af584..9c9a5d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "modules/fixtures/memory-bomb", "modules/fixtures/panic-bomb", "modules/fixtures/slow-host", + "modules/fixtures/topic-parity", "tools/load-gen", ] resolver = "2" diff --git a/crates/nexum-module-macros/Cargo.toml b/crates/nexum-module-macros/Cargo.toml index 547b47b..138f76b 100644 --- a/crates/nexum-module-macros/Cargo.toml +++ b/crates/nexum-module-macros/Cargo.toml @@ -13,7 +13,13 @@ proc-macro = true workspace = true [dependencies] +# Typed `B256` topics from the manifest extraction, embedded into the +# emitted parity check. +alloy-primitives.workspace = true nexum-world = { path = "../nexum-world", features = ["macros"] } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs index d021d6b..ddac670 100644 --- a/crates/nexum-module-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -4,8 +4,9 @@ //! per-cdylib module. Reach it through `nexum_sdk::module`, not this //! crate directly; the venue-side macros live in `videre-macros`. +use alloy_primitives::B256; use proc_macro::TokenStream; -use quote::quote; +use quote::{ToTokens, quote}; use syn::{ImplItem, ItemImpl}; /// The handler names recognised on a `#[module]` impl. An `on_`-prefixed @@ -39,16 +40,13 @@ const HANDLERS: [&str; 6] = [ /// on `wit-bindgen` directly; and the crate root must not shadow the /// std prelude names `Result`, `Vec`, or `Ok` (the generated `Guest` /// trait refers to them unqualified). +/// +/// `subscribes(EventType, ...)` fails the build unless the named events' +/// `SolEvent::SIGNATURE_HASH` values and the manifest's chain-log +/// `event_signature` values match as sets; the manifest stays authoritative. #[proc_macro_attribute] pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { - if !attr.is_empty() { - return syn::Error::new( - proc_macro2::Span::call_site(), - "#[nexum_sdk::module] takes no arguments", - ) - .to_compile_error() - .into(); - } + let args = syn::parse_macro_input!(attr as ModuleArgs); let input = syn::parse_macro_input!(item as ItemImpl); @@ -120,14 +118,34 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { } let has = |name: &str| present.contains(&name); - let (anchors, module_world) = match derive_module_world() { - Ok(parts) => parts, + let facts = match nexum_world::manifest_dir() + .and_then(|dir| derive_manifest_facts(&dir, !args.subscribes.is_empty())) + { + Ok(facts) => facts, Err(msg) => { return syn::Error::new(proc_macro2::Span::call_site(), msg) .to_compile_error() .into(); } }; + let ManifestFacts { + anchors, + world: module_world, + chain_log_topics, + path: manifest_path, + } = facts; + if !args.subscribes.is_empty() && chain_log_topics.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + format!( + "`subscribes(...)` names events, but {manifest_path} declares no chain-log \ + subscription with an `event_signature`; add the subscription or drop the argument" + ), + ) + .to_compile_error() + .into(); + } + let parity = topic_parity_check(&args.subscribes, &chain_log_topics); let wit_paths = match nexum_world::manifest_wit_packages(&module_world.packages) { Ok(paths) => paths, Err(msg) => { @@ -178,11 +196,9 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let message_arm = arm("on_message", "Message"); let custom_arm = arm("on_custom", "Custom"); + let anchors = rebuild_anchors(&anchors); quote! { - // Anchor a rebuild on the manifest and the extension registry: - // the emitted world is derived from them, so an edit to either - // must recompile the module. - #(const _: &[u8] = ::core::include_bytes!(#anchors);)* + #anchors wit_bindgen::generate!({ inline: #inline_world, @@ -193,6 +209,8 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); + #parity + #input #[doc(hidden)] @@ -217,11 +235,66 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +/// The macro's arguments: bare, or `subscribes(EventType, ...)`. +struct ModuleArgs { + subscribes: Vec, +} + +impl syn::parse::Parse for ModuleArgs { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + if input.is_empty() { + return Ok(Self { + subscribes: Vec::new(), + }); + } + let ident: syn::Ident = input.parse().map_err(|e| { + syn::Error::new( + e.span(), + "expected `subscribes(EventType, ...)` or no arguments", + ) + })?; + if ident != "subscribes" { + return Err(syn::Error::new( + ident.span(), + "#[nexum_sdk::module] takes no arguments except `subscribes(EventType, ...)`", + )); + } + let inner; + syn::parenthesized!(inner in input); + if !input.is_empty() { + return Err(input.error("unexpected tokens after `subscribes(...)`")); + } + let paths = inner + .call(syn::punctuated::Punctuated::::parse_terminated)?; + if paths.is_empty() { + return Err(syn::Error::new( + ident.span(), + "`subscribes(...)` must name at least one event type", + )); + } + Ok(Self { + subscribes: paths.into_iter().collect(), + }) + } +} + +struct ManifestFacts { + /// Rebuild anchor paths: the manifests the emitted world depends on. + anchors: Vec, + world: nexum_world::ModuleWorld, + /// Distinct chain-log `event_signature` topics, in declaration order. + chain_log_topics: Vec, + path: String, +} + /// Synthesize the per-module world from the crate's `module.toml` /// `[capabilities]` plus the nearest ancestor `extensions.toml`. -/// Returns the rebuild anchor paths alongside the world. -fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { - let crate_dir = nexum_world::manifest_dir()?; +/// Topics are read only for `want_topics`, so a manifest field no +/// opted-in module names can never fail a build. +fn derive_manifest_facts( + crate_dir: &std::path::Path, + want_topics: bool, +) -> Result { let manifest_path = crate_dir.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( @@ -234,9 +307,15 @@ fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), Stri let declared = nexum_world::manifest_capabilities(&text) .map_err(|e| format!("{}: {e}", manifest_path.display()))?; let manifest_path = manifest_path.to_string_lossy().into_owned(); + let chain_log_topics = if want_topics { + nexum_world::manifest_chain_log_topics(&text) + .map_err(|e| format!("{manifest_path}: {e}"))? + } else { + Vec::new() + }; let mut anchors = vec![manifest_path.clone()]; - let extensions = match nexum_world::find_extensions_manifest(&crate_dir) { + let extensions = match nexum_world::find_extensions_manifest(crate_dir) { None => Vec::new(), Some(registry) => { let text = std::fs::read_to_string(®istry) @@ -247,7 +326,221 @@ fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), Stri rows } }; - let module_world = nexum_world::synthesize(&declared, &extensions) + let world = nexum_world::synthesize(&declared, &extensions) .map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((anchors, module_world)) + Ok(ManifestFacts { + anchors, + world, + chain_log_topics, + path: manifest_path, + }) +} + +/// Const assertions pinning set equality between the `subscribes(...)` +/// events' topic-0 hashes and the manifest's chain-log topics. Const +/// eval stops at the first failure, so every message names both sides: +/// a `SIGNATURE_HASH` cannot be formatted into one. +fn topic_parity_check(events: &[syn::Path], topics: &[B256]) -> proc_macro2::TokenStream { + if events.is_empty() { + return proc_macro2::TokenStream::new(); + } + let n = events.len(); + let m = topics.len(); + let declared_list = events + .iter() + .map(path_string) + .collect::>() + .join(", "); + let manifest_list = topics + .iter() + .map(B256::to_string) + .collect::>() + .join(", "); + let manifest_topics = topics.iter().map(|topic| { + let bytes = topic.0; + quote! { ::nexum_sdk::prelude::B256::new([#(#bytes),*]) } + }); + let declared_topics = events.iter().map(|path| { + quote! { <#path as ::nexum_sdk::sol_types::SolEvent>::SIGNATURE_HASH } + }); + let declared_checks = events.iter().enumerate().map(|(i, path)| { + let msg = format!( + "topic drift: `{}`'s topic-0 is not among the module.toml chain-log event_signature \ + values [{manifest_list}]", + path_string(path), + ); + quote! { + ::core::assert!( + ::nexum_sdk::events::contains_topic(&DECLARED[#i], &MANIFEST), + #msg, + ); + } + }); + let manifest_checks = topics.iter().enumerate().map(|(j, topic)| { + let msg = format!( + "topic drift: module.toml chain-log event_signature {topic} is not the topic-0 of any \ + of subscribes({declared_list})", + ); + quote! { + ::core::assert!( + ::nexum_sdk::events::contains_topic(&MANIFEST[#j], &DECLARED), + #msg, + ); + } + }); + quote! { + const _: () = { + const MANIFEST: [::nexum_sdk::prelude::B256; #m] = [#(#manifest_topics),*]; + const DECLARED: [::nexum_sdk::prelude::B256; #n] = [#(#declared_topics),*]; + #(#declared_checks)* + #(#manifest_checks)* + }; + } +} + +/// Cargo reruns a build on an `include_bytes!` target's mtime, which is the +/// only thing making a manifest edit retrigger expansion. +fn rebuild_anchors(anchors: &[String]) -> proc_macro2::TokenStream { + quote! { #(const _: &[u8] = ::core::include_bytes!(#anchors);)* } +} + +/// A path's source spelling, without quote's token spacing. +fn path_string(path: &syn::Path) -> String { + path.to_token_stream().to_string().replace(' ', "") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_args(tokens: proc_macro2::TokenStream) -> syn::Result { + syn::parse2(tokens) + } + + #[test] + fn bare_attribute_names_no_events() { + assert!(parse_args(quote! {}).unwrap().subscribes.is_empty()); + } + + #[test] + fn subscribes_parses_paths_in_order() { + let args = parse_args(quote! { subscribes(OrderPlacement, events::Refund) }).unwrap(); + let names: Vec = args.subscribes.iter().map(path_string).collect(); + assert_eq!(names, ["OrderPlacement", "events::Refund"]); + } + + #[test] + fn empty_subscribes_is_rejected() { + let err = parse_args(quote! { subscribes() }).err().unwrap(); + assert!(err.to_string().contains("at least one event type"), "{err}"); + } + + #[test] + fn unknown_argument_is_rejected() { + let err = parse_args(quote! { emits(Foo) }).err().unwrap(); + assert!( + err.to_string().contains("subscribes(EventType, ...)"), + "{err}" + ); + let err = parse_args(quote! { subscribes(Foo), extra }).err().unwrap(); + assert!(err.to_string().contains("unexpected tokens"), "{err}"); + } + + #[test] + fn no_events_emits_no_parity_check() { + assert!(topic_parity_check(&[], &[B256::ZERO]).is_empty()); + } + + #[test] + fn parity_check_pins_both_directions() { + let event: syn::Path = syn::parse_quote!(OrderPlacement); + let topic: B256 = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + .parse() + .unwrap(); + let emitted = topic_parity_check(&[event], &[topic]).to_string(); + assert!(emitted.contains("SIGNATURE_HASH"), "{emitted}"); + assert!(emitted.contains("contains_topic"), "{emitted}"); + assert!( + emitted.contains("`OrderPlacement`'s topic-0 is not among"), + "{emitted}", + ); + assert!( + emitted.contains("is not the topic-0 of any of subscribes(OrderPlacement)"), + "{emitted}", + ); + // Topic bytes are embedded, not re-parsed at build time. + assert!(emitted.contains("207u8"), "{emitted}"); + } + + /// Const eval stops at the first failing assert, so whichever fires must + /// carry the code-side event and the manifest-side topics together. + #[test] + fn either_refusal_alone_names_both_sides() { + let events: Vec = vec![syn::parse_quote!(Placed), syn::parse_quote!(Filled)]; + let topics = [B256::with_last_byte(1), B256::with_last_byte(2)]; + let emitted = topic_parity_check(&events, &topics).to_string(); + let msgs = refusal_messages(&emitted); + assert_eq!(msgs.len(), 4, "one per event plus one per topic"); + for msg in msgs { + assert!(msg.contains("Placed") || msg.contains("Filled"), "{msg}"); + assert!( + msg.contains(&topics[0].to_string()) || msg.contains(&topics[1].to_string()), + "{msg}", + ); + } + } + + /// Both manifests the world is derived from are `include_bytes!`ed, so + /// editing either retriggers expansion. + #[test] + fn every_manifest_read_is_a_rebuild_anchor() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("module.toml"), MANIFEST).expect("write manifest"); + std::fs::write( + dir.path().join("extensions.toml"), + "[extensions.acme]\nimport = \"acme:host/acme@0.1.0\"\n", + ) + .expect("write registry"); + + let facts = derive_manifest_facts(dir.path(), true).expect("facts"); + let emitted = rebuild_anchors(&facts.anchors).to_string(); + for manifest in ["module.toml", "extensions.toml"] { + let anchor = dir.path().join(manifest); + assert!( + facts + .anchors + .contains(&anchor.to_string_lossy().into_owned()), + "{manifest} is not anchored", + ); + assert!(emitted.contains(manifest), "{emitted}"); + } + assert_eq!(emitted.matches("include_bytes").count(), 2, "{emitted}"); + } + + /// A manifest field only `subscribes(...)` reads must not fail the build + /// of a module that does not name it. + #[test] + fn topics_are_read_only_when_the_attribute_names_events() { + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = format!( + "{MANIFEST}\n[[subscription]]\nkind = \"chain-log\"\nchain_id = 1\n\ + event_signature = \"not-a-topic\"\n" + ); + std::fs::write(dir.path().join("module.toml"), manifest).expect("write manifest"); + + assert!(derive_manifest_facts(dir.path(), false).is_ok()); + let err = derive_manifest_facts(dir.path(), true).err().unwrap(); + assert!(err.contains("invalid topic \"not-a-topic\""), "{err}"); + } + + const MANIFEST: &str = "[module]\nname = \"t\"\n\n[capabilities]\nrequired = [\"logging\"]\n"; + + /// The string literals the emitted `assert!`s carry. + fn refusal_messages(emitted: &str) -> Vec { + emitted + .split("topic drift: ") + .skip(1) + .map(|rest| rest.split('"').next().unwrap_or_default().to_owned()) + .collect() + } } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 8e37971..aef5b75 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -207,6 +207,63 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } } + /// The macro-side topic extraction and the load-time parse read one + /// grammar: a drift lets a build-checked manifest fail at load, or vice + /// versa. + #[test] + fn world_topic_extraction_agrees_with_load() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "block" +chain_id = 1 + +[[subscription]] +kind = "chain-log" +chain_id = 1 +event_signature = "0xCF5F9DE2984132265203B5C335B25727702CA77262FF622E136BAA7362BF1DA9" + +[[subscription]] +kind = "chain-log" +chain_id = 1 +event_signature = "0x0000000000000000000000000000000000000000000000000000000000000001" + +[[subscription]] +kind = "chain-log" +chain_id = 100 +event_signature = "cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + // Distinct, not `dedup`: the repeat is non-adjacent, as it is on chain. + let mut loaded: Vec = Vec::new(); + for sub in &manifest.subscriptions { + if let Subscription::ChainLog { + event_signature: Some(topic), + .. + } = sub + && !loaded.contains(topic) + { + loaded.push(*topic); + } + } + assert_eq!( + loaded.len(), + 2, + "the fixture repeats a topic non-adjacently" + ); + assert_eq!( + nexum_world::manifest_chain_log_topics(toml).expect("extract"), + loaded, + ); + + let bad = "[module]\nname = \"bad\"\n\n[[subscription]]\nkind = \"chain-log\"\n\ + chain_id = 1\nevent_signature = \"not-a-topic\"\n"; + assert!(toml::from_str::(bad).is_err()); + assert!(nexum_world::manifest_chain_log_topics(bad).is_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 b7c9636..c0b65c8 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -83,13 +83,13 @@ pub enum Subscription { /// Core subscription kinds parsed by shape; others fall through to /// [`Subscription::Extension`]. +// `kebab-case` reproduces `nexum_world::SubscriptionKind`, which gates this. #[derive(Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] +#[serde(tag = "kind", rename_all = "kebab-case")] enum CoreSubscription { Block { chain_id: u64, }, - #[serde(rename = "chain-log")] ChainLog { chain_id: u64, #[serde(default, deserialize_with = "chain_log_address")] @@ -157,12 +157,12 @@ impl<'de> Deserialize<'de> for Subscription { let Some(kind) = table.get("kind").and_then(toml::Value::as_str) else { return Err(D::Error::missing_field("kind")); }; - match kind { - "block" | "chain-log" | "cron" => toml::Value::Table(table.clone()) + match kind.parse::() { + Ok(_) => toml::Value::Table(table.clone()) .try_into::() .map(Into::into) .map_err(D::Error::custom), - _ => { + Err(_) => { let kind = kind.to_owned(); let mut filters = BTreeMap::new(); for (key, value) in table { diff --git a/crates/nexum-sdk/src/events.rs b/crates/nexum-sdk/src/events.rs index 4342faa..c9ecbf8 100644 --- a/crates/nexum-sdk/src/events.rs +++ b/crates/nexum-sdk/src/events.rs @@ -10,6 +10,18 @@ use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, LogData}; /// The alloy RPC log delivered to modules for chain-log events. pub use alloy_rpc_types_eth::Log; +/// Const so the module macro's topic parity check fails the build, not the run. +pub const fn contains_topic(needle: &B256, set: &[B256]) -> bool { + let mut i = 0; + while i < set.len() { + if set[i].const_eq(needle) { + return true; + } + i += 1; + } + false +} + /// Borrowed raw fields of a WIT `chain-log` record, assembled into an /// alloy [`Log`] via `From`. Fixed-width byte fields are left-padded /// into their EVM word (20 bytes for the address, 32 for topics and @@ -69,6 +81,20 @@ impl From> for Log { mod tests { use super::*; + #[test] + fn contains_topic_is_bytewise_membership() { + let set = [B256::with_last_byte(1), B256::with_last_byte(2)]; + assert!(contains_topic(&B256::with_last_byte(2), &set)); + assert!(!contains_topic(&B256::with_last_byte(3), &set)); + assert!(!contains_topic(&B256::ZERO, &[])); + } + + /// The parity check the module macro emits must evaluate at const time. + const _: () = assert!(contains_topic( + &B256::with_last_byte(9), + &[B256::ZERO, B256::with_last_byte(9)], + )); + #[test] fn assembles_full_mined_log() { let addr = [0x11u8; 20]; diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 9023a59..52dfac6 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -28,6 +28,9 @@ /// handlers. See [`nexum_module_macros::module`]. pub use nexum_module_macros::module; +/// Names `SolEvent` in the emitted parity check; `sol!` still needs a direct dep. +pub use alloy_sol_types as sol_types; + pub mod address; pub mod chain; pub mod config; diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml index fd1a64f..a1be00d 100644 --- a/crates/nexum-world/Cargo.toml +++ b/crates/nexum-world/Cargo.toml @@ -15,6 +15,9 @@ workspace = true macros = ["dep:syn"] [dependencies] +# Typed `B256` topics for the chain-log extraction, so the macro-side +# parity check parses the same values the runtime loads. +alloy-primitives.workspace = true # Derives the closed capability / fault-label vocabularies: `VariantNames` # supersedes a hand-maintained list, `EnumString` parses fail-closed. strum.workspace = true diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 78d543b..9eef8dc 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -9,6 +9,7 @@ //! per-namespace rows come from a composition root's `extensions.toml` //! ([`manifest_extensions`]) and are passed to [`synthesize`]. +use alloy_primitives::B256; use std::path::{Path, PathBuf}; use strum::{Display, EnumString, IntoStaticStr, VariantNames}; @@ -43,6 +44,22 @@ impl Cap { } } +/// A core `[[subscription]] kind`. A kind with no variant here is +/// extension-owned, so the set is the runtime's core/extension split. +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Hash, Display, EnumString, IntoStaticStr, VariantNames, +)] +#[strum(serialize_all = "kebab-case")] +#[non_exhaustive] +pub enum SubscriptionKind { + /// New-block events. + Block, + /// Chain-log events filtered by address and topic-0. + ChainLog, + /// Cron-scheduled ticks. + Cron, +} + /// A `nexum:host/types.fault` case as a stable snake_case label, in WIT /// declaration order; the single source every label mirror emits from. #[derive( @@ -322,6 +339,44 @@ pub fn manifest_kind(text: &str) -> Result, String> { } } +/// The distinct chain-log `event_signature` topics from the manifest +/// text, in declaration order. Same hex grammar as the runtime's load. +pub fn manifest_chain_log_topics(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + let Some(subscriptions) = value.get("subscription") else { + return Ok(Vec::new()); + }; + let subscriptions = subscriptions + .as_array() + .ok_or_else(|| "[[subscription]] must be an array of tables".to_string())?; + let mut topics = Vec::new(); + for sub in subscriptions { + let kind = sub + .get("kind") + .and_then(toml::Value::as_str) + .map(str::parse::); + if !matches!(kind, Some(Ok(SubscriptionKind::ChainLog))) { + continue; + } + let Some(raw) = sub.get("event_signature") else { + continue; + }; + let raw = raw + .as_str() + .ok_or_else(|| "[[subscription]].event_signature must be a string".to_string())?; + let topic: B256 = raw + .parse() + // Pinned operator wording; mirrors the runtime's load-time refusal. + .map_err(|e| format!("invalid topic {raw:?}: {e}"))?; + if !topics.contains(&topic) { + topics.push(topic); + } + } + Ok(topics) +} + /// The registered extension rows from an `extensions.toml`. Each /// `[extensions.]` table carries a WIT `import` and the extra /// `packages` its resolve path needs. No `[extensions]` section @@ -803,6 +858,79 @@ allow = [] assert!(err.contains("[module].kind must be a string")); } + /// Pinned manifest grammar; the runtime's serde renames derive from it. + #[test] + fn subscription_kinds_spell_the_manifest_grammar() { + assert_eq!(SubscriptionKind::VARIANTS, ["block", "chain-log", "cron"]); + assert!("log".parse::().is_err()); + } + + #[test] + fn chain_log_topics_are_distinct_and_in_declaration_order() { + let topics = manifest_chain_log_topics( + r#" +[[subscription]] +kind = "chain-log" +chain_id = 1 +event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + +[[subscription]] +kind = "block" +chain_id = 1 + +[[subscription]] +kind = "chain-log" +chain_id = 100 +event_signature = "CF5F9DE2984132265203B5C335B25727702CA77262FF622E136BAA7362BF1DA9" + +[[subscription]] +kind = "chain-log" +chain_id = 1 +event_signature = "0x0000000000000000000000000000000000000000000000000000000000000001" +"#, + ) + .unwrap(); + assert_eq!( + topics, + vec![ + "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + .parse::() + .unwrap(), + B256::with_last_byte(1), + ], + ); + } + + #[test] + fn chain_log_topics_skip_wildcard_and_foreign_subscriptions() { + let text = r#" +[[subscription]] +kind = "chain-log" +chain_id = 1 + +[[subscription]] +kind = "acme-status" +event_signature = "not-hex-but-not-ours" +"#; + assert_eq!(manifest_chain_log_topics(text).unwrap(), Vec::::new()); + assert_eq!(manifest_chain_log_topics("").unwrap(), Vec::::new()); + } + + #[test] + fn chain_log_topic_refusal_pins_the_operator_wording() { + let err = manifest_chain_log_topics( + "[[subscription]]\nkind = \"chain-log\"\nchain_id = 1\n\ + event_signature = \"not-a-topic\"\n", + ) + .unwrap_err(); + assert!(err.starts_with("invalid topic \"not-a-topic\":"), "{err}"); + let err = manifest_chain_log_topics( + "[[subscription]]\nkind = \"chain-log\"\nchain_id = 1\nevent_signature = 7\n", + ) + .unwrap_err(); + assert!(err.contains("must be a string"), "{err}"); + } + #[test] fn manifest_without_capabilities_section_is_an_error() { let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); diff --git a/justfile b/justfile index 4f0be28..f51091b 100644 --- a/justfile +++ b/justfile @@ -15,7 +15,7 @@ build-examples: build-fixtures: cargo build --target wasm32-wasip2 --release \ -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb \ - -p panic-bomb -p slow-host + -p panic-bomb -p slow-host -p topic-parity # Build everything the E2E suite needs. build: build-engine build-module build-examples build-fixtures @@ -67,7 +67,7 @@ ci: cargo build --release --target wasm32-wasip2 \ -p example -p price-alert -p balance-tracker -p http-probe \ -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb \ - -p panic-bomb -p slow-host + -p panic-bomb -p slow-host -p topic-parity # nextest for the suite (as CI does); doctests run separately since nextest # does not cover them. cargo nextest run --workspace --all-features --no-fail-fast diff --git a/modules/fixtures/topic-parity/Cargo.toml b/modules/fixtures/topic-parity/Cargo.toml new file mode 100644 index 0000000..292078a --- /dev/null +++ b/modules/fixtures/topic-parity/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "topic-parity" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-sdk = { path = "../../../crates/nexum-sdk" } +# Declares the subscribed event, whose SIGNATURE_HASH the parity check reads. +alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/topic-parity/module.toml b/modules/fixtures/topic-parity/module.toml new file mode 100644 index 0000000..20e8c92 --- /dev/null +++ b/modules/fixtures/topic-parity/module.toml @@ -0,0 +1,25 @@ +# topic-parity build fixture. Never launched: it exists so CI compiles the +# `subscribes(...)` parity check the module macro emits. The two +# event_signature values below are the topic-0 of the two `sol!` events in +# src/lib.rs; edit either side alone and the build refuses. + +[module] +name = "topic-parity" +version = "0.1.0" + +[capabilities] +required = ["logging"] +optional = [] + +[capabilities.http] +allow = [] + +[[subscription]] +kind = "chain-log" +chain_id = 1 +event_signature = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +[[subscription]] +kind = "chain-log" +chain_id = 100 +event_signature = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925" diff --git a/modules/fixtures/topic-parity/src/lib.rs b/modules/fixtures/topic-parity/src/lib.rs new file mode 100644 index 0000000..ec52e5b --- /dev/null +++ b/modules/fixtures/topic-parity/src/lib.rs @@ -0,0 +1,29 @@ +//! # topic-parity (build fixture) +//! +//! Compile-only: `subscribes(...)` names the events below, so the macro +//! emits the const parity check against `module.toml`. A drift on either +//! side fails this crate's build. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use alloy_sol_types::sol; +use nexum::host::{logging, types}; + +sol! { + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); +} + +struct TopicParity; + +#[nexum_sdk::module(subscribes(Transfer, Approval))] +impl TopicParity { + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("received {} chain-log entries", batch.logs.len()), + ); + Ok(()) + } +}