From 2054584ca20f55430a2c5abf8e131b930f615189 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 1 Jun 2026 14:19:42 -0300 Subject: [PATCH 01/15] chore(deps): pull cowprotocol, alloy, redb, reqwest, tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the dependencies the 0.2 host backends need: - cowprotocol (1.0.0-alpha) for the cow-api submission path (OrderBookApi, OrderCreation, OrderUid, Chain). - alloy-provider / -rpc-client / -transport-ws / -primitives (1.5) for the chain JSON-RPC dispatch. The reqwest feature on alloy-provider engages connect_http; the pubsub/ws features back eth_subscribe-class methods. - redb (2) for local-store. Same crate cowprotocol's own watch-tower picked, so the dep tree does not bifurcate when both are used in the same workspace. - reqwest (0.12, rustls-tls) — direct, so the import survives any future cowprotocol feature rearrangement. - tracing + tracing-subscriber (env-filter + fmt) — replaces the 0.1 eprintln! debug log so the engine can drop into a structured log pipeline without re-instrumenting every host call. - thiserror (2) — typed error enums in each backend. - tempfile + wiremock as dev-deps for the host backend tests. Adds engine.example.toml documenting the [engine] state_dir + per- chain RPC URLs the chain backend reads at boot; data/ is now ignored so a local run does not leave the redb file in tree. --- .gitignore | 1 + crates/nexum-engine/Cargo.toml | 46 +++++++++++++++++++++++++++++++++- engine.example.toml | 34 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 engine.example.toml diff --git a/.gitignore b/.gitignore index 357bddc..e43a15c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Thumbs.db # Environment .env .env.* +data/ diff --git a/crates/nexum-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml index 65768c3..637f51f 100644 --- a/crates/nexum-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -6,10 +6,54 @@ license.workspace = true repository.workspace = true [dependencies] +# WASM Component Model runtime. wasmtime = { version = "45", features = ["component-model"] } wasmtime-wasi = "45" + +# Async + error plumbing. anyhow = "1" +thiserror = "2" tokio = { version = "1", features = ["full"] } -getrandom = "0.4" + +# Manifest parsing. serde = { version = "1", features = ["derive"] } toml = "1" +serde_json = "1" + +# Observability. `tracing` replaces the prior `eprintln!` debug log +# so the engine can drop into a structured log pipeline in production. +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi"] } + +# `cow-api` backend. cowprotocol pulls `OrderBookApi`, `OrderCreation`, +# `OrderUid`, the orderbook base URL table per `Chain`, and the typed +# error surface the host re-projects into `HostError`. Pinned to the +# crates.io release Shepherd is shipping against. +cowprotocol = "1.0.0-alpha" +# REST passthrough for `cow_api::request`. cowprotocol pulls reqwest +# transitively for its own client; we depend on it directly so the +# import is explicit and survives any future cowprotocol feature +# rearrangement. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# `chain` backend. Each configured chain owns a `DynProvider` built +# from a `WsConnect`/`Http` transport so the host's `request` / +# `request-batch` impls can hand a raw `(method, params)` pair to +# alloy's JSON-RPC layer without reimplementing the codec. +alloy-provider = { version = "1.5", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +alloy-rpc-client = { version = "1.5", default-features = false } +alloy-transport = { version = "1.5", default-features = false } +alloy-transport-ws = { version = "1.5", default-features = false } +alloy-primitives = { version = "1.5", default-features = false, features = ["std", "serde"] } + +# `local-store` backend. Per-module namespacing is enforced +# host-side via a `[len:u8][module_name][raw_key]` prefix. +redb = "2" + +# Misc. +getrandom = "0.4" +url = "2" + +[dev-dependencies] +tempfile = "3" +wiremock = "0.6" diff --git a/engine.example.toml b/engine.example.toml new file mode 100644 index 0000000..d6513b0 --- /dev/null +++ b/engine.example.toml @@ -0,0 +1,34 @@ +# Engine-side runtime configuration for `nexum-engine`. +# +# Distinct from `nexum.toml` (per-module manifest): this file +# describes the *engine*'s I/O wiring. Copy to `engine.toml` next to +# the binary, or pass the path as the third positional argument. + +[engine] +# Directory the local-store redb file (and future engine artefacts) +# will be created under. Created automatically at boot. +state_dir = "./data" + +# `tracing_subscriber::EnvFilter`-compatible directive. `RUST_LOG` +# overrides at process start. +log_level = "info" + +# One [chains.] table per chain the engine should be able to talk +# to. Chain ids are EVM decimal. `ws://` and `wss://` URLs engage +# alloy's pubsub transport (needed for `eth_subscribe`); `http://` and +# `https://` use the HTTP transport. + +[chains.1] +rpc_url = "https://ethereum-rpc.publicnode.com" + +[chains.100] +rpc_url = "https://rpc.gnosischain.com" + +[chains.11155111] +rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" + +[chains.42161] +rpc_url = "https://arb1.arbitrum.io/rpc" + +[chains.8453] +rpc_url = "https://mainnet.base.org" From f85d3d3ad7c915c0ca7369873b105293d3039fa1 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 1 Jun 2026 14:20:01 -0300 Subject: [PATCH 02/15] runtime: implement cow-api, chain, local-store host backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 0.2 Unsupported stubs with working backends. Each capability lives in its own host submodule so the trait impls in main.rs stay thin (dispatch + project the backend's typed error onto HostError). cow_api::submit_order - Parses the guest's bytes as JSON cowprotocol::OrderCreation. - Dispatches via cowprotocol::OrderBookApi::post_order. - Returns the assigned OrderUid as a 0x-prefixed hex string. cow_api::request - REST passthrough. The base URL is whichever URL the pool's OrderBookApi client carries — so OrderBookApi::new_with_base_url overrides (staging, wiremock) flow through transparently. - Method/path validated host-side; orderbook 4xx/5xx bodies are surfaced verbatim so the guest can decode {errorType,description}. chain::request - Raw JSON-RPC dispatch over an alloy DynProvider opened from engine.toml at boot. WebSocket URLs engage pubsub (eth_subscribe); HTTP URLs use the HTTP transport. Params are passed as serde_json::RawValue so alloy does not re-encode. - request-batch falls back to per-call dispatch (same shape as the earlier stub but now backed by real RPC). local_store - redb file under engine_config.engine.state_dir. - Single shared table. Per-module namespacing is enforced host-side via [len:u8][module_name][raw_key] prefix on every key. list_keys strips the prefix before returning to the guest. logging - Routes through tracing::event! tagged with module=. - Engine boot installs an EnvFilter-based subscriber; RUST_LOG overrides the engine.toml log_level. identity / remote-store / messaging / http stay at Unsupported per the 0.2 roadmap (keystore / Swarm / Waku land in 0.3). Tests (14, all green): - cow_orderbook: pool default chains, unknown-chain typing, REST GET passthrough, relative-path resolution, unknown-method rejection, submit_order round-trip — last three under wiremock so the full HTTP path is exercised without hitting api.cow.fi. - provider_pool: empty pool surfaces UnknownChain. - local_store: roundtrip, namespace isolation, delete, list_keys prefix-stripping, empty-namespace rejection. End-to-end against modules/example: example.wasm loads under the new wiring, logs init + on_event through the tracing pipeline. --- crates/nexum-engine/src/engine_config.rs | 98 +++++ crates/nexum-engine/src/host/cow_orderbook.rs | 294 +++++++++++++ .../nexum-engine/src/host/local_store_redb.rs | 211 +++++++++ crates/nexum-engine/src/host/mod.rs | 12 + crates/nexum-engine/src/host/provider_pool.rs | 152 +++++++ crates/nexum-engine/src/main.rs | 403 ++++++++++++------ 6 files changed, 1029 insertions(+), 141 deletions(-) create mode 100644 crates/nexum-engine/src/engine_config.rs create mode 100644 crates/nexum-engine/src/host/cow_orderbook.rs create mode 100644 crates/nexum-engine/src/host/local_store_redb.rs create mode 100644 crates/nexum-engine/src/host/mod.rs create mode 100644 crates/nexum-engine/src/host/provider_pool.rs diff --git a/crates/nexum-engine/src/engine_config.rs b/crates/nexum-engine/src/engine_config.rs new file mode 100644 index 0000000..4ec271c --- /dev/null +++ b/crates/nexum-engine/src/engine_config.rs @@ -0,0 +1,98 @@ +//! Engine-side runtime configuration. +//! +//! Distinct from `nexum.toml` (module manifest): this file describes +//! the *engine*'s I/O wiring — chain RPC endpoints and the on-disk +//! location of the `local-store` database. Both are required for the +//! 0.2 reference engine to do anything other than print stubs. +//! +//! Lookup order: +//! +//! 1. `--engine-config ` CLI flag (future), or third positional +//! argument today; +//! 2. `engine.toml` in the current working directory; +//! 3. defaults — no chains configured, `state_dir = ./data`. +//! +//! A missing config is OK for the example module (it only logs); for +//! the cow-api / chain backends it surfaces as `HostError { +//! kind: unsupported }` so guests learn early. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use tracing::{info, warn}; + +/// Engine-side configuration loaded from `engine.toml`. +#[derive(Debug, Default, Deserialize)] +pub struct EngineConfig { + #[serde(default)] + pub engine: EngineSection, + /// Per-chain RPC URLs keyed by EVM chain id (decimal in TOML). + /// Used by the `chain::request` host call and as the alloy provider + /// pool seed. + #[serde(default)] + pub chains: BTreeMap, +} + +#[derive(Debug, Deserialize)] +pub struct EngineSection { + #[serde(default = "default_state_dir")] + pub state_dir: PathBuf, + /// `tracing_subscriber::EnvFilter`-compatible directive. Defaults to + /// `info` when absent; `RUST_LOG` overrides at process start. + #[serde(default = "default_log_level")] + pub log_level: String, +} + +impl Default for EngineSection { + fn default() -> Self { + Self { + state_dir: default_state_dir(), + log_level: default_log_level(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct ChainConfig { + /// JSON-RPC endpoint. `ws://` and `wss://` engage alloy's pubsub + /// transport (required for `eth_subscribe`); `http://` and `https://` + /// engage the HTTP transport (request/response only). + pub rpc_url: String, +} + +fn default_state_dir() -> PathBuf { + PathBuf::from("./data") +} + +fn default_log_level() -> String { + "info".to_owned() +} + +/// Read an engine config from disk, returning defaults if the file is +/// missing. Parse errors propagate. +pub fn load_or_default(path: Option<&Path>) -> anyhow::Result { + let path = match path { + Some(p) => p.to_path_buf(), + None => PathBuf::from("engine.toml"), + }; + + if !path.exists() { + warn!( + path = %path.display(), + "engine.toml not found — running with defaults (no chain RPC endpoints; \ + chain::request and cow_api::submit_order will return Unsupported)" + ); + return Ok(EngineConfig::default()); + } + + let raw = std::fs::read_to_string(&path)?; + let cfg: EngineConfig = toml::from_str(&raw)?; + info!( + path = %path.display(), + chains = cfg.chains.len(), + state_dir = %cfg.engine.state_dir.display(), + "engine config loaded", + ); + Ok(cfg) +} diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs new file mode 100644 index 0000000..cd11173 --- /dev/null +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -0,0 +1,294 @@ +//! `shepherd:cow/cow-api` backend. +//! +//! Two responsibilities: +//! +//! 1. `request` — generic REST passthrough. Module gives the HTTP +//! method, path (relative to the chain's orderbook base URL), and +//! optional JSON body. We dispatch via `reqwest`, return the +//! response body verbatim. +//! 2. `submit_order` — typed submission. Module gives a JSON-encoded +//! `cowprotocol::OrderCreation`; we parse, dispatch via +//! `cowprotocol::OrderBookApi::post_order`, return the assigned +//! `OrderUid` as a `0x`-prefixed hex string. +//! +//! Per-chain `OrderBookApi` instances are constructed once at engine +//! boot from the discriminated chain set in `cowprotocol::Chain`. +//! Chains the SDK does not know about return `Unsupported` at the +//! host call boundary. + +use std::collections::BTreeMap; + +use cowprotocol::{Chain, OrderBookApi, OrderCreation, OrderUid}; +use thiserror::Error; + +/// Process-wide pool of `OrderBookApi` clients keyed by EVM chain id. +#[derive(Debug, Clone)] +pub struct OrderBookPool { + clients: BTreeMap, + http: reqwest::Client, +} + +impl OrderBookPool { + /// Build a pool covering every `cowprotocol::Chain` variant. The + /// default `OrderBookApi::new(chain)` constructor uses the canonical + /// `api.cow.fi/{slug}/api/v1` base URL from the SDK; callers that + /// need barn or a custom staging URL override per chain. + pub fn with_default_chains() -> Self { + let http = reqwest::Client::new(); + let chains = [ + Chain::Mainnet, + Chain::Gnosis, + Chain::Sepolia, + Chain::ArbitrumOne, + Chain::Base, + ]; + let clients = chains + .iter() + .map(|c| (c.id(), OrderBookApi::new(*c))) + .collect(); + Self { clients, http } + } + + /// Look up the client for a chain. + pub fn get(&self, chain_id: u64) -> Result<&OrderBookApi, CowApiError> { + self.clients + .get(&chain_id) + .ok_or(CowApiError::UnknownChain(chain_id)) + } + + /// REST passthrough. The base URL is whichever URL the pool's + /// `OrderBookApi` client carries — overrides set via + /// `OrderBookApi::new_with_base_url` (staging, wiremock) flow + /// through here too, which keeps the passthrough and the typed + /// `submit_order_json` path aimed at the same orderbook. + pub async fn request( + &self, + chain_id: u64, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + let api = self.get(chain_id)?; + let base = api.base_url().clone(); + // `path` may or may not lead with a slash; `Url::join` handles + // both, but we strip a single leading `/` so consumers can + // write either `/orders/...` or `orders/...` interchangeably. + let trimmed = path.strip_prefix('/').unwrap_or(path); + let url = base + .join(trimmed) + .map_err(|e| CowApiError::BadPath(format!("{path:?}: {e}")))?; + + let request = match method.to_ascii_uppercase().as_str() { + "GET" => self.http.get(url), + "POST" => self.http.post(url), + "PUT" => self.http.put(url), + "DELETE" => self.http.delete(url), + other => return Err(CowApiError::BadMethod(other.to_owned())), + }; + let request = if let Some(body) = body { + request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body.to_owned()) + } else { + request + }; + + let response = request.send().await.map_err(CowApiError::Network)?; + // Surface the orderbook's structured 4xx / 5xx bodies verbatim + // so the guest can decode `{"errorType": "...", "description": + // "..."}` — projecting them into HostError here loses the + // detail the guest needs to recover. + let text = response.text().await.map_err(CowApiError::Network)?; + Ok(text) + } + + /// Typed submission. `body` is the JSON encoding of + /// `cowprotocol::OrderCreation`. The chain's orderbook validates + /// `from`, the EIP-712 hash, and (if `Eip1271`) the contract + /// signature; we return whatever UID it assigns. + pub async fn submit_order_json( + &self, + chain_id: u64, + body: &[u8], + ) -> Result { + let creation: OrderCreation = serde_json::from_slice(body).map_err(CowApiError::Decode)?; + let api = self.get(chain_id)?; + let uid = api + .post_order(&creation) + .await + .map_err(|e| CowApiError::Orderbook(e.to_string()))?; + Ok(uid) + } +} + +#[derive(Debug, Error)] +pub enum CowApiError { + #[error("unknown chain {0} (no cowprotocol::Chain variant)")] + UnknownChain(u64), + #[error("bad HTTP method `{0}` (expected GET/POST/PUT/DELETE)")] + BadMethod(String), + #[error("invalid path: {0}")] + BadPath(String), + #[error("network: {0}")] + Network(#[from] reqwest::Error), + #[error("decode OrderCreation JSON: {0}")] + Decode(#[from] serde_json::Error), + #[error("orderbook rejected: {0}")] + Orderbook(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn pool_indexes_default_chains() { + let pool = OrderBookPool::with_default_chains(); + assert!(pool.get(1).is_ok(), "mainnet present"); + assert!(pool.get(100).is_ok(), "gnosis present"); + assert!(pool.get(11_155_111).is_ok(), "sepolia present"); + assert!(pool.get(42_161).is_ok(), "arbitrum present"); + assert!(pool.get(8_453).is_ok(), "base present"); + } + + #[test] + fn unknown_chain_surfaces_typed_error() { + let pool = OrderBookPool::with_default_chains(); + assert!(matches!( + pool.get(99_999), + Err(CowApiError::UnknownChain(99_999)) + )); + } + + /// Build a pool whose Mainnet entry points at `mock.uri()`. + /// `OrderBookApi::new_with_base_url` ships in cowprotocol; we + /// rely on it so wiremock-driven tests can exercise the full + /// request path without re-implementing the HTTP client. + fn pool_with_mainnet_at(mock: &MockServer) -> OrderBookPool { + let mut clients = std::collections::BTreeMap::new(); + clients.insert( + Chain::Mainnet.id(), + OrderBookApi::new_with_base_url(mock.uri().parse().expect("mock uri parses")), + ); + OrderBookPool { + clients, + http: reqwest::Client::new(), + } + } + + #[tokio::test] + async fn request_passes_get_path_through() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/version")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"version":"x.y.z"}"#)) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request(Chain::Mainnet.id(), "GET", "/api/v1/version", None) + .await + .expect("request succeeds"); + assert_eq!(body, r#"{"version":"x.y.z"}"#); + } + + #[tokio::test] + async fn request_relative_path_works() { + // Module passes a path without a leading slash. The + // passthrough should still resolve against the orderbook + // base URL. + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/native_price/0xabc")) + .respond_with(ResponseTemplate::new(200).set_body_string("1.23")) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request( + Chain::Mainnet.id(), + "GET", + "api/v1/native_price/0xabc", + None, + ) + .await + .expect("relative path resolves"); + assert_eq!(body, "1.23"); + } + + #[tokio::test] + async fn request_rejects_unknown_method() { + let pool = OrderBookPool::with_default_chains(); + let err = pool + .request(Chain::Mainnet.id(), "PATCH", "/x", None) + .await + .unwrap_err(); + assert!(matches!(err, CowApiError::BadMethod(_))); + } + + #[tokio::test] + async fn submit_order_propagates_orderbook_response() { + let mock = MockServer::start().await; + let body_json = sample_order_json(); + // cowprotocol POST /api/v1/orders returns the order UID + // (56-byte hex) as a JSON string body. + let returned_uid = format!("\"0x{}\"", "ab".repeat(56)); + Mock::given(method("POST")) + .and(path("/api/v1/orders")) + .respond_with(ResponseTemplate::new(201).set_body_string(returned_uid.clone())) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let uid = pool + .submit_order_json(Chain::Mainnet.id(), body_json.as_bytes()) + .await + .expect("submit succeeds"); + assert_eq!(uid.as_slice().len(), 56); + assert_eq!(uid.as_slice(), &[0xab; 56]); + } + + /// A minimal but accepted-by-cowprotocol OrderCreation JSON. We + /// generate it inside the test so the JSON shape stays in lockstep + /// with the published `cowprotocol` version. + fn sample_order_json() -> String { + use alloy_primitives::{Address, U256}; + use cowprotocol::OrderCreation; + use cowprotocol::app_data::{EMPTY_APP_DATA_HASH, EMPTY_APP_DATA_JSON}; + use cowprotocol::order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}; + use cowprotocol::signature::Signature; + use cowprotocol::signing_scheme::SigningScheme; + + let order_data = OrderData { + sell_token: Address::from([0x01; 20]), + buy_token: Address::from([0x02; 20]), + receiver: None, + sell_amount: U256::from(100u64), + buy_amount: U256::from(99u64), + valid_to: u32::MAX, + app_data: EMPTY_APP_DATA_HASH, + fee_amount: U256::ZERO, + kind: OrderKind::Sell, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }; + let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); + let creation = OrderCreation::from_signed_order_data( + &order_data, + signature, + Address::from([0x03; 20]), + EMPTY_APP_DATA_JSON.to_owned(), + None, + ) + .expect("valid OrderCreation"); + serde_json::to_string(&creation).expect("serialise OrderCreation") + } +} diff --git a/crates/nexum-engine/src/host/local_store_redb.rs b/crates/nexum-engine/src/host/local_store_redb.rs new file mode 100644 index 0000000..4e535e0 --- /dev/null +++ b/crates/nexum-engine/src/host/local_store_redb.rs @@ -0,0 +1,211 @@ +//! `nexum:host/local-store` backend. +//! +//! Single redb file under `EngineConfig.engine.state_dir`. Per-module +//! namespacing is enforced host-side via a `[len:u8][module_name][raw_key]` +//! prefix on every redb key. Two modules using the same key string see +//! disjoint data. +//! +//! The runtime supplies the namespace; modules see plain key strings. +//! Module names longer than 255 bytes are rejected at construction +//! (matches the one-byte length prefix). + +// The redb error enum is large by construction (Txn / Storage / +// Commit each carry a redb backtrace ≈ 160 bytes). Allowing the +// cap-on-Result-size lint here is the lesser evil: boxing every +// variant pushes the error path to the heap just to humour the lint. +#![allow(clippy::result_large_err)] + +use std::path::Path; +use std::sync::Arc; + +use redb::{Database, ReadableTable, TableDefinition}; +use thiserror::Error; + +const TABLE: TableDefinition<'static, &[u8], &[u8]> = TableDefinition::new("nexum:local-store"); +const MAX_NAMESPACE_LEN: usize = u8::MAX as usize; + +/// Process-wide handle to the local-store redb database. Cheap to +/// clone; the per-module view is constructed by setting the +/// namespace prefix at call time. +#[derive(Debug, Clone)] +pub struct LocalStore { + db: Arc, +} + +impl LocalStore { + /// Open (or create) the redb file at `path`. Materialises the + /// shared table so subsequent read transactions never hit + /// `TableDoesNotExist`. + pub fn open(path: impl AsRef) -> Result { + let db = Database::create(path).map_err(StorageError::Open)?; + { + let txn = db.begin_write().map_err(StorageError::Txn)?; + txn.open_table(TABLE).map_err(StorageError::Table)?; + txn.commit().map_err(StorageError::Commit)?; + } + Ok(Self { db: Arc::new(db) }) + } + + /// Fetch a value for `(namespace, key)`. Returns `Ok(None)` when + /// no entry exists; module never observes the prefix. + pub fn get(&self, namespace: &str, key: &str) -> Result>, StorageError> { + let full = build_key(namespace, key)?; + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + let value = table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .map(|v| v.value().to_vec()); + Ok(value) + } + + /// Insert or overwrite. + pub fn set(&self, namespace: &str, key: &str, value: &[u8]) -> Result<(), StorageError> { + let full = build_key(namespace, key)?; + let txn = self.db.begin_write().map_err(StorageError::Txn)?; + { + let mut table = txn.open_table(TABLE).map_err(StorageError::Table)?; + table + .insert(full.as_slice(), value) + .map_err(StorageError::Storage)?; + } + txn.commit().map_err(StorageError::Commit)?; + Ok(()) + } + + /// Delete. Idempotent — deleting a missing key is a no-op. + pub fn delete(&self, namespace: &str, key: &str) -> Result<(), StorageError> { + let full = build_key(namespace, key)?; + let txn = self.db.begin_write().map_err(StorageError::Txn)?; + { + let mut table = txn.open_table(TABLE).map_err(StorageError::Table)?; + table + .remove(full.as_slice()) + .map_err(StorageError::Storage)?; + } + txn.commit().map_err(StorageError::Commit)?; + Ok(()) + } + + /// Enumerate keys in `namespace` whose raw key (post-prefix) + /// starts with `prefix`. Returns only the module-visible key + /// strings — the host strips the namespace prefix. + pub fn list_keys(&self, namespace: &str, prefix: &str) -> Result, StorageError> { + let ns_prefix = namespace_prefix(namespace)?; + let full_prefix = build_key(namespace, prefix)?; + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + let mut out = Vec::new(); + for entry in table.iter().map_err(StorageError::Storage)? { + let (k, _v) = entry.map_err(StorageError::Storage)?; + let key_bytes = k.value(); + if key_bytes.starts_with(&full_prefix) + && let Ok(s) = std::str::from_utf8(&key_bytes[ns_prefix.len()..]) + { + out.push(s.to_owned()); + } + } + Ok(out) + } +} + +fn namespace_prefix(namespace: &str) -> Result, StorageError> { + if namespace.is_empty() { + return Err(StorageError::InvalidNamespace( + "module namespace must not be empty".into(), + )); + } + let bytes = namespace.as_bytes(); + if bytes.len() > MAX_NAMESPACE_LEN { + return Err(StorageError::InvalidNamespace(format!( + "namespace `{namespace}` is {} bytes; max is {MAX_NAMESPACE_LEN}", + bytes.len() + ))); + } + let mut out = Vec::with_capacity(1 + bytes.len()); + out.push(bytes.len() as u8); + out.extend_from_slice(bytes); + Ok(out) +} + +fn build_key(namespace: &str, key: &str) -> Result, StorageError> { + let mut out = namespace_prefix(namespace)?; + out.extend_from_slice(key.as_bytes()); + Ok(out) +} + +/// Errors surfaced by [`LocalStore`]. +#[derive(Debug, Error)] +pub enum StorageError { + #[error("open redb: {0}")] + Open(#[source] redb::DatabaseError), + #[error("redb txn: {0}")] + Txn(#[source] redb::TransactionError), + #[error("redb table: {0}")] + Table(#[source] redb::TableError), + #[error("redb storage: {0}")] + Storage(#[source] redb::StorageError), + #[error("redb commit: {0}")] + Commit(#[source] redb::CommitError), + #[error("invalid namespace: {0}")] + InvalidNamespace(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh() -> (tempfile::TempDir, LocalStore) { + let dir = tempfile::tempdir().expect("tempdir"); + let store = LocalStore::open(dir.path().join("ls.redb")).expect("open"); + (dir, store) + } + + #[test] + fn set_get_roundtrip() { + let (_dir, store) = fresh(); + store.set("twap", "k", b"v").unwrap(); + assert_eq!(store.get("twap", "k").unwrap().as_deref(), Some(&b"v"[..])); + } + + #[test] + fn namespaces_isolate_modules() { + let (_dir, store) = fresh(); + store.set("a", "k", b"from-a").unwrap(); + store.set("b", "k", b"from-b").unwrap(); + assert_eq!( + store.get("a", "k").unwrap().as_deref(), + Some(&b"from-a"[..]) + ); + assert_eq!( + store.get("b", "k").unwrap().as_deref(), + Some(&b"from-b"[..]) + ); + } + + #[test] + fn delete_then_get_is_none() { + let (_dir, store) = fresh(); + store.set("twap", "k", b"v").unwrap(); + store.delete("twap", "k").unwrap(); + assert!(store.get("twap", "k").unwrap().is_none()); + } + + #[test] + fn list_keys_strips_namespace_prefix() { + let (_dir, store) = fresh(); + store.set("twap", "posted:1", b"x").unwrap(); + store.set("twap", "posted:2", b"y").unwrap(); + store.set("twap", "other", b"z").unwrap(); + let keys = store.list_keys("twap", "posted:").unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().all(|k| k.starts_with("posted:"))); + } + + #[test] + fn rejects_empty_namespace() { + let (_dir, store) = fresh(); + let err = store.set("", "k", b"v").unwrap_err(); + assert!(matches!(err, StorageError::InvalidNamespace(_))); + } +} diff --git a/crates/nexum-engine/src/host/mod.rs b/crates/nexum-engine/src/host/mod.rs new file mode 100644 index 0000000..b76fe99 --- /dev/null +++ b/crates/nexum-engine/src/host/mod.rs @@ -0,0 +1,12 @@ +//! Host-side backends for the `nexum:host` / `shepherd:cow` +//! interfaces. +//! +//! Each submodule owns one capability. The trait impls in `main.rs` +//! stay thin: they validate inputs, dispatch to the backend, and +//! project the backend's typed error onto the bindgen-generated +//! `HostError`. Keeping the backends pure (no bindgen types) means +//! each can be unit-tested without spinning up a wasmtime store. + +pub mod cow_orderbook; +pub mod local_store_redb; +pub mod provider_pool; diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs new file mode 100644 index 0000000..0b6d94b --- /dev/null +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -0,0 +1,152 @@ +//! `nexum:host/chain` backend. +//! +//! Per-chain alloy provider, opened from the engine config at boot. +//! `request` is a raw JSON-RPC dispatch: the host hands `(method, +//! params)` straight to alloy's transport and returns the result body +//! verbatim. No method allowlist, no re-encoding of params — the +//! contract is "give us a JSON-RPC pair, we'll return what the node +//! returns". +//! +//! Transports: +//! - `ws://` / `wss://` — `WsConnect`; required for `eth_subscribe`. +//! - `http://` / `https://` — alloy's HTTP transport; request/response only. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use alloy_provider::{DynProvider, Provider, ProviderBuilder, WsConnect}; +use serde_json::value::RawValue; +use thiserror::Error; +use tracing::info; + +use crate::engine_config::EngineConfig; + +/// Pool of alloy providers keyed by chain id. +#[derive(Debug, Clone)] +pub struct ProviderPool { + providers: Arc>, +} + +impl ProviderPool { + /// Open one provider per chain in `cfg.chains`. WebSocket URLs + /// engage alloy's pubsub transport; HTTP URLs use the HTTP + /// transport. Connection failures propagate to the caller; the + /// engine treats them as fatal at boot. + pub async fn from_config(cfg: &EngineConfig) -> Result { + let mut providers: BTreeMap = BTreeMap::new(); + for (chain_id, chain_cfg) in &cfg.chains { + let url = chain_cfg.rpc_url.as_str(); + info!(chain_id, url, "opening chain RPC provider"); + let provider = if url.starts_with("ws://") || url.starts_with("wss://") { + ProviderBuilder::new() + .connect_ws(WsConnect::new(url)) + .await + .map_err(|e| ProviderError::Connect { + chain_id: *chain_id, + detail: e.to_string(), + })? + .erased() + } else { + let parsed: url::Url = + url.parse() + .map_err(|e: url::ParseError| ProviderError::Connect { + chain_id: *chain_id, + detail: e.to_string(), + })?; + ProviderBuilder::new().connect_http(parsed).erased() + }; + providers.insert(*chain_id, provider); + } + Ok(Self { + providers: Arc::new(providers), + }) + } + + /// Empty pool — used by tests and as a default when no + /// `engine.toml` is found. Every `request` call returns + /// `UnknownChain`. + #[cfg_attr(not(test), allow(dead_code))] + pub fn empty() -> Self { + Self { + providers: Arc::new(BTreeMap::new()), + } + } + + /// Raw JSON-RPC dispatch. `params_json` must be the JSON encoding + /// of the params array (e.g. `"[\"0x...\",\"latest\"]"`), as + /// produced by the SDK's `chain::request` glue. + pub async fn request( + &self, + chain_id: u64, + method: String, + params_json: String, + ) -> Result { + let provider = self + .providers + .get(&chain_id) + .ok_or(ProviderError::UnknownChain(chain_id))?; + // Pass the params through as a raw JSON value so alloy does + // not re-encode them on the way to the node. + let params: Box = RawValue::from_string(params_json.clone()).map_err(|e| { + ProviderError::InvalidParams { + method: method.clone(), + detail: e.to_string(), + } + })?; + let result: Box = provider + .raw_request(method.clone().into(), params) + .await + .map_err(|e| ProviderError::Rpc { + method, + detail: e.to_string(), + })?; + Ok(result.get().to_owned()) + } +} + +/// Errors surfaced by [`ProviderPool`]. +#[derive(Debug, Error)] +pub enum ProviderError { + /// Chain id absent from the engine config. + #[error("unknown chain {0} (no engine.toml entry)")] + UnknownChain(u64), + /// Could not open the underlying transport. + #[error("connect chain {chain_id}: {detail}")] + Connect { + /// Chain id we failed to dial. + chain_id: u64, + /// Transport-side error string. + detail: String, + }, + /// The guest-supplied JSON params did not parse. + #[error("invalid params JSON for `{method}`: {detail}")] + InvalidParams { + /// RPC method name. + method: String, + /// JSON-parser detail. + detail: String, + }, + /// The node returned an error for the dispatched call. + #[error("rpc `{method}` failed: {detail}")] + Rpc { + /// RPC method name. + method: String, + /// Transport-side error string. + detail: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn empty_pool_rejects_lookups() { + let pool = ProviderPool::empty(); + let err = pool + .request(1, "eth_blockNumber".into(), "[]".into()) + .await + .unwrap_err(); + assert!(matches!(err, ProviderError::UnknownChain(1))); + } +} diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index f013f22..eca6629 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -1,7 +1,12 @@ +mod engine_config; +mod host; mod manifest; use std::path::PathBuf; use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::error::Context as _; use wasmtime::{Engine, Store}; @@ -28,6 +33,15 @@ struct HostState { /// Per-module `[capabilities.http].allow` allowlist (from nexum.toml). /// Consulted by `http::fetch` before any outbound call. http_allowlist: Vec, + /// Namespace for the running module's `local-store` rows. Set from + /// `manifest.module.name` at instantiation. + module_namespace: String, + /// `cow-api` backend — per-chain `OrderBookApi` clients + reqwest. + cow: host::cow_orderbook::OrderBookPool, + /// `chain` backend — per-chain alloy `DynProvider` pool. + chain: host::provider_pool::ProviderPool, + /// `local-store` backend — redb file with host-side namespacing. + store: host::local_store_redb::LocalStore, } impl WasiView for HostState { @@ -49,58 +63,135 @@ fn unimplemented(domain: &str, detail: impl Into) -> HostError { } } -// -- Stub implementations for host interfaces -- +fn internal_error(domain: &str, detail: impl Into) -> HostError { + HostError { + domain: domain.into(), + kind: HostErrorKind::Internal, + code: 0, + message: detail.into(), + data: None, + } +} + +// -- nexum:host/types is empty (declarations only). -- impl nexum::host::types::Host for HostState {} +// -- shepherd:cow/cow-api: REST passthrough + typed submission. -- + impl shepherd::cow::cow_api::Host for HostState { async fn request( &mut self, - _chain_id: u64, + chain_id: u64, method: String, path: String, - _body: Option, + body: Option, ) -> Result { let start = Instant::now(); - eprintln!("[cow-api] {method} {path}"); - let result = Err(unimplemented( - "cow-api", - format!("not implemented: {method} {path}"), - )); - eprintln!("[timing] cow-api::request: {:?}", start.elapsed()); + tracing::debug!(chain_id, %method, %path, "cow-api::request"); + let result = match self + .cow + .request(chain_id, &method, &path, body.as_deref()) + .await + { + Ok(body) => Ok(body), + Err(host::cow_orderbook::CowApiError::UnknownChain(id)) => Err(unimplemented( + "cow-api", + format!("chain {id} not in cowprotocol"), + )), + Err(host::cow_orderbook::CowApiError::BadMethod(m)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: format!("unsupported HTTP method: {m}"), + data: None, + }), + Err(host::cow_orderbook::CowApiError::BadPath(msg)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: msg, + data: None, + }), + Err(err) => Err(internal_error("cow-api", err.to_string())), + }; + tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::request done"); result } async fn submit_order( &mut self, - _chain_id: u64, - _order_data: Vec, + chain_id: u64, + order_data: Vec, ) -> Result { let start = Instant::now(); - eprintln!("[cow-api] submit-order"); - let result = Err(unimplemented("cow-api", "submit-order not implemented")); - eprintln!("[timing] cow-api::submit-order: {:?}", start.elapsed()); + tracing::debug!(chain_id, bytes = order_data.len(), "cow-api::submit-order"); + let result = match self.cow.submit_order_json(chain_id, &order_data).await { + Ok(uid) => Ok(format!("0x{}", hex_encode(uid.as_slice()))), + Err(host::cow_orderbook::CowApiError::UnknownChain(id)) => Err(unimplemented( + "cow-api", + format!("chain {id} not in cowprotocol"), + )), + Err(host::cow_orderbook::CowApiError::Decode(err)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: format!("invalid OrderCreation JSON: {err}"), + data: None, + }), + Err(host::cow_orderbook::CowApiError::Orderbook(msg)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::Denied, + code: 0, + message: msg, + data: None, + }), + Err(err) => Err(internal_error("cow-api", err.to_string())), + }; + tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::submit-order done"); result } } +// -- nexum:host/chain: raw JSON-RPC dispatch over alloy. -- + impl nexum::host::chain::Host for HostState { async fn request( &mut self, - _chain_id: u64, + chain_id: u64, method: String, - _params: String, + params: String, ) -> Result { let start = Instant::now(); - eprintln!("[chain] request: {method}"); - let result = Err(HostError { - domain: "chain".into(), - kind: HostErrorKind::Unsupported, - code: -32601, - message: format!("method not implemented: {method}"), - data: None, - }); - eprintln!("[timing] chain::request: {:?}", start.elapsed()); + tracing::debug!(chain_id, %method, "chain::request"); + let result = match self.chain.request(chain_id, method.clone(), params).await { + Ok(body) => Ok(body), + Err(host::provider_pool::ProviderError::UnknownChain(id)) => Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::Unsupported, + code: 0, + message: format!("chain {id} has no engine.toml RPC entry"), + data: None, + }), + Err(host::provider_pool::ProviderError::InvalidParams { detail, .. }) => { + Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::InvalidInput, + code: -32602, + message: detail, + data: None, + }) + } + Err(host::provider_pool::ProviderError::Rpc { detail, .. }) => Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::Internal, + code: -32603, + message: detail, + data: None, + }), + Err(err) => Err(internal_error("chain", err.to_string())), + }; + tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done"); result } @@ -110,34 +201,30 @@ impl nexum::host::chain::Host for HostState { requests: Vec, ) -> Result, HostError> { let start = Instant::now(); - eprintln!("[chain] request-batch: {} calls", requests.len()); + tracing::debug!(chain_id, count = requests.len(), "chain::request-batch"); let mut out = Vec::with_capacity(requests.len()); for req in requests { - match self.request(chain_id, req.method, req.params).await { + match nexum::host::chain::Host::request(self, chain_id, req.method, req.params).await { Ok(s) => out.push(nexum::host::chain::RpcResult::Ok(s)), Err(e) => out.push(nexum::host::chain::RpcResult::Err(e)), } } - eprintln!("[timing] chain::request-batch: {:?}", start.elapsed()); + tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request-batch done"); Ok(out) } } +// -- nexum:host/identity: deferred to 0.3 (keystore/KMS backend). -- + impl nexum::host::identity::Host for HostState { async fn accounts(&mut self) -> Result>, HostError> { - let start = Instant::now(); - eprintln!("[identity] accounts"); - let result = Ok(vec![]); - eprintln!("[timing] identity::accounts: {:?}", start.elapsed()); - result + // No keystore wired yet — return an empty roster so guests can + // probe-then-skip without erroring. Real keystore lands in 0.3. + Ok(vec![]) } async fn sign(&mut self, _account: Vec, _message: Vec) -> Result, HostError> { - let start = Instant::now(); - eprintln!("[identity] sign"); - let result = Err(unimplemented("identity", "sign not implemented")); - eprintln!("[timing] identity::sign: {:?}", start.elapsed()); - result + Err(unimplemented("identity", "sign requires a keystore (0.3)")) } async fn sign_typed_data( @@ -145,61 +232,54 @@ impl nexum::host::identity::Host for HostState { _account: Vec, _typed_data: String, ) -> Result, HostError> { - let start = Instant::now(); - eprintln!("[identity] sign-typed-data"); - let result = Err(unimplemented("identity", "sign-typed-data not implemented")); - eprintln!("[timing] identity::sign-typed-data: {:?}", start.elapsed()); - result + Err(unimplemented( + "identity", + "sign-typed-data requires a keystore (0.3)", + )) } } +// -- nexum:host/local-store: redb backend with host-side namespacing. -- + impl nexum::host::local_store::Host for HostState { async fn get(&mut self, key: String) -> Result>, HostError> { - let start = Instant::now(); - eprintln!("[local-store] get: {key}"); - let result = Ok(None); - eprintln!("[timing] local-store::get: {:?}", start.elapsed()); - result + self.store + .get(&self.module_namespace, &key) + .map_err(|err| internal_error("local-store", err.to_string())) } - async fn set(&mut self, key: String, _value: Vec) -> Result<(), HostError> { - let start = Instant::now(); - eprintln!("[local-store] set: {key}"); - let result = Ok(()); - eprintln!("[timing] local-store::set: {:?}", start.elapsed()); - result + async fn set(&mut self, key: String, value: Vec) -> Result<(), HostError> { + self.store + .set(&self.module_namespace, &key, &value) + .map_err(|err| internal_error("local-store", err.to_string())) } async fn delete(&mut self, key: String) -> Result<(), HostError> { - let start = Instant::now(); - eprintln!("[local-store] delete: {key}"); - let result = Ok(()); - eprintln!("[timing] local-store::delete: {:?}", start.elapsed()); - result + self.store + .delete(&self.module_namespace, &key) + .map_err(|err| internal_error("local-store", err.to_string())) } async fn list_keys(&mut self, prefix: String) -> Result, HostError> { - let start = Instant::now(); - eprintln!("[local-store] list-keys: {prefix}"); - let result = Ok(vec![]); - eprintln!("[timing] local-store::list-keys: {:?}", start.elapsed()); - result + self.store + .list_keys(&self.module_namespace, &prefix) + .map_err(|err| internal_error("local-store", err.to_string())) } } impl nexum::host::remote_store::Host for HostState { async fn upload(&mut self, _data: Vec) -> Result, HostError> { - let start = Instant::now(); - let result = Err(unimplemented("remote-store", "upload not implemented")); - eprintln!("[timing] remote-store::upload: {:?}", start.elapsed()); - result + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) } async fn download(&mut self, _reference: Vec) -> Result, HostError> { - let start = Instant::now(); - let result = Err(unimplemented("remote-store", "download not implemented")); - eprintln!("[timing] remote-store::download: {:?}", start.elapsed()); - result + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) } async fn read_feed( @@ -207,56 +287,51 @@ impl nexum::host::remote_store::Host for HostState { _owner: Vec, _topic: Vec, ) -> Result>, HostError> { - let start = Instant::now(); - let result = Err(unimplemented("remote-store", "read-feed not implemented")); - eprintln!("[timing] remote-store::read-feed: {:?}", start.elapsed()); - result + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) } async fn write_feed(&mut self, _topic: Vec, _data: Vec) -> Result, HostError> { - let start = Instant::now(); - let result = Err(unimplemented("remote-store", "write-feed not implemented")); - eprintln!("[timing] remote-store::write-feed: {:?}", start.elapsed()); - result + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) } } impl nexum::host::messaging::Host for HostState { - async fn publish(&mut self, content_topic: String, _payload: Vec) -> Result<(), HostError> { - let start = Instant::now(); - eprintln!("[messaging] publish: {content_topic}"); - let result = Err(unimplemented("messaging", "publish not implemented")); - eprintln!("[timing] messaging::publish: {:?}", start.elapsed()); - result + async fn publish( + &mut self, + _content_topic: String, + _payload: Vec, + ) -> Result<(), HostError> { + Err(unimplemented("messaging", "Waku backend deferred to 0.3")) } async fn query( &mut self, - content_topic: String, + _content_topic: String, _start_time: Option, _end_time: Option, _limit: Option, ) -> Result, HostError> { - let start = Instant::now(); - eprintln!("[messaging] query: {content_topic}"); - let result = Ok(vec![]); - eprintln!("[timing] messaging::query: {:?}", start.elapsed()); - result + // Empty result — same posture as `identity::accounts`. + Ok(vec![]) } } impl nexum::host::logging::Host for HostState { async fn log(&mut self, level: nexum::host::logging::Level, message: String) { - let start = Instant::now(); - let level_str = match level { - nexum::host::logging::Level::Trace => "TRACE", - nexum::host::logging::Level::Debug => "DEBUG", - nexum::host::logging::Level::Info => "INFO", - nexum::host::logging::Level::Warn => "WARN", - nexum::host::logging::Level::Error => "ERROR", - }; - eprintln!("[{level_str}] {message}"); - eprintln!("[timing] logging::log: {:?}", start.elapsed()); + let module = self.module_namespace.as_str(); + match level { + nexum::host::logging::Level::Trace => tracing::trace!(module, "{}", message), + nexum::host::logging::Level::Debug => tracing::debug!(module, "{}", message), + nexum::host::logging::Level::Info => tracing::info!(module, "{}", message), + nexum::host::logging::Level::Warn => tracing::warn!(module, "{}", message), + nexum::host::logging::Level::Error => tracing::error!(module, "{}", message), + } } } @@ -292,16 +367,12 @@ impl nexum::host::http::Host for HostState { &mut self, req: nexum::host::http::Request, ) -> Result { - let start = Instant::now(); - eprintln!("[http] {} {}", req.method, req.url); - // Manifest allowlist enforcement runs before any I/O. Hosts that // never link a manifest leave `http_allowlist` empty, which denies // every request — matching the "no implicit network" stance. let host = match manifest::extract_host(&req.url) { Some(h) => h, None => { - eprintln!("[timing] http::fetch: {:?}", start.elapsed()); return Err(HostError { domain: "http".into(), kind: HostErrorKind::InvalidInput, @@ -312,8 +383,7 @@ impl nexum::host::http::Host for HostState { } }; if !manifest::host_allowed(host, &self.http_allowlist) { - eprintln!("[http] denied by allowlist: {host}"); - eprintln!("[timing] http::fetch: {:?}", start.elapsed()); + warn!(host, "[http] denied by allowlist"); return Err(HostError { domain: "http".into(), kind: HostErrorKind::Denied, @@ -325,31 +395,52 @@ impl nexum::host::http::Host for HostState { data: None, }); } - // 0.2: allowlist passed, but the reference runtime does not perform // real HTTP yet. Real fetch lands in 0.3. - let result = Err(unimplemented( + Err(unimplemented( "http", "fetch not implemented in 0.2 reference runtime (allowlist passed)", - )); - eprintln!("[timing] http::fetch: {:?}", start.elapsed()); - result + )) } } +/// Lowercase hex encoder. Kept in the engine binary rather than +/// pulling a `hex` crate just for one call site. +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); let wasm_path = args.next().ok_or_else(|| { - anyhow::anyhow!("usage: nexum-engine []") + anyhow::anyhow!( + "usage: nexum-engine [] []" + ) })?; let explicit_manifest = args.next().map(PathBuf::from); + let explicit_engine_config = args.next().map(PathBuf::from); + + // -- 1. Load engine config (optional). -- + let engine_cfg = engine_config::load_or_default(explicit_engine_config.as_deref())?; - println!("nexum-engine: loading component from {wasm_path}"); + // -- 2. Install tracing subscriber. -- + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) + .unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .init(); - // Load the manifest from the explicit path if given, otherwise from - // `nexum.toml` next to the component file. Missing → fallback (with - // deprecation warning). + info!("nexum-engine starting"); + info!(wasm = %wasm_path, "loading component"); + + // -- 3. Load the module manifest. -- let manifest_path = explicit_manifest.or_else(|| { PathBuf::from(&wasm_path) .parent() @@ -357,20 +448,40 @@ async fn main() -> anyhow::Result<()> { }); let loaded = match manifest_path.as_deref() { Some(p) if p.exists() => { - println!("nexum-engine: loading manifest from {}", p.display()); + info!(manifest = %p.display(), "loading nexum.toml"); manifest::load(p)? } _ => manifest::fallback_manifest(), }; + // -- 4. Bring up the host backends. -- + std::fs::create_dir_all(&engine_cfg.engine.state_dir).with_context(|| { + format!( + "create state directory {}", + engine_cfg.engine.state_dir.display() + ) + })?; + let store_path = engine_cfg.engine.state_dir.join("local-store.redb"); + let local_store = host::local_store_redb::LocalStore::open(&store_path) + .with_context(|| format!("open local-store at {}", store_path.display()))?; + let cow_pool = host::cow_orderbook::OrderBookPool::with_default_chains(); + let provider_pool = host::provider_pool::ProviderPool::from_config(&engine_cfg) + .await + .context("open chain providers")?; + + // -- 5. Build the wasmtime engine + component. -- let mut config = wasmtime::Config::new(); config.wasm_component_model(true); + // `async_support` was deprecated in wasmtime 45 — the engine + // resolves async on its own. Keeping the call out of the Config + // chain silences the `deprecated` warning under + // `RUSTFLAGS=-D warnings`. let engine = Engine::new(&config)?; - let start = Instant::now(); + let load_start = Instant::now(); let component = Component::from_file(&engine, &wasm_path).context("failed to load component")?; - eprintln!("[timing] component load: {:?}", start.elapsed()); + tracing::debug!(elapsed_ms = ?load_start.elapsed(), "component load"); let mut linker = Linker::::new(&engine); Shepherd::add_to_linker::>( @@ -380,6 +491,11 @@ async fn main() -> anyhow::Result<()> { wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; let wasi = WasiCtxBuilder::new().inherit_stdio().build(); + let module_namespace = if loaded.manifest.module.name.is_empty() { + "module".to_owned() + } else { + loaded.manifest.module.name.clone() + }; let mut store = Store::new( &engine, @@ -388,36 +504,39 @@ async fn main() -> anyhow::Result<()> { table: ResourceTable::new(), monotonic_baseline: Instant::now(), http_allowlist: loaded.http_allowlist, + module_namespace, + cow: cow_pool, + chain: provider_pool, + store: local_store, }, ); - let start = Instant::now(); + let inst_start = Instant::now(); let bindings = Shepherd::instantiate_async(&mut store, &component, &linker) .await .context("failed to instantiate component")?; - eprintln!("[timing] component instantiate: {:?}", start.elapsed()); + tracing::debug!(elapsed_ms = ?inst_start.elapsed(), "component instantiate"); - println!("nexum-engine: calling init..."); - // 0.2: [config] is stringly-typed (typed variant deferred to 0.3). - // Fall back to a single ("name", "") pair if the manifest has - // no [config] section so the example module still has something to log. + info!("calling init"); let config_entries: Config = if loaded.config.is_empty() { vec![("name".into(), loaded.manifest.module.name.clone())] } else { loaded.config }; - let start = Instant::now(); + let init_start = Instant::now(); match bindings.call_init(&mut store, &config_entries).await? { - Ok(()) => println!("nexum-engine: init succeeded"), - Err(e) => println!( - "nexum-engine: init failed: {}::{:?} {} ({})", - e.domain, e.kind, e.message, e.code + Ok(()) => info!(elapsed_ms = ?init_start.elapsed(), "init succeeded"), + Err(e) => warn!( + domain = %e.domain, + kind = ?e.kind, + code = e.code, + message = %e.message, + "init failed", ), } - eprintln!("[timing] call_init: {:?}", start.elapsed()); // Dispatch a test block event (timestamps are ms since Unix epoch, UTC). - println!("nexum-engine: dispatching test block event..."); + info!("dispatching test block event"); let block = nexum::host::types::Block { chain_id: 1, number: 19_000_000, @@ -425,16 +544,18 @@ async fn main() -> anyhow::Result<()> { timestamp: 1_700_000_000_000, }; let event = nexum::host::types::Event::Block(block); - let start = Instant::now(); + let evt_start = Instant::now(); match bindings.call_on_event(&mut store, &event).await? { - Ok(()) => println!("nexum-engine: on-event succeeded"), - Err(e) => println!( - "nexum-engine: on-event failed: {}::{:?} {} ({})", - e.domain, e.kind, e.message, e.code + Ok(()) => info!(elapsed_ms = ?evt_start.elapsed(), "on-event succeeded"), + Err(e) => warn!( + domain = %e.domain, + kind = ?e.kind, + code = e.code, + message = %e.message, + "on-event failed", ), } - eprintln!("[timing] call_on_event: {:?}", start.elapsed()); - println!("nexum-engine: done"); + info!("done"); Ok(()) } From 6f669c6d6ae6ef9834330db1a9749e660176cf5d Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 1 Jun 2026 15:43:42 -0300 Subject: [PATCH 03/15] runtime: multi-module supervisor + block/log event loop --- crates/nexum-engine/Cargo.toml | 2 + crates/nexum-engine/src/engine_config.rs | 21 + crates/nexum-engine/src/host/provider_pool.rs | 49 +++ crates/nexum-engine/src/main.rs | 336 ++++++++++----- crates/nexum-engine/src/manifest.rs | 48 +++ crates/nexum-engine/src/supervisor.rs | 387 ++++++++++++++++++ 6 files changed, 745 insertions(+), 98 deletions(-) create mode 100644 crates/nexum-engine/src/supervisor.rs diff --git a/crates/nexum-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml index 637f51f..047abfb 100644 --- a/crates/nexum-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -42,9 +42,11 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # alloy's JSON-RPC layer without reimplementing the codec. alloy-provider = { version = "1.5", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } alloy-rpc-client = { version = "1.5", default-features = false } +alloy-rpc-types-eth = { version = "1.5", default-features = false, features = ["std"] } alloy-transport = { version = "1.5", default-features = false } alloy-transport-ws = { version = "1.5", default-features = false } alloy-primitives = { version = "1.5", default-features = false, features = ["std", "serde"] } +futures = "0.3" # `local-store` backend. Per-module namespacing is enforced # host-side via a `[len:u8][module_name][raw_key]` prefix. diff --git a/crates/nexum-engine/src/engine_config.rs b/crates/nexum-engine/src/engine_config.rs index 4ec271c..595a688 100644 --- a/crates/nexum-engine/src/engine_config.rs +++ b/crates/nexum-engine/src/engine_config.rs @@ -32,6 +32,27 @@ pub struct EngineConfig { /// pool seed. #[serde(default)] pub chains: BTreeMap, + /// Modules the supervisor should boot. Each entry resolves a + /// `(component.wasm, nexum.toml)` pair on the local filesystem + /// for 0.2 — content-addressed resolution (Swarm / OCI / + /// `[[content.sources]]`) lands in 0.3 per + /// `docs/03-module-discovery.md`. + #[serde(default)] + pub modules: Vec, +} + +/// One `[[modules]]` table from `engine.toml`. +/// +/// Both fields are filesystem paths in 0.2. `manifest` defaults to +/// `nexum.toml` next to `path` if omitted, matching the bundle layout +/// in `docs/02-modules-events-packaging.md`. +#[derive(Debug, Deserialize)] +pub struct ModuleEntry { + /// Path to the compiled `.wasm` component. + pub path: std::path::PathBuf, + /// Path to the module's `nexum.toml`. Defaults to `/nexum.toml`. + #[serde(default)] + pub manifest: Option, } #[derive(Debug, Deserialize)] diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs index 0b6d94b..adf589f 100644 --- a/crates/nexum-engine/src/host/provider_pool.rs +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -12,9 +12,13 @@ //! - `http://` / `https://` — alloy's HTTP transport; request/response only. use std::collections::BTreeMap; +use std::pin::Pin; use std::sync::Arc; use alloy_provider::{DynProvider, Provider, ProviderBuilder, WsConnect}; +use alloy_rpc_types_eth::{Filter, Header, Log}; +use futures::stream::Stream; +use futures::stream::StreamExt as _; use serde_json::value::RawValue; use thiserror::Error; use tracing::info; @@ -72,6 +76,46 @@ impl ProviderPool { } } + /// Open a new-blocks (`eth_subscribe newHeads`) stream on + /// `chain_id`. Requires a WS / IPC transport at construction + /// time; HTTP-only providers surface `UnknownChain` here. + pub async fn subscribe_blocks(&self, chain_id: u64) -> Result { + let provider = self + .providers + .get(&chain_id) + .ok_or(ProviderError::UnknownChain(chain_id))?; + let sub = provider + .subscribe_blocks() + .await + .map_err(|e| ProviderError::Rpc { + method: "eth_subscribe(newHeads)".into(), + detail: e.to_string(), + })?; + let stream = sub.into_stream().map(Ok::<_, ProviderError>); + Ok(Box::pin(stream)) + } + + /// Open an `eth_subscribe(logs, filter)` stream on `chain_id`. + pub async fn subscribe_logs( + &self, + chain_id: u64, + filter: Filter, + ) -> Result { + let provider = self + .providers + .get(&chain_id) + .ok_or(ProviderError::UnknownChain(chain_id))?; + let sub = provider + .subscribe_logs(&filter) + .await + .map_err(|e| ProviderError::Rpc { + method: "eth_subscribe(logs)".into(), + detail: e.to_string(), + })?; + let stream = sub.into_stream().map(Ok::<_, ProviderError>); + Ok(Box::pin(stream)) + } + /// Raw JSON-RPC dispatch. `params_json` must be the JSON encoding /// of the params array (e.g. `"[\"0x...\",\"latest\"]"`), as /// produced by the SDK's `chain::request` glue. @@ -104,6 +148,11 @@ impl ProviderPool { } } +/// Boxed stream of `newHeads`-style block headers. +pub type BlockStream = Pin> + Send>>; +/// Boxed stream of `logs`-filtered log events. +pub type LogStream = Pin> + Send>>; + /// Errors surfaced by [`ProviderPool`]. #[derive(Debug, Error)] pub enum ProviderError { diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index eca6629..0602ccc 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -1,16 +1,19 @@ mod engine_config; mod host; mod manifest; +mod supervisor; use std::path::PathBuf; use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use futures::StreamExt; +use futures::stream::{FuturesUnordered, select_all}; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; -use wasmtime::component::{Component, Linker, ResourceTable}; +use wasmtime::Engine; +use wasmtime::component::{Linker, ResourceTable}; use wasmtime::error::Context as _; -use wasmtime::{Engine, Store}; -use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; // Both packages are listed explicitly so wit-parser can resolve the // cross-package reference natively — no vendored deps/ tree needed. @@ -416,19 +419,16 @@ fn hex_encode(bytes: &[u8]) -> String { #[tokio::main] async fn main() -> anyhow::Result<()> { - let mut args = std::env::args().skip(1); - let wasm_path = args.next().ok_or_else(|| { - anyhow::anyhow!( - "usage: nexum-engine [] []" - ) - })?; - let explicit_manifest = args.next().map(PathBuf::from); - let explicit_engine_config = args.next().map(PathBuf::from); + // CLI args: + // nexum-engine [ []] [--engine-config ] + // + // Positional `` is a backwards-compat shortcut that + // synthesises a one-module engine config. Production deployments + // pass `--engine-config` and declare modules in TOML. + let cli = Cli::parse(); - // -- 1. Load engine config (optional). -- - let engine_cfg = engine_config::load_or_default(explicit_engine_config.as_deref())?; + let engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; - // -- 2. Install tracing subscriber. -- let env_filter = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) .unwrap_or_else(|_| EnvFilter::new("info")); @@ -438,23 +438,8 @@ async fn main() -> anyhow::Result<()> { .init(); info!("nexum-engine starting"); - info!(wasm = %wasm_path, "loading component"); - - // -- 3. Load the module manifest. -- - let manifest_path = explicit_manifest.or_else(|| { - PathBuf::from(&wasm_path) - .parent() - .map(|p| p.join("nexum.toml")) - }); - let loaded = match manifest_path.as_deref() { - Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading nexum.toml"); - manifest::load(p)? - } - _ => manifest::fallback_manifest(), - }; - // -- 4. Bring up the host backends. -- + // Bring up shared host backends. std::fs::create_dir_all(&engine_cfg.engine.state_dir).with_context(|| { format!( "create state directory {}", @@ -469,20 +454,11 @@ async fn main() -> anyhow::Result<()> { .await .context("open chain providers")?; - // -- 5. Build the wasmtime engine + component. -- + // wasmtime engine + linker — one of each, shared across modules. let mut config = wasmtime::Config::new(); config.wasm_component_model(true); - // `async_support` was deprecated in wasmtime 45 — the engine - // resolves async on its own. Keeping the call out of the Config - // chain silences the `deprecated` warning under - // `RUSTFLAGS=-D warnings`. let engine = Engine::new(&config)?; - let load_start = Instant::now(); - let component = - Component::from_file(&engine, &wasm_path).context("failed to load component")?; - tracing::debug!(elapsed_ms = ?load_start.elapsed(), "component load"); - let mut linker = Linker::::new(&engine); Shepherd::add_to_linker::>( &mut linker, @@ -490,72 +466,236 @@ async fn main() -> anyhow::Result<()> { )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - let wasi = WasiCtxBuilder::new().inherit_stdio().build(); - let module_namespace = if loaded.manifest.module.name.is_empty() { - "module".to_owned() + // Boot supervisor — `engine.toml.[[modules]]` first, CLI + // positional second. + let mut supervisor = if let Some(wasm) = cli.wasm.as_deref() { + if !engine_cfg.modules.is_empty() { + warn!("ignoring engine.toml [[modules]] because a positional was given"); + } + supervisor::Supervisor::boot_single( + &engine, + &linker, + wasm, + cli.manifest.as_deref(), + &cow_pool, + &provider_pool, + &local_store, + ) + .await? + } else if !engine_cfg.modules.is_empty() { + supervisor::Supervisor::boot( + &engine, + &linker, + &engine_cfg, + &cow_pool, + &provider_pool, + &local_store, + ) + .await? } else { - loaded.manifest.module.name.clone() + anyhow::bail!( + "no modules to run — either pass a positional or declare \ + [[modules]] entries in engine.toml" + ); }; - let mut store = Store::new( - &engine, - HostState { - wasi, - table: ResourceTable::new(), - monotonic_baseline: Instant::now(), - http_allowlist: loaded.http_allowlist, - module_namespace, - cow: cow_pool, - chain: provider_pool, - store: local_store, - }, + info!( + modules = supervisor.module_count(), + chains = supervisor.block_chains().len(), + "supervisor ready" ); - let inst_start = Instant::now(); - let bindings = Shepherd::instantiate_async(&mut store, &component, &linker) - .await - .context("failed to instantiate component")?; - tracing::debug!(elapsed_ms = ?inst_start.elapsed(), "component instantiate"); + // Open per-chain block subscriptions + per-module log + // subscriptions, merge, dispatch until shutdown. + let block_chains = supervisor.block_chains(); + let log_subs = supervisor.log_subscriptions(); - info!("calling init"); - let config_entries: Config = if loaded.config.is_empty() { - vec![("name".into(), loaded.manifest.module.name.clone())] - } else { - loaded.config - }; - let init_start = Instant::now(); - match bindings.call_init(&mut store, &config_entries).await? { - Ok(()) => info!(elapsed_ms = ?init_start.elapsed(), "init succeeded"), - Err(e) => warn!( - domain = %e.domain, - kind = ?e.kind, - code = e.code, - message = %e.message, - "init failed", - ), - } - - // Dispatch a test block event (timestamps are ms since Unix epoch, UTC). - info!("dispatching test block event"); - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - let event = nexum::host::types::Event::Block(block); - let evt_start = Instant::now(); - match bindings.call_on_event(&mut store, &event).await? { - Ok(()) => info!(elapsed_ms = ?evt_start.elapsed(), "on-event succeeded"), - Err(e) => warn!( - domain = %e.domain, - kind = ?e.kind, - code = e.code, - message = %e.message, - "on-event failed", - ), + if block_chains.is_empty() && log_subs.is_empty() { + info!("no [[subscription]] entries — engine has nothing to run; exiting"); + return Ok(()); } + let block_streams = open_block_streams(&provider_pool, &block_chains).await; + let log_streams = open_log_streams(&provider_pool, log_subs).await; + + let shutdown = async { + match wait_for_shutdown_signal().await { + Ok(name) => info!(signal = %name, "shutdown signal received"), + Err(err) => warn!(error = %err, "signal handler failed — using ctrl-c"), + } + }; + + run_event_loop(&mut supervisor, block_streams, log_streams, shutdown).await; info!("done"); Ok(()) } + +/// Parsed CLI surface. +#[derive(Debug, Default)] +struct Cli { + wasm: Option, + manifest: Option, + engine_config: Option, +} + +impl Cli { + fn parse() -> Self { + let mut args = std::env::args().skip(1); + let mut cli = Self::default(); + let mut positional = Vec::new(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--engine-config" => cli.engine_config = args.next().map(PathBuf::from), + "-h" | "--help" => { + eprintln!( + "usage: nexum-engine [ []] \ + [--engine-config ]" + ); + std::process::exit(0); + } + _ => positional.push(arg), + } + } + if let Some(p) = positional.first() { + cli.wasm = Some(PathBuf::from(p)); + } + if let Some(p) = positional.get(1) { + cli.manifest = Some(PathBuf::from(p)); + } + cli + } +} + +/// Per-chain block subscriptions, one shared stream per chain id. +async fn open_block_streams( + pool: &host::provider_pool::ProviderPool, + chains: &std::collections::BTreeSet, +) -> Vec { + let mut openings: FuturesUnordered<_> = chains + .iter() + .copied() + .map(|chain_id| async move { (chain_id, pool.subscribe_blocks(chain_id).await) }) + .collect(); + + let mut streams = Vec::new(); + while let Some((chain_id, result)) = openings.next().await { + match result { + Ok(stream) => { + info!(chain_id, "block subscription open"); + let tagged: TaggedBlockStream = Box::pin(stream.map(move |item| { + item.map(|header| (chain_id, header)) + .map_err(anyhow::Error::from) + })); + streams.push(tagged); + } + Err(err) => { + warn!(chain_id, error = %err, "block subscription failed"); + } + } + } + streams +} + +/// Per-module log subscriptions. Each entry is a stream tagged with +/// the owning module name + chain id. +async fn open_log_streams( + pool: &host::provider_pool::ProviderPool, + subs: Vec<(String, u64, alloy_rpc_types_eth::Filter)>, +) -> Vec { + let mut openings: FuturesUnordered<_> = subs + .into_iter() + .map(|(module, chain_id, filter)| async move { + let stream = pool.subscribe_logs(chain_id, filter).await; + (module, chain_id, stream) + }) + .collect(); + + let mut streams = Vec::new(); + while let Some((module, chain_id, result)) = openings.next().await { + match result { + Ok(stream) => { + info!(module = %module, chain_id, "log subscription open"); + let module_name = module.clone(); + let tagged: TaggedLogStream = Box::pin(stream.map(move |item| { + item.map(|log| (module_name.clone(), chain_id, log)) + .map_err(anyhow::Error::from) + })); + streams.push(tagged); + } + Err(err) => { + warn!(module = %module, chain_id, error = %err, "log subscription failed"); + } + } + } + streams +} + +type TaggedBlockStream = std::pin::Pin< + Box< + dyn futures::Stream> + + Send, + >, +>; +type TaggedLogStream = std::pin::Pin< + Box< + dyn futures::Stream> + + Send, + >, +>; + +/// Drive the supervisor with events until `shutdown` resolves. +async fn run_event_loop( + supervisor: &mut supervisor::Supervisor, + block_streams: Vec, + log_streams: Vec, + shutdown: impl std::future::Future + Send, +) { + let mut blocks = select_all(block_streams); + let mut logs = select_all(log_streams); + let mut shutdown = Box::pin(shutdown); + loop { + tokio::select! { + biased; + () = &mut shutdown => return, + next = blocks.next() => match next { + Some(Ok((chain_id, header))) => { + let block = nexum::host::types::Block { + chain_id, + number: header.number, + hash: header.hash.as_slice().to_vec(), + timestamp: header.timestamp.saturating_mul(1000), + }; + supervisor.dispatch_block(block).await; + } + Some(Err(err)) => warn!(error = %err, "block stream error — continuing"), + None => {} + }, + next = logs.next() => match next { + Some(Ok((module, chain_id, log))) => { + supervisor.dispatch_log(&module, chain_id, log).await; + } + Some(Err(err)) => warn!(error = %err, "log stream error — continuing"), + None => {} + }, + } + } +} + +/// Wait for SIGINT or (on Unix) SIGTERM, whichever arrives first. +async fn wait_for_shutdown_signal() -> anyhow::Result<&'static str> { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = signal(SignalKind::terminate())?; + let mut sigint = signal(SignalKind::interrupt())?; + tokio::select! { + _ = sigterm.recv() => Ok("SIGTERM"), + _ = sigint.recv() => Ok("SIGINT"), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await?; + Ok("ctrl-c") + } +} diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index 522e168..2ce576a 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -47,6 +47,54 @@ pub struct Manifest { pub capabilities: Option, #[serde(default)] pub config: toml::Table, + /// Event subscriptions the runtime wires before calling + /// `_init`. See `docs/02-modules-events-packaging.md` for the + /// schema; 0.2 implements `block` and `log` kinds, `cron` is + /// parsed and ignored (deferred to 0.3). + #[serde(default, rename = "subscription")] + pub subscriptions: Vec, +} + +/// One `[[subscription]]` table in `nexum.toml`. +/// +/// The discriminator is the `kind` field; remaining fields are +/// validated per-kind by the supervisor. Unknown kinds are surfaced +/// at load time so a typo does not silently disable an event source. +#[derive(Debug, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Subscription { + /// New-block events. Fan-out is shared per chain — the + /// supervisor opens one subscription per chain id and routes to + /// every module that asked for blocks on that chain. + Block { + /// EVM chain id. + chain_id: u64, + }, + /// Log events matching `address` + topic-0. Fan-out is + /// per-module — the supervisor opens one subscription per + /// `[[subscription]]` entry and tags emitted events with the + /// owning module. + Log { + /// EVM chain id. + chain_id: u64, + /// Contract address as `0x`-prefixed 20-byte hex. Optional. + #[serde(default)] + address: Option, + /// Topic-0 of the event the module wants to consume. `0x`- + /// prefixed 32-byte hex. Optional — when absent the + /// subscription matches every event from the address(es). + #[serde(default)] + event_signature: Option, + }, + /// Cron-scheduled tick. 0.2 parses but does not dispatch; the + /// supervisor emits a warning so the operator knows the + /// declaration is currently inert. `schedule` is preserved so a + /// 0.3 dispatcher can pick it up without re-parsing the manifest. + Cron { + /// Standard 5-field cron expression. + #[allow(dead_code)] + schedule: String, + }, } #[derive(Debug, Deserialize, Default)] diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs new file mode 100644 index 0000000..18c2fa4 --- /dev/null +++ b/crates/nexum-engine/src/supervisor.rs @@ -0,0 +1,387 @@ +//! Multi-module supervisor. +//! +//! Loads every `[[modules]]` entry from `engine.toml`, instantiates +//! each as a `Shepherd` bindings against a dedicated wasmtime +//! `Store`, and routes the event types declared in each manifest's +//! `[[subscription]]` table. +//! +//! 0.2 dispatches a block event to every module that subscribed to +//! that chain's blocks, and a log event only to the module that +//! opened the subscription. Restart + poison-pill bookkeeping ships +//! in a follow-up — for now a failing `_on_event` is logged via +//! `tracing::error!` and the module continues to receive subsequent +//! events. Lifecycle states `Restart` / `Dead` from +//! `docs/02-modules-events-packaging.md` land alongside the +//! `[module.restart]` schema in 0.3. + +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{Context, Error, Result, anyhow}; +use tracing::{error, info, warn}; +use wasmtime::component::{Component, Linker, ResourceTable}; +use wasmtime::{Engine, Store}; +use wasmtime_wasi::WasiCtxBuilder; + +use crate::engine_config::{EngineConfig, ModuleEntry}; +use crate::host::cow_orderbook::OrderBookPool; +use crate::host::local_store_redb::LocalStore; +use crate::host::provider_pool::ProviderPool; +use crate::manifest::{self, LoadedManifest, Subscription}; +use crate::{HostState, Shepherd}; + +/// Owns every loaded module and exposes the dispatch surface the +/// event loop needs. +pub struct Supervisor { + modules: Vec, +} + +struct LoadedModule { + name: String, + bindings: Shepherd, + store: Store, + /// Subscriptions copied from `nexum.toml`. The supervisor reads + /// these on every event to decide whether to dispatch. + subscriptions: Vec, +} + +impl Supervisor { + /// Compile + instantiate every module declared in + /// `engine_cfg.modules`. The wasmtime `Engine` + `Linker` are + /// passed in so `main.rs` can build them once (the bindgen + /// `Shepherd::add_to_linker` call binds them to `HostState`, + /// which the supervisor does not re-derive). + pub async fn boot( + engine: &Engine, + linker: &Linker, + engine_cfg: &EngineConfig, + cow_pool: &OrderBookPool, + provider_pool: &ProviderPool, + local_store: &LocalStore, + ) -> Result { + let mut modules = Vec::with_capacity(engine_cfg.modules.len()); + for entry in &engine_cfg.modules { + let loaded = + Self::load_one(engine, linker, entry, cow_pool, provider_pool, local_store) + .await + .with_context(|| format!("load module {}", entry.path.display()))?; + modules.push(loaded); + } + info!(count = modules.len(), "supervisor up"); + Ok(Self { modules }) + } + + /// One-shot construction from a single ad-hoc `(component, manifest)` + /// pair. Used by the CLI-positional invocation so `just run` + /// against the example module keeps working without an + /// `engine.toml`. + pub async fn boot_single( + engine: &Engine, + linker: &Linker, + wasm: &Path, + manifest: Option<&Path>, + cow_pool: &OrderBookPool, + provider_pool: &ProviderPool, + local_store: &LocalStore, + ) -> Result { + let entry = ModuleEntry { + path: wasm.to_path_buf(), + manifest: manifest.map(Path::to_path_buf), + }; + let loaded = + Self::load_one(engine, linker, &entry, cow_pool, provider_pool, local_store).await?; + Ok(Self { + modules: vec![loaded], + }) + } + + async fn load_one( + engine: &Engine, + linker: &Linker, + entry: &ModuleEntry, + cow_pool: &OrderBookPool, + provider_pool: &ProviderPool, + local_store: &LocalStore, + ) -> Result { + let manifest_path = entry + .manifest + .clone() + .or_else(|| entry.path.parent().map(|p| p.join("nexum.toml"))); + let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { + Some(p) if p.exists() => { + info!(manifest = %p.display(), "loading nexum.toml"); + manifest::load(p)? + } + _ => { + warn!( + component = %entry.path.display(), + "no nexum.toml — falling back to anonymous module" + ); + manifest::fallback_manifest() + } + }; + + // Compile + instantiate. + info!(component = %entry.path.display(), "compiling component"); + let component = Component::from_file(engine, &entry.path) + .map_err(Error::from) + .with_context(|| format!("compile {}", entry.path.display()))?; + let wasi = WasiCtxBuilder::new().inherit_stdio().build(); + let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "module".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + let mut store = Store::new( + engine, + HostState { + wasi, + table: ResourceTable::new(), + monotonic_baseline: std::time::Instant::now(), + http_allowlist: loaded_manifest.http_allowlist.clone(), + module_namespace: module_namespace.clone(), + cow: cow_pool.clone(), + chain: provider_pool.clone(), + store: local_store.clone(), + }, + ); + let bindings = Shepherd::instantiate_async(&mut store, &component, linker) + .await + .map_err(Error::from) + .with_context(|| format!("instantiate {}", entry.path.display()))?; + + // Call `init` with the manifest's `[config]`. + let config: crate::Config = if loaded_manifest.config.is_empty() { + vec![("name".into(), module_namespace.clone())] + } else { + loaded_manifest.config.clone() + }; + match bindings + .call_init(&mut store, &config) + .await + .map_err(Error::from)? + { + Ok(()) => info!(module = %module_namespace, "init succeeded"), + Err(e) => warn!( + module = %module_namespace, + domain = %e.domain, + kind = ?e.kind, + code = e.code, + message = %e.message, + "init failed", + ), + } + + // Surface any `[[subscription]]` entries the host cannot + // service yet, so an operator running 0.2 against a 0.3 + // manifest does not silently drop events. + for sub in &loaded_manifest.manifest.subscriptions { + if matches!(sub, Subscription::Cron { .. }) { + warn!( + module = %module_namespace, + "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", + ); + } + } + + Ok(LoadedModule { + name: module_namespace, + bindings, + store, + subscriptions: loaded_manifest.manifest.subscriptions.clone(), + }) + } + + /// Number of modules currently loaded. + pub fn module_count(&self) -> usize { + self.modules.len() + } + + /// Set of chain ids any module asked for block events on. The + /// caller opens one shared block subscription per chain id and + /// routes through `dispatch_block`. + pub fn block_chains(&self) -> BTreeSet { + let mut out = BTreeSet::new(); + for module in &self.modules { + for sub in &module.subscriptions { + if let Subscription::Block { chain_id } = sub { + out.insert(*chain_id); + } + } + } + out + } + + /// Per-module log subscriptions. Each entry is a `(module_name, + /// chain_id, filter)` triple the event loop opens against the + /// matching alloy provider; the resulting stream tags every log + /// with `module_name` so `dispatch_log` routes correctly. + pub fn log_subscriptions(&self) -> Vec<(String, u64, alloy_rpc_types_eth::Filter)> { + let mut out = Vec::new(); + for module in &self.modules { + for sub in &module.subscriptions { + if let Subscription::Log { + chain_id, + address, + event_signature, + } = sub + { + match build_alloy_filter(address.as_deref(), event_signature.as_deref()) { + Ok(filter) => out.push((module.name.clone(), *chain_id, filter)), + Err(err) => warn!( + module = %module.name, + chain_id, + error = %err, + "invalid log subscription — skipping", + ), + } + } + } + } + out + } + + /// Dispatch a block event to every module subscribed to + /// `block.chain_id`. Returns the number of modules invoked. + pub async fn dispatch_block(&mut self, block: crate::nexum::host::types::Block) -> usize { + let event = crate::nexum::host::types::Event::Block(block); + let chain_id = match &event { + crate::nexum::host::types::Event::Block(b) => b.chain_id, + _ => unreachable!(), + }; + let mut dispatched = 0; + for module in &mut self.modules { + let subscribed = module + .subscriptions + .iter() + .any(|s| matches!(s, Subscription::Block { chain_id: cid } if *cid == chain_id)); + if !subscribed { + continue; + } + match module + .bindings + .call_on_event(&mut module.store, &event) + .await + { + Ok(Ok(())) => dispatched += 1, + Ok(Err(host_err)) => warn!( + module = %module.name, + chain_id, + domain = %host_err.domain, + kind = ?host_err.kind, + message = %host_err.message, + "on-event returned host-error", + ), + Err(trap) => error!( + module = %module.name, + chain_id, + error = %trap, + "on-event trapped", + ), + } + } + dispatched + } + + /// Dispatch a log event to the specific module that opened the + /// subscription. Returns `true` when the module accepted the + /// dispatch; `false` when the module was not found or its + /// callback failed. + pub async fn dispatch_log( + &mut self, + module_name: &str, + chain_id: u64, + log: alloy_rpc_types_eth::Log, + ) -> bool { + let target = match self.modules.iter_mut().find(|m| m.name == module_name) { + Some(m) => m, + None => { + warn!(module = %module_name, "no such module — dropping log"); + return false; + } + }; + let event = crate::nexum::host::types::Event::Logs(vec![project_log(chain_id, &log)]); + match target + .bindings + .call_on_event(&mut target.store, &event) + .await + { + Ok(Ok(())) => true, + Ok(Err(host_err)) => { + warn!( + module = %module_name, + chain_id, + domain = %host_err.domain, + kind = ?host_err.kind, + message = %host_err.message, + "on-event returned host-error", + ); + false + } + Err(trap) => { + error!( + module = %module_name, + chain_id, + error = %trap, + "on-event trapped", + ); + false + } + } + } +} + +/// Project an alloy `Log` onto the WIT `log` record. The chain id +/// is not on the alloy log (the subscription context carries it), +/// so we receive it alongside. +fn project_log(chain_id: u64, log: &alloy_rpc_types_eth::Log) -> crate::nexum::host::types::Log { + crate::nexum::host::types::Log { + chain_id, + address: log.address().as_slice().to_vec(), + topics: log.topics().iter().map(|t| t.as_slice().to_vec()).collect(), + data: log.inner.data.data.to_vec(), + block_number: log.block_number.unwrap_or(0), + transaction_hash: log + .transaction_hash + .map(|h| h.as_slice().to_vec()) + .unwrap_or_default(), + log_index: log.log_index.unwrap_or(0) as u32, + } +} + +/// Translate a `[[subscription]]` log entry into an alloy `Filter`. +fn build_alloy_filter( + address: Option<&str>, + event_signature: Option<&str>, +) -> Result { + use alloy_primitives::{Address, B256}; + let mut filter = alloy_rpc_types_eth::Filter::new(); + if let Some(addr_hex) = address { + let addr: Address = addr_hex + .parse() + .map_err(|e| anyhow!("invalid log address {addr_hex:?}: {e}"))?; + filter = filter.address(addr); + } + if let Some(topic_hex) = event_signature { + let topic: B256 = topic_hex + .parse() + .map_err(|e| anyhow!("invalid topic {topic_hex:?}: {e}"))?; + filter = filter.event_signature(topic); + } + Ok(filter) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_supervisor_returns_no_subscriptions() { + let sup = Supervisor { + modules: Vec::new(), + }; + assert!(sup.block_chains().is_empty()); + assert!(sup.log_subscriptions().is_empty()); + assert_eq!(sup.module_count(), 0); + } +} From ed4831959eaf075389a6a2d4e283c2f7feb327ee Mon Sep 17 00:00:00 2001 From: brunota20 Date: Tue, 9 Jun 2026 17:59:38 -0300 Subject: [PATCH 04/15] feat(supervisor): apply ADR-0001/0003/0005/0016 and trap-based module death (BLEU-813-817) --- crates/nexum-engine/src/engine_config.rs | 8 +- crates/nexum-engine/src/host/cow_orderbook.rs | 21 +- .../nexum-engine/src/host/local_store_redb.rs | 83 +++++--- crates/nexum-engine/src/main.rs | 6 +- crates/nexum-engine/src/manifest.rs | 190 ++++++++++++++++-- crates/nexum-engine/src/supervisor.rs | 90 ++++++--- 6 files changed, 311 insertions(+), 87 deletions(-) diff --git a/crates/nexum-engine/src/engine_config.rs b/crates/nexum-engine/src/engine_config.rs index 595a688..1246850 100644 --- a/crates/nexum-engine/src/engine_config.rs +++ b/crates/nexum-engine/src/engine_config.rs @@ -1,6 +1,6 @@ //! Engine-side runtime configuration. //! -//! Distinct from `nexum.toml` (module manifest): this file describes +//! Distinct from `module.toml` (module manifest): this file describes //! the *engine*'s I/O wiring — chain RPC endpoints and the on-disk //! location of the `local-store` database. Both are required for the //! 0.2 reference engine to do anything other than print stubs. @@ -33,7 +33,7 @@ pub struct EngineConfig { #[serde(default)] pub chains: BTreeMap, /// Modules the supervisor should boot. Each entry resolves a - /// `(component.wasm, nexum.toml)` pair on the local filesystem + /// `(component.wasm, module.toml)` pair on the local filesystem /// for 0.2 — content-addressed resolution (Swarm / OCI / /// `[[content.sources]]`) lands in 0.3 per /// `docs/03-module-discovery.md`. @@ -44,13 +44,13 @@ pub struct EngineConfig { /// One `[[modules]]` table from `engine.toml`. /// /// Both fields are filesystem paths in 0.2. `manifest` defaults to -/// `nexum.toml` next to `path` if omitted, matching the bundle layout +/// `module.toml` next to `path` if omitted, matching the bundle layout /// in `docs/02-modules-events-packaging.md`. #[derive(Debug, Deserialize)] pub struct ModuleEntry { /// Path to the compiled `.wasm` component. pub path: std::path::PathBuf, - /// Path to the module's `nexum.toml`. Defaults to `/nexum.toml`. + /// Path to the module's `module.toml`. Defaults to `/module.toml`. #[serde(default)] pub manifest: Option, } diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs index cd11173..227efd1 100644 --- a/crates/nexum-engine/src/host/cow_orderbook.rs +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -28,12 +28,12 @@ pub struct OrderBookPool { http: reqwest::Client, } -impl OrderBookPool { - /// Build a pool covering every `cowprotocol::Chain` variant. The - /// default `OrderBookApi::new(chain)` constructor uses the canonical - /// `api.cow.fi/{slug}/api/v1` base URL from the SDK; callers that - /// need barn or a custom staging URL override per chain. - pub fn with_default_chains() -> Self { +impl Default for OrderBookPool { + /// Build a pool covering every `cowprotocol::Chain` variant. Each entry + /// uses the canonical `api.cow.fi/{slug}/api/v1` base URL from the SDK. + /// Override individual entries via `OrderBookApi::new_with_base_url` for + /// barn or staging targets. + fn default() -> Self { let http = reqwest::Client::new(); let chains = [ Chain::Mainnet, @@ -48,6 +48,9 @@ impl OrderBookPool { .collect(); Self { clients, http } } +} + +impl OrderBookPool { /// Look up the client for a chain. pub fn get(&self, chain_id: u64) -> Result<&OrderBookApi, CowApiError> { @@ -145,7 +148,7 @@ mod tests { #[test] fn pool_indexes_default_chains() { - let pool = OrderBookPool::with_default_chains(); + let pool = OrderBookPool::default(); assert!(pool.get(1).is_ok(), "mainnet present"); assert!(pool.get(100).is_ok(), "gnosis present"); assert!(pool.get(11_155_111).is_ok(), "sepolia present"); @@ -155,7 +158,7 @@ mod tests { #[test] fn unknown_chain_surfaces_typed_error() { - let pool = OrderBookPool::with_default_chains(); + let pool = OrderBookPool::default(); assert!(matches!( pool.get(99_999), Err(CowApiError::UnknownChain(99_999)) @@ -224,7 +227,7 @@ mod tests { #[tokio::test] async fn request_rejects_unknown_method() { - let pool = OrderBookPool::with_default_chains(); + let pool = OrderBookPool::default(); let err = pool .request(Chain::Mainnet.id(), "PATCH", "/x", None) .await diff --git a/crates/nexum-engine/src/host/local_store_redb.rs b/crates/nexum-engine/src/host/local_store_redb.rs index 4e535e0..46d980e 100644 --- a/crates/nexum-engine/src/host/local_store_redb.rs +++ b/crates/nexum-engine/src/host/local_store_redb.rs @@ -1,41 +1,43 @@ //! `nexum:host/local-store` backend. //! //! Single redb file under `EngineConfig.engine.state_dir`. Per-module -//! namespacing is enforced host-side via a `[len:u8][module_name][raw_key]` -//! prefix on every redb key. Two modules using the same key string see -//! disjoint data. +//! namespacing is enforced host-side via a fixed-length 32-byte prefix: +//! `keccak256(module_name) ++ raw_key`. Two modules using the same key +//! string see disjoint data regardless of how similar their names are. //! -//! The runtime supplies the namespace; modules see plain key strings. -//! Module names longer than 255 bytes are rejected at construction -//! (matches the one-byte length prefix). - -// The redb error enum is large by construction (Txn / Storage / -// Commit each carry a redb backtrace ≈ 160 bytes). Allowing the -// cap-on-Result-size lint here is the lesser evil: boxing every -// variant pushes the error path to the heap just to humour the lint. +//! The 32-byte hash prefix has two properties that the old +//! `[len:u8][name][key]` scheme lacked: +//! +//! - **Fixed width** — no length field to forge; a module cannot craft a +//! key that bleeds into another module's prefix range. +//! - **ENS-compatible** — keccak256 is the same hash used by ENS node +//! derivation, so module identities can be derived from ENS names +//! without extra hashing in the future (ADR-0003). + #![allow(clippy::result_large_err)] use std::path::Path; use std::sync::Arc; +use alloy_primitives::keccak256; use redb::{Database, ReadableTable, TableDefinition}; use thiserror::Error; const TABLE: TableDefinition<'static, &[u8], &[u8]> = TableDefinition::new("nexum:local-store"); -const MAX_NAMESPACE_LEN: usize = u8::MAX as usize; +#[cfg(test)] +const PREFIX_LEN: usize = 32; /// Process-wide handle to the local-store redb database. Cheap to -/// clone; the per-module view is constructed by setting the -/// namespace prefix at call time. +/// clone; the per-module view is constructed by setting the namespace +/// prefix at call time. #[derive(Debug, Clone)] pub struct LocalStore { db: Arc, } impl LocalStore { - /// Open (or create) the redb file at `path`. Materialises the - /// shared table so subsequent read transactions never hit - /// `TableDoesNotExist`. + /// Open (or create) the redb file at `path`. Materialises the shared + /// table so subsequent read transactions never hit `TableDoesNotExist`. pub fn open(path: impl AsRef) -> Result { let db = Database::create(path).map_err(StorageError::Open)?; { @@ -47,7 +49,7 @@ impl LocalStore { } /// Fetch a value for `(namespace, key)`. Returns `Ok(None)` when - /// no entry exists; module never observes the prefix. + /// no entry exists; the module never observes the prefix. pub fn get(&self, namespace: &str, key: &str) -> Result>, StorageError> { let full = build_key(namespace, key)?; let txn = self.db.begin_read().map_err(StorageError::Txn)?; @@ -87,9 +89,9 @@ impl LocalStore { Ok(()) } - /// Enumerate keys in `namespace` whose raw key (post-prefix) - /// starts with `prefix`. Returns only the module-visible key - /// strings — the host strips the namespace prefix. + /// Enumerate keys in `namespace` whose raw key (post-prefix) starts + /// with `prefix`. Returns only the module-visible key strings — the + /// host strips the namespace prefix. pub fn list_keys(&self, namespace: &str, prefix: &str) -> Result, StorageError> { let ns_prefix = namespace_prefix(namespace)?; let full_prefix = build_key(namespace, prefix)?; @@ -109,23 +111,15 @@ impl LocalStore { } } +/// Returns the 32-byte keccak256 hash of `namespace` as a `Vec`. +/// Rejects the empty string so callers can rely on a non-trivial prefix. fn namespace_prefix(namespace: &str) -> Result, StorageError> { if namespace.is_empty() { return Err(StorageError::InvalidNamespace( "module namespace must not be empty".into(), )); } - let bytes = namespace.as_bytes(); - if bytes.len() > MAX_NAMESPACE_LEN { - return Err(StorageError::InvalidNamespace(format!( - "namespace `{namespace}` is {} bytes; max is {MAX_NAMESPACE_LEN}", - bytes.len() - ))); - } - let mut out = Vec::with_capacity(1 + bytes.len()); - out.push(bytes.len() as u8); - out.extend_from_slice(bytes); - Ok(out) + Ok(keccak256(namespace.as_bytes()).to_vec()) } fn build_key(namespace: &str, key: &str) -> Result, StorageError> { @@ -208,4 +202,29 @@ mod tests { let err = store.set("", "k", b"v").unwrap_err(); assert!(matches!(err, StorageError::InvalidNamespace(_))); } + + #[test] + fn prefix_is_fixed_32_bytes() { + let short = namespace_prefix("a").unwrap(); + let long = namespace_prefix(&"a".repeat(300)).unwrap(); + assert_eq!(short.len(), PREFIX_LEN); + assert_eq!(long.len(), PREFIX_LEN); + // Different inputs produce different prefixes. + assert_ne!(short, long); + } + + #[test] + fn prefix_is_deterministic() { + let p1 = namespace_prefix("twap-monitor").unwrap(); + let p2 = namespace_prefix("twap-monitor").unwrap(); + assert_eq!(p1, p2); + } + + #[test] + fn similar_names_differ() { + // Verify that names that share a common prefix don't collide. + let pa = namespace_prefix("module-a").unwrap(); + let pb = namespace_prefix("module-b").unwrap(); + assert_ne!(pa, pb); + } } diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index 0602ccc..4fe14ad 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -33,7 +33,7 @@ struct HostState { /// Origin for `clock::monotonic-ns`. Differences between successive /// readings are the only meaningful values. monotonic_baseline: Instant, - /// Per-module `[capabilities.http].allow` allowlist (from nexum.toml). + /// Per-module `[capabilities.http].allow` allowlist (from module.toml). /// Consulted by `http::fetch` before any outbound call. http_allowlist: Vec, /// Namespace for the running module's `local-store` rows. Set from @@ -393,7 +393,7 @@ impl nexum::host::http::Host for HostState { code: 0, message: format!( "host {host} not in [capabilities.http].allow; \ - add it to nexum.toml to permit" + add it to module.toml to permit" ), data: None, }); @@ -449,7 +449,7 @@ async fn main() -> anyhow::Result<()> { let store_path = engine_cfg.engine.state_dir.join("local-store.redb"); let local_store = host::local_store_redb::LocalStore::open(&store_path) .with_context(|| format!("open local-store at {}", store_path.display()))?; - let cow_pool = host::cow_orderbook::OrderBookPool::with_default_chains(); + let cow_pool = host::cow_orderbook::OrderBookPool::default(); let provider_pool = host::provider_pool::ProviderPool::from_config(&engine_cfg) .await .context("open chain providers")?; diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index 2ce576a..d1ce6bc 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -1,7 +1,6 @@ -//! Minimal `nexum.toml` parser and capability-enforcement helpers (0.2 scope). +//! `module.toml` parser and capability-enforcement helpers (0.2 scope). //! -//! 0.2 intentionally ships a slim subset of the manifest spec described in -//! the migration guide §3: +//! 0.2 intentionally ships a slim subset of the manifest spec: //! //! - `[capabilities].required` is parsed and validated (names must be in //! the known capability set; the 0.2 reference engine always provides @@ -14,9 +13,9 @@ //! module's `init`. Typed `config-value` variant is deferred to 0.3. //! //! When the manifest file is missing or has no `[capabilities]` section, -//! a deprecation warning is emitted on stderr and the engine falls back -//! to 0.1 behaviour (treat every linked capability as required). This -//! fallback will be removed in 0.3. +//! a deprecation warning is emitted and the engine falls back to 0.1 +//! behaviour (treat every linked capability as required). This fallback +//! will be removed in 0.3. use std::collections::HashSet; use std::path::Path; @@ -55,7 +54,7 @@ pub struct Manifest { pub subscriptions: Vec, } -/// One `[[subscription]]` table in `nexum.toml`. +/// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are /// validated per-kind by the supervisor. Unknown kinds are surfaced @@ -162,7 +161,88 @@ pub struct LoadedManifest { pub config: Vec<(String, String)>, } -/// Read `nexum.toml` from `path`, parse, validate, and emit a deprecation +/// Error returned when a component's WIT imports exceed its declared capabilities. +#[derive(Debug)] +pub struct CapabilityViolation { + /// Capability name (e.g. `"remote-store"`). + pub capability: String, + /// Full WIT import name as it appeared in the component (e.g. + /// `"nexum:host/remote-store@0.2.0"`). + pub wit_import: String, +} + +impl std::fmt::Display for CapabilityViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "component imports `{}` ({}) but it is not listed in \ + [capabilities].required or [capabilities].optional", + self.capability, self.wit_import + ) + } +} + +impl std::error::Error for CapabilityViolation {} + +/// Check that every capability-bearing WIT import of the component is covered +/// by the module's manifest declarations. Call this after loading the +/// component but before instantiation. +/// +/// When `[capabilities]` is absent the manifest is in 0.1-fallback mode and +/// all imports are allowed; the caller is expected to have already emitted +/// a deprecation warning. +/// +/// `component_imports` should be the iterator returned by +/// `component.component_type().imports(&engine)` — pass the **name** part +/// (`&str`) of each `(&str, ComponentItem)` tuple. +pub fn enforce_capabilities<'a>( + loaded: &LoadedManifest, + component_imports: impl Iterator, +) -> Result<(), CapabilityViolation> { + let caps = match loaded.manifest.capabilities.as_ref() { + None => return Ok(()), // 0.1-fallback: no enforcement + Some(c) => c, + }; + + let declared: HashSet<&str> = caps + .required + .iter() + .chain(caps.optional.iter()) + .map(String::as_str) + .collect(); + + for import_name in component_imports { + if let Some(cap) = wit_import_to_cap(import_name) { + if !declared.contains(cap) { + return Err(CapabilityViolation { + capability: cap.to_owned(), + wit_import: import_name.to_owned(), + }); + } + } + } + Ok(()) +} + +/// Map a WIT import name to a capability name, or `None` for non-capability +/// imports (wasi:*, wasi:io, wasi:cli, etc.). +/// +/// Examples: +/// - `"nexum:host/chain@0.2.0"` → `Some("chain")` +/// - `"shepherd:cow/cow-api@0.2.0"` → `Some("cow-api")` +/// - `"wasi:io/streams@0.2.0"` → `None` +fn wit_import_to_cap(import_name: &str) -> Option<&str> { + let without_version = import_name.split('@').next().unwrap_or(import_name); + if let Some(iface) = without_version.strip_prefix("nexum:host/") { + Some(iface) + } else if let Some(iface) = without_version.strip_prefix("shepherd:cow/") { + Some(iface) + } else { + None + } +} + +/// Read `module.toml` from `path`, parse, validate, and emit a deprecation /// warning if `[capabilities]` is absent (0.1-compat fallback). pub fn load(path: &Path) -> Result { let raw = std::fs::read_to_string(path).map_err(ParseError::Io)?; @@ -171,10 +251,9 @@ pub fn load(path: &Path) -> Result { let caps = manifest.capabilities.as_ref(); if caps.is_none() { eprintln!( - "[deprecation] no [capabilities] section in nexum.toml — \ + "[deprecation] no [capabilities] section in module.toml — \ defaulting to all-required (0.1 behaviour). This default \ - will be removed in 0.3; add an explicit [capabilities] block \ - now." + will be removed in 0.3; add an explicit [capabilities] block." ); } @@ -221,13 +300,13 @@ pub fn load(path: &Path) -> Result { }) } -/// Synthesise a "0.1 fallback" manifest for when no `nexum.toml` is found. +/// Synthesise a "0.1 fallback" manifest for when no `module.toml` is found. /// Emits the same deprecation warning as a missing-section manifest. pub fn fallback_manifest() -> LoadedManifest { eprintln!( - "[deprecation] no nexum.toml found — defaulting to all-required \ + "[deprecation] no module.toml found — defaulting to all-required \ (0.1 behaviour). This default will be removed in 0.3; ship a \ - nexum.toml alongside your component." + module.toml alongside your component." ); LoadedManifest { manifest: Manifest::default(), @@ -310,4 +389,87 @@ mod tests { assert!(!host_allowed("discord.com", &allow)); assert!(!host_allowed("nope.example", &allow)); } + + // ── capability enforcement ──────────────────────────────────────────── + + #[test] + fn wit_import_to_cap_nexum_host() { + assert_eq!(wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!( + wit_import_to_cap("nexum:host/local-store@0.2.0"), + Some("local-store") + ); + assert_eq!(wit_import_to_cap("nexum:host/http@0.2.0"), Some("http")); + } + + #[test] + fn wit_import_to_cap_shepherd_cow() { + assert_eq!( + wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), + Some("cow-api") + ); + } + + #[test] + fn wit_import_to_cap_wasi_is_none() { + assert_eq!(wit_import_to_cap("wasi:io/streams@0.2.0"), None); + assert_eq!(wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); + } + + fn manifest_with_caps(required: &[&str], optional: &[&str]) -> LoadedManifest { + LoadedManifest { + manifest: Manifest { + capabilities: Some(CapabilitiesSection { + required: required.iter().map(|s| s.to_string()).collect(), + optional: optional.iter().map(|s| s.to_string()).collect(), + http: None, + }), + ..Default::default() + }, + http_allowlist: vec![], + config: vec![], + } + } + + fn manifest_no_caps() -> LoadedManifest { + LoadedManifest { + manifest: Manifest::default(), + http_allowlist: vec![], + config: vec![], + } + } + + #[test] + fn enforce_passes_when_caps_absent() { + let loaded = manifest_no_caps(); + let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); + } + + #[test] + fn enforce_passes_when_all_imports_declared() { + let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); + let imports = [ + "nexum:host/chain@0.2.0", + "shepherd:cow/cow-api@0.2.0", + "nexum:host/http@0.2.0", + "wasi:io/streams@0.2.0", + ]; + assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); + } + + #[test] + fn enforce_rejects_undeclared_import() { + let loaded = manifest_with_caps(&["chain"], &[]); + let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + let err = enforce_capabilities(&loaded, imports.into_iter()).unwrap_err(); + assert_eq!(err.capability, "remote-store"); + } + + #[test] + fn enforce_optional_caps_are_also_allowed() { + let loaded = manifest_with_caps(&["chain"], &["remote-store"]); + let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); + } } diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 18c2fa4..89e02f2 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -5,14 +5,11 @@ //! `Store`, and routes the event types declared in each manifest's //! `[[subscription]]` table. //! -//! 0.2 dispatches a block event to every module that subscribed to -//! that chain's blocks, and a log event only to the module that -//! opened the subscription. Restart + poison-pill bookkeeping ships -//! in a follow-up — for now a failing `_on_event` is logged via -//! `tracing::error!` and the module continues to receive subsequent -//! events. Lifecycle states `Restart` / `Dead` from -//! `docs/02-modules-events-packaging.md` land alongside the -//! `[module.restart]` schema in 0.3. +//! Trap handling (BLEU-817): a wasmtime trap in `on_event` marks the +//! module as `alive = false` and removes it from all future dispatch. +//! The module's subscriptions remain registered (the event-loop +//! streams are not closed) but the dispatcher skips dead modules. +//! Full restart-with-backoff lands in 0.3. use std::collections::BTreeSet; use std::path::Path; @@ -40,9 +37,13 @@ struct LoadedModule { name: String, bindings: Shepherd, store: Store, - /// Subscriptions copied from `nexum.toml`. The supervisor reads + /// Subscriptions copied from `module.toml`. The supervisor reads /// these on every event to decide whether to dispatch. subscriptions: Vec, + /// Set to `false` when `on_event` traps. Dead modules are silently + /// skipped on every subsequent dispatch. Full restart-with-backoff + /// lands in 0.3. + alive: bool, } impl Supervisor { @@ -103,19 +104,33 @@ impl Supervisor { provider_pool: &ProviderPool, local_store: &LocalStore, ) -> Result { - let manifest_path = entry - .manifest - .clone() - .or_else(|| entry.path.parent().map(|p| p.join("nexum.toml"))); + // Canonical name is module.toml (ADR-0001). nexum.toml is accepted + // with a deprecation warning during the 0.1→0.2 transition. + let manifest_path = entry.manifest.clone().or_else(|| { + let dir = entry.path.parent()?.to_owned(); + let canonical = dir.join("module.toml"); + if canonical.exists() { + return Some(canonical); + } + let legacy = dir.join("nexum.toml"); + if legacy.exists() { + eprintln!( + "[deprecation] nexum.toml is deprecated; rename to module.toml \ + (ADR-0001). Support will be removed in 0.3." + ); + return Some(legacy); + } + None + }); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading nexum.toml"); + info!(manifest = %p.display(), "loading module manifest"); manifest::load(p)? } _ => { warn!( component = %entry.path.display(), - "no nexum.toml — falling back to anonymous module" + "no module.toml — falling back to anonymous module" ); manifest::fallback_manifest() } @@ -126,6 +141,14 @@ impl Supervisor { let component = Component::from_file(engine, &entry.path) .map_err(Error::from) .with_context(|| format!("compile {}", entry.path.display()))?; + + // Enforce capability declarations before spending time on instantiation. + manifest::enforce_capabilities( + &loaded_manifest, + component.component_type().imports(engine).map(|(n, _)| n), + ) + .map_err(|e| Error::msg(e.to_string())) + .with_context(|| format!("capability violation in {}", entry.path.display()))?; let wasi = WasiCtxBuilder::new().inherit_stdio().build(); let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { "module".to_owned() @@ -189,6 +212,7 @@ impl Supervisor { bindings, store, subscriptions: loaded_manifest.manifest.subscriptions.clone(), + alive: true, }) } @@ -243,6 +267,7 @@ impl Supervisor { /// Dispatch a block event to every module subscribed to /// `block.chain_id`. Returns the number of modules invoked. + /// Modules that trap are marked dead and excluded from future dispatch. pub async fn dispatch_block(&mut self, block: crate::nexum::host::types::Block) -> usize { let event = crate::nexum::host::types::Event::Block(block); let chain_id = match &event { @@ -251,6 +276,9 @@ impl Supervisor { }; let mut dispatched = 0; for module in &mut self.modules { + if !module.alive { + continue; + } let subscribed = module .subscriptions .iter() @@ -272,21 +300,24 @@ impl Supervisor { message = %host_err.message, "on-event returned host-error", ), - Err(trap) => error!( - module = %module.name, - chain_id, - error = %trap, - "on-event trapped", - ), + Err(trap) => { + error!( + module = %module.name, + chain_id, + error = %trap, + "on-event trapped — module marked dead, removed from dispatch", + ); + module.alive = false; + } } } dispatched } /// Dispatch a log event to the specific module that opened the - /// subscription. Returns `true` when the module accepted the - /// dispatch; `false` when the module was not found or its - /// callback failed. + /// subscription. Returns `true` when the module accepted the dispatch; + /// `false` when the module is dead, not found, or its callback failed. + /// A trapping module is marked dead and excluded from future dispatch. pub async fn dispatch_log( &mut self, module_name: &str, @@ -300,6 +331,9 @@ impl Supervisor { return false; } }; + if !target.alive { + return false; + } let event = crate::nexum::host::types::Event::Logs(vec![project_log(chain_id, &log)]); match target .bindings @@ -323,12 +357,18 @@ impl Supervisor { module = %module_name, chain_id, error = %trap, - "on-event trapped", + "on-event trapped — module marked dead, removed from dispatch", ); + target.alive = false; false } } } + + /// Count of modules currently alive (not dead due to traps). + pub fn alive_count(&self) -> usize { + self.modules.iter().filter(|m| m.alive).count() + } } /// Project an alloy `Log` onto the WIT `log` record. The chain id From 157003602b79ff9d53afdd82f46e9de16d697021 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Tue, 9 Jun 2026 18:04:18 -0300 Subject: [PATCH 05/15] feat(supervisor): add fuel + memory limits per module store (BLEU-818) --- crates/nexum-engine/src/main.rs | 12 ++++++++++++ crates/nexum-engine/src/supervisor.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index 4fe14ad..38f32da 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -27,9 +27,20 @@ wasmtime::component::bindgen!({ use nexum::host::types::HostErrorKind; +/// Default fuel budget granted per `on_event` invocation (≈ 1 billion WASM +/// instructions). Modules that exceed this budget trap with `OutOfFuel`. +/// Configurable per-module via `engine.toml` in 0.3. +pub const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000; + +/// Default linear-memory cap per module store (64 MiB). Prevents a single +/// runaway module from exhausting process memory. Configurable in 0.3. +pub const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024; + struct HostState { wasi: WasiCtx, table: ResourceTable, + /// Wasmtime memory/table/instance resource limits for this store. + limits: wasmtime::StoreLimits, /// Origin for `clock::monotonic-ns`. Differences between successive /// readings are the only meaningful values. monotonic_baseline: Instant, @@ -457,6 +468,7 @@ async fn main() -> anyhow::Result<()> { // wasmtime engine + linker — one of each, shared across modules. let mut config = wasmtime::Config::new(); config.wasm_component_model(true); + config.consume_fuel(true); let engine = Engine::new(&config)?; let mut linker = Linker::::new(&engine); diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 89e02f2..9bb4cb3 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -155,11 +155,15 @@ impl Supervisor { } else { loaded_manifest.manifest.module.name.clone() }; + let limits = wasmtime::StoreLimitsBuilder::new() + .memory_size(crate::DEFAULT_MEMORY_LIMIT) + .build(); let mut store = Store::new( engine, HostState { wasi, table: ResourceTable::new(), + limits, monotonic_baseline: std::time::Instant::now(), http_allowlist: loaded_manifest.http_allowlist.clone(), module_namespace: module_namespace.clone(), @@ -168,6 +172,8 @@ impl Supervisor { store: local_store.clone(), }, ); + store.limiter(|state| &mut state.limits); + store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT)?; let bindings = Shepherd::instantiate_async(&mut store, &component, linker) .await .map_err(Error::from) @@ -194,6 +200,8 @@ impl Supervisor { "init failed", ), } + // Refuel after init so the first on_event starts with a full budget. + store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT)?; // Surface any `[[subscription]]` entries the host cannot // service yet, so an operator running 0.2 against a 0.3 @@ -286,6 +294,11 @@ impl Supervisor { if !subscribed { continue; } + // Refuel before each invocation so each event gets a fresh budget. + if let Err(e) = module.store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT) { + error!(module = %module.name, error = %e, "set_fuel failed — skipping"); + continue; + } match module .bindings .call_on_event(&mut module.store, &event) @@ -334,6 +347,10 @@ impl Supervisor { if !target.alive { return false; } + if let Err(e) = target.store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT) { + error!(module = %module_name, error = %e, "set_fuel failed — skipping"); + return false; + } let event = crate::nexum::host::types::Event::Logs(vec![project_log(chain_id, &log)]); match target .bindings From 32a2198c22379f3cafc1899ce731c092a830e9b6 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Tue, 9 Jun 2026 21:50:36 -0300 Subject: [PATCH 06/15] docs: rename nexum.toml -> module.toml in example, justfile, and README (BLEU-820) --- README.md | 62 +++++++++++++++++++++++++++++++++++++ justfile | 8 +++-- modules/example/module.toml | 27 ++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 modules/example/module.toml diff --git a/README.md b/README.md index d146082..f6be460 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,72 @@ just build # Run the runtime against the example module just run + +# Run unit tests +just test ``` Without Nix, you need: Rust (edition 2024, see `rust-toolchain.toml` if present), the `wasm32-wasip2` target, and `wasm-tools`. +## Running + +### Single-module (development) + +```sh +nexum-engine [] +``` + +The `module.toml` is optional; without it the engine prints a deprecation warning and loads the module with empty capabilities and config (0.1 fallback). + +### Multi-module (production) + +```sh +nexum-engine --engine-config engine.toml +``` + +`engine.toml` declares RPC endpoints, the state directory, and a `[[modules]]` list: + +```toml +[engine] +state_dir = "/var/lib/shepherd" +log_level = "info" + +[[chains]] +id = 1 +url = "wss://mainnet.infura.io/ws/v3/..." + +[[modules]] +path = "modules/twap-monitor/twap-monitor.wasm" +manifest = "modules/twap-monitor/module.toml" + +[[modules]] +path = "modules/ethflow-watcher/ethflow-watcher.wasm" +``` + +### Module manifest (`module.toml`) + +```toml +[module] +name = "twap-monitor" +version = "0.1.0" + +[capabilities] +required = ["chain", "local-store", "cow-api"] +optional = ["http"] + +[capabilities.http] +allow = ["api.cow.fi"] + +[[subscription]] +kind = "log" +chain_id = 1 +address = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" # ComposableCoW + +[[subscription]] +kind = "block" +chain_id = 1 +``` + ## Documentation The `docs/` directory contains the design corpus: diff --git a/justfile b/justfile index 3e01212..e3ebd15 100644 --- a/justfile +++ b/justfile @@ -10,10 +10,14 @@ build-module: build: build-engine build-module # Build the module then run the engine with it. The second argument is the -# module's nexum.toml — without it the engine prints the 0.1-compat +# module's module.toml — without it the engine prints the 0.1-compat # deprecation warning and proceeds with empty capabilities/config. run: build-module build-engine - cargo run -p nexum-engine -- target/wasm32-wasip2/release/example.wasm modules/example/nexum.toml + cargo run -p nexum-engine -- target/wasm32-wasip2/release/example.wasm modules/example/module.toml + +# Run host engine unit tests +test: + cargo test -p nexum-engine # Check the entire workspace check: diff --git a/modules/example/module.toml b/modules/example/module.toml new file mode 100644 index 0000000..e17a547 --- /dev/null +++ b/modules/example/module.toml @@ -0,0 +1,27 @@ +# Example module manifest — exercises the 0.2 manifest schema end-to-end. + +[module] +name = "example" +version = "0.1.0" +# Placeholder content hash. 0.2 parses but does not verify this; 0.3 will +# compare it against the sha256 of the loaded component bytes. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# 0.2 reference engine provides all listed capabilities; this list is a +# sanity check + future-proofing. +required = ["logging"] + +# Capabilities the module would use opportunistically. In 0.2 these are +# parsed and logged; trap-stub fallback for absent optionals ships in 0.3. +optional = [] + +[capabilities.http] +# Per-module HTTP allowlist. Empty list = no outbound HTTP permitted. +# Entries are exact hostnames or *.domain wildcards. +allow = [] + +[config] +# Stringly-typed in 0.2 (typed variant on 0.3 roadmap). Numbers and +# booleans are flattened to their text form by the host on the way through. +name = "example" From 38ac8e3be5734144cfbc266e65e6f6b1dfc1376d Mon Sep 17 00:00:00 2001 From: brunota20 Date: Tue, 9 Jun 2026 22:02:40 -0300 Subject: [PATCH 07/15] =?UTF-8?q?test:=20fill=20host=20backend=20test=20ga?= =?UTF-8?q?ps=20=E2=80=94=20manifest=20parsing,=20cow-api,=20provider-pool?= =?UTF-8?q?,=20supervisor=20(BLEU-821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/nexum-engine/src/host/cow_orderbook.rs | 58 ++++++++++++ crates/nexum-engine/src/host/provider_pool.rs | 29 ++++++ crates/nexum-engine/src/manifest.rs | 92 +++++++++++++++++++ crates/nexum-engine/src/supervisor.rs | 39 ++++++++ 4 files changed, 218 insertions(+) diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs index 227efd1..61375fd 100644 --- a/crates/nexum-engine/src/host/cow_orderbook.rs +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -235,6 +235,64 @@ mod tests { assert!(matches!(err, CowApiError::BadMethod(_))); } + #[tokio::test] + async fn request_post_with_body_is_forwarded() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/quote")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"quote":"ok"}"#)) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request( + Chain::Mainnet.id(), + "POST", + "/api/v1/quote", + Some(r#"{"sellToken":"0x01"}"#), + ) + .await + .expect("post with body succeeds"); + assert_eq!(body, r#"{"quote":"ok"}"#); + } + + #[tokio::test] + async fn request_4xx_response_is_returned_verbatim() { + // The host must NOT surface a 4xx as an error — the module + // needs the structured JSON body to decode `OrderPostError`. + let mock = MockServer::start().await; + let error_body = r#"{"errorType":"InsufficientFee","description":"fee too low"}"#; + Mock::given(method("POST")) + .and(path("/api/v1/orders")) + .respond_with( + ResponseTemplate::new(400).set_body_string(error_body), + ) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request( + Chain::Mainnet.id(), + "POST", + "/api/v1/orders", + Some(r#"{"test":true}"#), + ) + .await + .expect("4xx body is returned, not an Err"); + assert_eq!(body, error_body); + } + + #[tokio::test] + async fn request_rejects_unknown_chain() { + let pool = OrderBookPool::default(); + let err = pool.request(99_999, "GET", "/x", None).await.unwrap_err(); + assert!(matches!(err, CowApiError::UnknownChain(99_999))); + } + #[tokio::test] async fn submit_order_propagates_orderbook_response() { let mock = MockServer::start().await; diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs index adf589f..e6c3e42 100644 --- a/crates/nexum-engine/src/host/provider_pool.rs +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -198,4 +198,33 @@ mod tests { .unwrap_err(); assert!(matches!(err, ProviderError::UnknownChain(1))); } + + #[tokio::test] + async fn empty_pool_rejects_block_subscribe() { + let pool = ProviderPool::empty(); + // Can't use .unwrap_err() because BlockStream doesn't impl Debug. + assert!(matches!( + pool.subscribe_blocks(1).await, + Err(ProviderError::UnknownChain(1)) + )); + } + + #[tokio::test] + async fn empty_pool_rejects_log_subscribe() { + let pool = ProviderPool::empty(); + let filter = alloy_rpc_types_eth::Filter::new(); + assert!(matches!( + pool.subscribe_logs(1, filter).await, + Err(ProviderError::UnknownChain(1)) + )); + } + + #[tokio::test] + async fn invalid_params_json_is_rejected_before_network() { + // RawValue::from_string rejects non-JSON; verify the parse layer + // we rely on before forwarding to alloy. + let bad = "not json at all {{{"; + let result = RawValue::from_string(bad.to_owned()); + assert!(result.is_err(), "invalid JSON should fail RawValue parse"); + } } diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index d1ce6bc..8eacffb 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -149,6 +149,7 @@ impl std::fmt::Display for ParseError { impl std::error::Error for ParseError {} /// Loaded + validated manifest, plus its source path for diagnostics. +#[derive(Debug)] pub struct LoadedManifest { pub manifest: Manifest, /// Hosts to allow for `http::fetch`. Each entry is either an exact @@ -472,4 +473,95 @@ mod tests { let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); } + + // ── manifest parsing ────────────────────────────────────────────────── + + #[test] + fn load_parses_block_and_log_subscriptions() { + let toml = r#" +[module] +name = "twap-monitor" + +[capabilities] +required = ["chain", "local-store"] + +[[subscription]] +kind = "block" +chain_id = 1 + +[[subscription]] +kind = "log" +chain_id = 1 +address = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" +event_signature = "0x00000000000000000000000000000000000000000000000000000000deadbeef" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert_eq!(manifest.module.name, "twap-monitor"); + assert_eq!(manifest.subscriptions.len(), 2); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Block { chain_id: 1 } + )); + if let Subscription::Log { chain_id, address, .. } = &manifest.subscriptions[1] { + assert_eq!(*chain_id, 1); + assert!(address.is_some()); + } else { + panic!("expected Log subscription"); + } + } + + #[test] + fn load_parses_cron_subscription() { + let toml = r#" +[module] +name = "scheduler" + +[[subscription]] +kind = "cron" +schedule = "*/5 * * * *" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Cron { .. } + )); + } + + #[test] + fn load_rejects_unknown_capability() { + let toml = r#" +[module] +name = "bad" + +[capabilities] +required = ["chain", "not-a-real-cap"] +"#; + // Write to a temp file so load() can read it. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("module.toml"); + std::fs::write(&path, toml).unwrap(); + let err = load(&path).unwrap_err(); + assert!(matches!(err, ParseError::UnknownCapability(ref name) if name == "not-a-real-cap")); + } + + #[test] + fn load_parses_config_table() { + let toml = r#" +[module] +name = "example" + +[config] +chain_id = 1 +label = "mainnet" +enabled = true +"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("module.toml"); + std::fs::write(&path, toml).unwrap(); + let loaded = load(&path).unwrap(); + let config: std::collections::HashMap<_, _> = loaded.config.into_iter().collect(); + assert_eq!(config.get("chain_id").map(String::as_str), Some("1")); + assert_eq!(config.get("label").map(String::as_str), Some("mainnet")); + assert_eq!(config.get("enabled").map(String::as_str), Some("true")); + } } diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 9bb4cb3..0067e1b 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -441,4 +441,43 @@ mod tests { assert!(sup.log_subscriptions().is_empty()); assert_eq!(sup.module_count(), 0); } + + // ── build_alloy_filter ──────────────────────────────────────────────── + + #[test] + fn alloy_filter_with_address_and_topic() { + let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; + let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; + let filter = build_alloy_filter(Some(addr), Some(topic)).unwrap(); + // Check address is set (alloy Filter doesn't expose a simple getter, + // but we can verify the filter serialises the address field). + let serialised = serde_json::to_value(&filter).unwrap(); + let addr_field = serialised.get("address").unwrap().to_string().to_lowercase(); + assert!(addr_field.contains(&addr.to_lowercase()[2..])); // strip 0x + } + + #[test] + fn alloy_filter_no_address_no_topic() { + let filter = build_alloy_filter(None, None).unwrap(); + let serialised = serde_json::to_value(&filter).unwrap(); + // Address and topics should be absent or null. + assert!( + serialised.get("address").is_none() + || serialised["address"].is_null() + || serialised["address"] == serde_json::json!([]) + ); + } + + #[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()); + } } From fdd64e4da2d3b1fe0f52e7157893b95fa10644b3 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Tue, 9 Jun 2026 22:07:11 -0300 Subject: [PATCH 08/15] test: E2E supervisor tests + fix wit_import_to_cap to skip type-only interfaces (BLEU-819) --- crates/nexum-engine/src/manifest.rs | 28 +++-- crates/nexum-engine/src/supervisor.rs | 150 ++++++++++++++++++++++++++ justfile | 4 + 3 files changed, 172 insertions(+), 10 deletions(-) diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index 8eacffb..9c885ed 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -226,21 +226,29 @@ pub fn enforce_capabilities<'a>( } /// Map a WIT import name to a capability name, or `None` for non-capability -/// imports (wasi:*, wasi:io, wasi:cli, etc.). +/// imports. +/// +/// Returns `Some` only for functional interfaces that appear in +/// `KNOWN_CAPABILITIES`. Type-only packages (e.g. `nexum:host/types`) and +/// WASI system interfaces are treated as non-capability and ignored. /// /// Examples: -/// - `"nexum:host/chain@0.2.0"` → `Some("chain")` -/// - `"shepherd:cow/cow-api@0.2.0"` → `Some("cow-api")` -/// - `"wasi:io/streams@0.2.0"` → `None` +/// - `"nexum:host/chain@0.2.0"` → `Some("chain")` +/// - `"shepherd:cow/cow-api@0.2.0"` → `Some("cow-api")` +/// - `"nexum:host/types@0.2.0"` → `None` (type-only, not a capability) +/// - `"wasi:io/streams@0.2.0"` → `None` fn wit_import_to_cap(import_name: &str) -> Option<&str> { let without_version = import_name.split('@').next().unwrap_or(import_name); - if let Some(iface) = without_version.strip_prefix("nexum:host/") { - Some(iface) - } else if let Some(iface) = without_version.strip_prefix("shepherd:cow/") { - Some(iface) + let iface = if let Some(i) = without_version.strip_prefix("nexum:host/") { + i + } else if let Some(i) = without_version.strip_prefix("shepherd:cow/") { + i } else { - None - } + return None; + }; + // Only return Some for functional capabilities. Type-only packages + // (like nexum:host/types) are shared data definitions, not capabilities. + if KNOWN_CAPABILITIES.contains(&iface) { Some(iface) } else { None } } /// Read `module.toml` from `path`, parse, validate, and emit a deprecation diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 0067e1b..c4f9f4e 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -383,6 +383,7 @@ impl Supervisor { } /// Count of modules currently alive (not dead due to traps). + #[cfg_attr(not(test), allow(dead_code))] pub fn alive_count(&self) -> usize { self.modules.iter().filter(|m| m.alive).count() } @@ -430,6 +431,8 @@ fn build_alloy_filter( #[cfg(test)] mod tests { + use std::path::{Path, PathBuf}; + use super::*; #[test] @@ -442,6 +445,153 @@ mod tests { assert_eq!(sup.module_count(), 0); } + // ── E2E helpers ─────────────────────────────────────────────────────── + + /// Path to the pre-built example WASM component. Tests that need it + /// call `example_wasm_or_skip()` which skips gracefully if absent. + fn example_wasm() -> PathBuf { + // CARGO_MANIFEST_DIR → crates/nexum-engine + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/wasm32-wasip2/release/example.wasm") + } + + fn example_module_toml() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("modules/example/module.toml") + } + + /// Returns `None` and prints a skip message if the fixture isn't built. + fn example_wasm_or_skip() -> Option { + let p = example_wasm(); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found — run `just build-module` to enable E2E tests", + p.display() + ); + None + } + } + + fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") + } + + fn make_linker(engine: &wasmtime::Engine) -> Linker { + let mut linker = Linker::::new(engine); + crate::Shepherd::add_to_linker::>( + &mut linker, + |s| s, + ) + .expect("add_to_linker"); + wasmtime_wasi::p2::add_to_linker_async(&mut linker).expect("add_wasi"); + linker + } + + fn temp_local_store() -> crate::host::local_store_redb::LocalStore { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ls.redb"); + // Leak the dir so the file stays alive for the duration of the test. + let _ = std::mem::ManuallyDrop::new(dir); + crate::host::local_store_redb::LocalStore::open(path).expect("local store") + } + + // ── E2E tests ───────────────────────────────────────────────────────── + + /// Boot supervisor with the example module; verify it starts alive. + #[tokio::test] + async fn e2e_supervisor_boots_example_module() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); + let provider_pool = crate::host::provider_pool::ProviderPool::empty(); + let local_store = temp_local_store(); + + let supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(example_module_toml()).as_deref(), + &cow_pool, + &provider_pool, + &local_store, + ) + .await + .expect("boot_single"); + + assert_eq!(supervisor.module_count(), 1); + assert_eq!(supervisor.alive_count(), 1); + } + + /// Boot with a manifest that subscribes to block events; dispatch one + /// block event and verify the module was invoked and stayed alive. + #[tokio::test] + async fn e2e_block_subscription_dispatched() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "block" +chain_id = 1 +"#, + ) + .unwrap(); + + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); + let provider_pool = crate::host::provider_pool::ProviderPool::empty(); + let local_store = temp_local_store(); + + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &cow_pool, + &provider_pool, + &local_store, + ) + .await + .expect("boot_single"); + + let block = crate::nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + let dispatched = supervisor.dispatch_block(block).await; + assert_eq!(dispatched, 1, "one module subscribed to chain 1 blocks"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + } + // ── build_alloy_filter ──────────────────────────────────────────────── #[test] diff --git a/justfile b/justfile index e3ebd15..4230a80 100644 --- a/justfile +++ b/justfile @@ -19,6 +19,10 @@ run: build-module build-engine test: cargo test -p nexum-engine +# Build module + engine, then run E2E integration tests +test-e2e: build-module build-engine + cargo test -p nexum-engine supervisor::tests::e2e + # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example From 7131282ae2a4b1c4793cd4e9507c152e72405b9e Mon Sep 17 00:00:00 2001 From: brunota20 Date: Tue, 9 Jun 2026 22:26:54 -0300 Subject: [PATCH 09/15] style: apply rust-idiomatic rules (em-dashes, #[from] Orderbook, unused_crate_dependencies, drop redundant map_err) --- crates/nexum-engine/src/engine_config.rs | 8 ++--- crates/nexum-engine/src/host/cow_orderbook.rs | 19 +++++----- .../nexum-engine/src/host/local_store_redb.rs | 8 ++--- crates/nexum-engine/src/host/provider_pool.rs | 8 ++--- crates/nexum-engine/src/main.rs | 36 ++++++++++--------- crates/nexum-engine/src/manifest.rs | 14 ++++---- crates/nexum-engine/src/supervisor.rs | 17 +++++---- 7 files changed, 54 insertions(+), 56 deletions(-) diff --git a/crates/nexum-engine/src/engine_config.rs b/crates/nexum-engine/src/engine_config.rs index 1246850..9637981 100644 --- a/crates/nexum-engine/src/engine_config.rs +++ b/crates/nexum-engine/src/engine_config.rs @@ -1,7 +1,7 @@ //! Engine-side runtime configuration. //! //! Distinct from `module.toml` (module manifest): this file describes -//! the *engine*'s I/O wiring — chain RPC endpoints and the on-disk +//! the *engine*'s I/O wiring - chain RPC endpoints and the on-disk //! location of the `local-store` database. Both are required for the //! 0.2 reference engine to do anything other than print stubs. //! @@ -10,7 +10,7 @@ //! 1. `--engine-config ` CLI flag (future), or third positional //! argument today; //! 2. `engine.toml` in the current working directory; -//! 3. defaults — no chains configured, `state_dir = ./data`. +//! 3. defaults - no chains configured, `state_dir = ./data`. //! //! A missing config is OK for the example module (it only logs); for //! the cow-api / chain backends it surfaces as `HostError { @@ -34,7 +34,7 @@ pub struct EngineConfig { pub chains: BTreeMap, /// Modules the supervisor should boot. Each entry resolves a /// `(component.wasm, module.toml)` pair on the local filesystem - /// for 0.2 — content-addressed resolution (Swarm / OCI / + /// for 0.2 - content-addressed resolution (Swarm / OCI / /// `[[content.sources]]`) lands in 0.3 per /// `docs/03-module-discovery.md`. #[serde(default)] @@ -101,7 +101,7 @@ pub fn load_or_default(path: Option<&Path>) -> anyhow::Result { if !path.exists() { warn!( path = %path.display(), - "engine.toml not found — running with defaults (no chain RPC endpoints; \ + "engine.toml not found - running with defaults (no chain RPC endpoints; \ chain::request and cow_api::submit_order will return Unsupported)" ); return Ok(EngineConfig::default()); diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs index 61375fd..e95df9c 100644 --- a/crates/nexum-engine/src/host/cow_orderbook.rs +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -2,11 +2,11 @@ //! //! Two responsibilities: //! -//! 1. `request` — generic REST passthrough. Module gives the HTTP +//! 1. `request` - generic REST passthrough. Module gives the HTTP //! method, path (relative to the chain's orderbook base URL), and //! optional JSON body. We dispatch via `reqwest`, return the //! response body verbatim. -//! 2. `submit_order` — typed submission. Module gives a JSON-encoded +//! 2. `submit_order` - typed submission. Module gives a JSON-encoded //! `cowprotocol::OrderCreation`; we parse, dispatch via //! `cowprotocol::OrderBookApi::post_order`, return the assigned //! `OrderUid` as a `0x`-prefixed hex string. @@ -60,7 +60,7 @@ impl OrderBookPool { } /// REST passthrough. The base URL is whichever URL the pool's - /// `OrderBookApi` client carries — overrides set via + /// `OrderBookApi` client carries - overrides set via /// `OrderBookApi::new_with_base_url` (staging, wiremock) flow /// through here too, which keeps the passthrough and the typed /// `submit_order_json` path aimed at the same orderbook. @@ -99,7 +99,7 @@ impl OrderBookPool { let response = request.send().await.map_err(CowApiError::Network)?; // Surface the orderbook's structured 4xx / 5xx bodies verbatim // so the guest can decode `{"errorType": "...", "description": - // "..."}` — projecting them into HostError here loses the + // "..."}` - projecting them into HostError here loses the // detail the guest needs to recover. let text = response.text().await.map_err(CowApiError::Network)?; Ok(text) @@ -116,10 +116,7 @@ impl OrderBookPool { ) -> Result { let creation: OrderCreation = serde_json::from_slice(body).map_err(CowApiError::Decode)?; let api = self.get(chain_id)?; - let uid = api - .post_order(&creation) - .await - .map_err(|e| CowApiError::Orderbook(e.to_string()))?; + let uid = api.post_order(&creation).await?; Ok(uid) } } @@ -136,8 +133,8 @@ pub enum CowApiError { Network(#[from] reqwest::Error), #[error("decode OrderCreation JSON: {0}")] Decode(#[from] serde_json::Error), - #[error("orderbook rejected: {0}")] - Orderbook(String), + #[error("orderbook: {0}")] + Orderbook(#[from] cowprotocol::Error), } #[cfg(test)] @@ -260,7 +257,7 @@ mod tests { #[tokio::test] async fn request_4xx_response_is_returned_verbatim() { - // The host must NOT surface a 4xx as an error — the module + // The host must NOT surface a 4xx as an error - the module // needs the structured JSON body to decode `OrderPostError`. let mock = MockServer::start().await; let error_body = r#"{"errorType":"InsufficientFee","description":"fee too low"}"#; diff --git a/crates/nexum-engine/src/host/local_store_redb.rs b/crates/nexum-engine/src/host/local_store_redb.rs index 46d980e..4822e3e 100644 --- a/crates/nexum-engine/src/host/local_store_redb.rs +++ b/crates/nexum-engine/src/host/local_store_redb.rs @@ -8,9 +8,9 @@ //! The 32-byte hash prefix has two properties that the old //! `[len:u8][name][key]` scheme lacked: //! -//! - **Fixed width** — no length field to forge; a module cannot craft a +//! - **Fixed width** - no length field to forge; a module cannot craft a //! key that bleeds into another module's prefix range. -//! - **ENS-compatible** — keccak256 is the same hash used by ENS node +//! - **ENS-compatible** - keccak256 is the same hash used by ENS node //! derivation, so module identities can be derived from ENS names //! without extra hashing in the future (ADR-0003). @@ -75,7 +75,7 @@ impl LocalStore { Ok(()) } - /// Delete. Idempotent — deleting a missing key is a no-op. + /// Delete. Idempotent - deleting a missing key is a no-op. pub fn delete(&self, namespace: &str, key: &str) -> Result<(), StorageError> { let full = build_key(namespace, key)?; let txn = self.db.begin_write().map_err(StorageError::Txn)?; @@ -90,7 +90,7 @@ impl LocalStore { } /// Enumerate keys in `namespace` whose raw key (post-prefix) starts - /// with `prefix`. Returns only the module-visible key strings — the + /// with `prefix`. Returns only the module-visible key strings - the /// host strips the namespace prefix. pub fn list_keys(&self, namespace: &str, prefix: &str) -> Result, StorageError> { let ns_prefix = namespace_prefix(namespace)?; diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs index e6c3e42..4928982 100644 --- a/crates/nexum-engine/src/host/provider_pool.rs +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -3,13 +3,13 @@ //! Per-chain alloy provider, opened from the engine config at boot. //! `request` is a raw JSON-RPC dispatch: the host hands `(method, //! params)` straight to alloy's transport and returns the result body -//! verbatim. No method allowlist, no re-encoding of params — the +//! verbatim. No method allowlist, no re-encoding of params - the //! contract is "give us a JSON-RPC pair, we'll return what the node //! returns". //! //! Transports: -//! - `ws://` / `wss://` — `WsConnect`; required for `eth_subscribe`. -//! - `http://` / `https://` — alloy's HTTP transport; request/response only. +//! - `ws://` / `wss://` - `WsConnect`; required for `eth_subscribe`. +//! - `http://` / `https://` - alloy's HTTP transport; request/response only. use std::collections::BTreeMap; use std::pin::Pin; @@ -66,7 +66,7 @@ impl ProviderPool { }) } - /// Empty pool — used by tests and as a default when no + /// Empty pool - used by tests and as a default when no /// `engine.toml` is found. Every `request` call returns /// `UnknownChain`. #[cfg_attr(not(test), allow(dead_code))] diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index 38f32da..b0c761a 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + mod engine_config; mod host; mod manifest; @@ -16,7 +18,7 @@ use wasmtime::error::Context as _; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; // Both packages are listed explicitly so wit-parser can resolve the -// cross-package reference natively — no vendored deps/ tree needed. +// cross-package reference natively - no vendored deps/ tree needed. // World name is fully qualified. wasmtime::component::bindgen!({ path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], @@ -50,11 +52,11 @@ struct HostState { /// Namespace for the running module's `local-store` rows. Set from /// `manifest.module.name` at instantiation. module_namespace: String, - /// `cow-api` backend — per-chain `OrderBookApi` clients + reqwest. + /// `cow-api` backend - per-chain `OrderBookApi` clients + reqwest. cow: host::cow_orderbook::OrderBookPool, - /// `chain` backend — per-chain alloy `DynProvider` pool. + /// `chain` backend - per-chain alloy `DynProvider` pool. chain: host::provider_pool::ProviderPool, - /// `local-store` backend — redb file with host-side namespacing. + /// `local-store` backend - redb file with host-side namespacing. store: host::local_store_redb::LocalStore, } @@ -153,11 +155,11 @@ impl shepherd::cow::cow_api::Host for HostState { message: format!("invalid OrderCreation JSON: {err}"), data: None, }), - Err(host::cow_orderbook::CowApiError::Orderbook(msg)) => Err(HostError { + Err(host::cow_orderbook::CowApiError::Orderbook(err)) => Err(HostError { domain: "cow-api".into(), kind: HostErrorKind::Denied, code: 0, - message: msg, + message: err.to_string(), data: None, }), Err(err) => Err(internal_error("cow-api", err.to_string())), @@ -232,7 +234,7 @@ impl nexum::host::chain::Host for HostState { impl nexum::host::identity::Host for HostState { async fn accounts(&mut self) -> Result>, HostError> { - // No keystore wired yet — return an empty roster so guests can + // No keystore wired yet - return an empty roster so guests can // probe-then-skip without erroring. Real keystore lands in 0.3. Ok(vec![]) } @@ -331,7 +333,7 @@ impl nexum::host::messaging::Host for HostState { _end_time: Option, _limit: Option, ) -> Result, HostError> { - // Empty result — same posture as `identity::accounts`. + // Empty result - same posture as `identity::accounts`. Ok(vec![]) } } @@ -369,7 +371,7 @@ impl nexum::host::random::Host for HostState { let mut buf = vec![0u8; len as usize]; // getrandom 0.4: fill() returns Result<(), Error>. CSPRNG failures // are exceptionally rare on supported platforms; on failure we - // return zero-filled bytes — guests that need a strong-failure + // return zero-filled bytes - guests that need a strong-failure // signal should use identity or chain primitives instead. let _ = getrandom::fill(&mut buf); buf @@ -383,7 +385,7 @@ impl nexum::host::http::Host for HostState { ) -> Result { // Manifest allowlist enforcement runs before any I/O. Hosts that // never link a manifest leave `http_allowlist` empty, which denies - // every request — matching the "no implicit network" stance. + // every request - matching the "no implicit network" stance. let host = match manifest::extract_host(&req.url) { Some(h) => h, None => { @@ -465,7 +467,7 @@ async fn main() -> anyhow::Result<()> { .await .context("open chain providers")?; - // wasmtime engine + linker — one of each, shared across modules. + // wasmtime engine + linker - one of each, shared across modules. let mut config = wasmtime::Config::new(); config.wasm_component_model(true); config.consume_fuel(true); @@ -478,7 +480,7 @@ async fn main() -> anyhow::Result<()> { )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - // Boot supervisor — `engine.toml.[[modules]]` first, CLI + // Boot supervisor - `engine.toml.[[modules]]` first, CLI // positional second. let mut supervisor = if let Some(wasm) = cli.wasm.as_deref() { if !engine_cfg.modules.is_empty() { @@ -506,7 +508,7 @@ async fn main() -> anyhow::Result<()> { .await? } else { anyhow::bail!( - "no modules to run — either pass a positional or declare \ + "no modules to run - either pass a positional or declare \ [[modules]] entries in engine.toml" ); }; @@ -523,7 +525,7 @@ async fn main() -> anyhow::Result<()> { let log_subs = supervisor.log_subscriptions(); if block_chains.is_empty() && log_subs.is_empty() { - info!("no [[subscription]] entries — engine has nothing to run; exiting"); + info!("no [[subscription]] entries - engine has nothing to run; exiting"); return Ok(()); } @@ -533,7 +535,7 @@ async fn main() -> anyhow::Result<()> { let shutdown = async { match wait_for_shutdown_signal().await { Ok(name) => info!(signal = %name, "shutdown signal received"), - Err(err) => warn!(error = %err, "signal handler failed — using ctrl-c"), + Err(err) => warn!(error = %err, "signal handler failed - using ctrl-c"), } }; @@ -679,14 +681,14 @@ async fn run_event_loop( }; supervisor.dispatch_block(block).await; } - Some(Err(err)) => warn!(error = %err, "block stream error — continuing"), + Some(Err(err)) => warn!(error = %err, "block stream error - continuing"), None => {} }, next = logs.next() => match next { Some(Ok((module, chain_id, log))) => { supervisor.dispatch_log(&module, chain_id, log).await; } - Some(Err(err)) => warn!(error = %err, "log stream error — continuing"), + Some(Err(err)) => warn!(error = %err, "log stream error - continuing"), None => {} }, } diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index 9c885ed..c659d9c 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -62,7 +62,7 @@ pub struct Manifest { #[derive(Debug, Deserialize, Clone)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Subscription { - /// New-block events. Fan-out is shared per chain — the + /// New-block events. Fan-out is shared per chain - the /// supervisor opens one subscription per chain id and routes to /// every module that asked for blocks on that chain. Block { @@ -70,7 +70,7 @@ pub enum Subscription { chain_id: u64, }, /// Log events matching `address` + topic-0. Fan-out is - /// per-module — the supervisor opens one subscription per + /// per-module - the supervisor opens one subscription per /// `[[subscription]]` entry and tags emitted events with the /// owning module. Log { @@ -80,7 +80,7 @@ pub enum Subscription { #[serde(default)] address: Option, /// Topic-0 of the event the module wants to consume. `0x`- - /// prefixed 32-byte hex. Optional — when absent the + /// prefixed 32-byte hex. Optional - when absent the /// subscription matches every event from the address(es). #[serde(default)] event_signature: Option, @@ -194,7 +194,7 @@ impl std::error::Error for CapabilityViolation {} /// a deprecation warning. /// /// `component_imports` should be the iterator returned by -/// `component.component_type().imports(&engine)` — pass the **name** part +/// `component.component_type().imports(&engine)` - pass the **name** part /// (`&str`) of each `(&str, ComponentItem)` tuple. pub fn enforce_capabilities<'a>( loaded: &LoadedManifest, @@ -260,7 +260,7 @@ pub fn load(path: &Path) -> Result { let caps = manifest.capabilities.as_ref(); if caps.is_none() { eprintln!( - "[deprecation] no [capabilities] section in module.toml — \ + "[deprecation] no [capabilities] section in module.toml - \ defaulting to all-required (0.1 behaviour). This default \ will be removed in 0.3; add an explicit [capabilities] block." ); @@ -313,7 +313,7 @@ pub fn load(path: &Path) -> Result { /// Emits the same deprecation warning as a missing-section manifest. pub fn fallback_manifest() -> LoadedManifest { eprintln!( - "[deprecation] no module.toml found — defaulting to all-required \ + "[deprecation] no module.toml found - defaulting to all-required \ (0.1 behaviour). This default will be removed in 0.3; ship a \ module.toml alongside your component." ); @@ -340,7 +340,7 @@ pub fn host_allowed(host: &str, allowlist: &[String]) -> bool { } /// Extract the host component from a URL. Returns `None` for non-http(s) -/// schemes or malformed input. Intentionally simple — adds no `url` +/// schemes or malformed input. Intentionally simple - adds no `url` /// crate dependency. pub fn extract_host(url: &str) -> Option<&str> { let after_scheme = url diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index c4f9f4e..4b7823d 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -130,7 +130,7 @@ impl Supervisor { _ => { warn!( component = %entry.path.display(), - "no module.toml — falling back to anonymous module" + "no module.toml - falling back to anonymous module" ); manifest::fallback_manifest() } @@ -147,7 +147,6 @@ impl Supervisor { &loaded_manifest, component.component_type().imports(engine).map(|(n, _)| n), ) - .map_err(|e| Error::msg(e.to_string())) .with_context(|| format!("capability violation in {}", entry.path.display()))?; let wasi = WasiCtxBuilder::new().inherit_stdio().build(); let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { @@ -264,7 +263,7 @@ impl Supervisor { module = %module.name, chain_id, error = %err, - "invalid log subscription — skipping", + "invalid log subscription - skipping", ), } } @@ -296,7 +295,7 @@ impl Supervisor { } // Refuel before each invocation so each event gets a fresh budget. if let Err(e) = module.store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT) { - error!(module = %module.name, error = %e, "set_fuel failed — skipping"); + error!(module = %module.name, error = %e, "set_fuel failed - skipping"); continue; } match module @@ -318,7 +317,7 @@ impl Supervisor { module = %module.name, chain_id, error = %trap, - "on-event trapped — module marked dead, removed from dispatch", + "on-event trapped - module marked dead, removed from dispatch", ); module.alive = false; } @@ -340,7 +339,7 @@ impl Supervisor { let target = match self.modules.iter_mut().find(|m| m.name == module_name) { Some(m) => m, None => { - warn!(module = %module_name, "no such module — dropping log"); + warn!(module = %module_name, "no such module - dropping log"); return false; } }; @@ -348,7 +347,7 @@ impl Supervisor { return false; } if let Err(e) = target.store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT) { - error!(module = %module_name, error = %e, "set_fuel failed — skipping"); + error!(module = %module_name, error = %e, "set_fuel failed - skipping"); return false; } let event = crate::nexum::host::types::Event::Logs(vec![project_log(chain_id, &log)]); @@ -374,7 +373,7 @@ impl Supervisor { module = %module_name, chain_id, error = %trap, - "on-event trapped — module marked dead, removed from dispatch", + "on-event trapped - module marked dead, removed from dispatch", ); target.alive = false; false @@ -475,7 +474,7 @@ mod tests { Some(p) } else { eprintln!( - "SKIP: {} not found — run `just build-module` to enable E2E tests", + "SKIP: {} not found - run `just build-module` to enable E2E tests", p.display() ); None From 0679580c3a59312661f07280fb3b8e1926f1f73b Mon Sep 17 00:00:00 2001 From: brunota20 Date: Fri, 12 Jun 2026 10:06:10 -0300 Subject: [PATCH 10/15] review: apply lgahdl feedback on PR #9 (+ rebase PR #8 fixes) PR #9 specific: - main: warn + return when block/log streams end (WebSocket dropped) - supervisor: simplify dispatch_block by extracting chain_id before move - supervisor: temp_local_store returns (TempDir, LocalStore) instead of leaking - README: correct engine.toml chain syntax to [chains.] with rpc_url Rebased from PR #8: - local_store_redb: table.range() instead of iter() for O(matching) keys - provider_pool: dedupe method clone on the success path - main: hex_encode writes into the pre-allocated buffer - cow_orderbook: drop blank line nit - manifest: collapse nested if and use ? operator (clippy) - alloy_rpc_client / alloy_transport(_ws) imports as _ to satisfy unused_crate_dependencies. --- README.md | 5 ++- crates/nexum-engine/src/host/cow_orderbook.rs | 1 - .../nexum-engine/src/host/local_store_redb.rs | 19 ++++++++--- crates/nexum-engine/src/host/provider_pool.rs | 24 ++++++++------ crates/nexum-engine/src/main.rs | 32 ++++++++++++++++--- crates/nexum-engine/src/manifest.rs | 30 ++++++++--------- crates/nexum-engine/src/supervisor.rs | 20 ++++++------ 7 files changed, 83 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index f6be460..e44e9d4 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,8 @@ nexum-engine --engine-config engine.toml state_dir = "/var/lib/shepherd" log_level = "info" -[[chains]] -id = 1 -url = "wss://mainnet.infura.io/ws/v3/..." +[chains.1] +rpc_url = "wss://mainnet.infura.io/ws/v3/..." [[modules]] path = "modules/twap-monitor/twap-monitor.wasm" diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs index e95df9c..bac376d 100644 --- a/crates/nexum-engine/src/host/cow_orderbook.rs +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -51,7 +51,6 @@ impl Default for OrderBookPool { } impl OrderBookPool { - /// Look up the client for a chain. pub fn get(&self, chain_id: u64) -> Result<&OrderBookApi, CowApiError> { self.clients diff --git a/crates/nexum-engine/src/host/local_store_redb.rs b/crates/nexum-engine/src/host/local_store_redb.rs index 4822e3e..96f02eb 100644 --- a/crates/nexum-engine/src/host/local_store_redb.rs +++ b/crates/nexum-engine/src/host/local_store_redb.rs @@ -20,7 +20,7 @@ use std::path::Path; use std::sync::Arc; use alloy_primitives::keccak256; -use redb::{Database, ReadableTable, TableDefinition}; +use redb::{Database, TableDefinition}; use thiserror::Error; const TABLE: TableDefinition<'static, &[u8], &[u8]> = TableDefinition::new("nexum:local-store"); @@ -98,12 +98,21 @@ impl LocalStore { let txn = self.db.begin_read().map_err(StorageError::Txn)?; let table = txn.open_table(TABLE).map_err(StorageError::Table)?; let mut out = Vec::new(); - for entry in table.iter().map_err(StorageError::Storage)? { + // redb's B-tree iterates keys in sorted order, so a range + // starting at `full_prefix` only touches matching entries (and + // the first key past the prefix range). Breaking on the first + // non-matching key keeps this O(matching entries) instead of + // the O(total DB entries) `table.iter()` would do. + for entry in table + .range(full_prefix.as_slice()..) + .map_err(StorageError::Storage)? + { let (k, _v) = entry.map_err(StorageError::Storage)?; let key_bytes = k.value(); - if key_bytes.starts_with(&full_prefix) - && let Ok(s) = std::str::from_utf8(&key_bytes[ns_prefix.len()..]) - { + if !key_bytes.starts_with(&full_prefix) { + break; + } + if let Ok(s) = std::str::from_utf8(&key_bytes[ns_prefix.len()..]) { out.push(s.to_owned()); } } diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs index 4928982..d65e391 100644 --- a/crates/nexum-engine/src/host/provider_pool.rs +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -131,19 +131,23 @@ impl ProviderPool { .ok_or(ProviderError::UnknownChain(chain_id))?; // Pass the params through as a raw JSON value so alloy does // not re-encode them on the way to the node. - let params: Box = RawValue::from_string(params_json.clone()).map_err(|e| { - ProviderError::InvalidParams { + let params: Box = + RawValue::from_string(params_json).map_err(|e| ProviderError::InvalidParams { method: method.clone(), detail: e.to_string(), - } - })?; - let result: Box = provider - .raw_request(method.clone().into(), params) - .await - .map_err(|e| ProviderError::Rpc { - method, - detail: e.to_string(), })?; + // `raw_request` consumes the method name; clone once for the + // error branch so the success path moves the original string + // straight into alloy without an extra allocation. + let method_for_err = method.clone(); + let result: Box = + provider + .raw_request(method.into(), params) + .await + .map_err(|e| ProviderError::Rpc { + method: method_for_err, + detail: e.to_string(), + })?; Ok(result.get().to_owned()) } } diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index b0c761a..58ae8f4 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -1,5 +1,13 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] +// alloy split its API across multiple crates; we depend on the +// transports directly so cargo resolves the right feature set, but +// the runtime code only names them through the `alloy_provider` +// re-exports. Silence `unused_crate_dependencies` with `as _`. +use alloy_rpc_client as _; +use alloy_transport as _; +use alloy_transport_ws as _; + mod engine_config; mod host; mod manifest; @@ -421,11 +429,14 @@ impl nexum::host::http::Host for HostState { } /// Lowercase hex encoder. Kept in the engine binary rather than -/// pulling a `hex` crate just for one call site. +/// pulling a `hex` crate just for one call site. Writes into the +/// pre-allocated buffer to avoid the per-byte `String` allocation +/// `format!("{b:02x}")` would do. fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { - s.push_str(&format!("{b:02x}")); + write!(s, "{b:02x}").expect("writing to String never fails"); } s } @@ -682,14 +693,27 @@ async fn run_event_loop( supervisor.dispatch_block(block).await; } Some(Err(err)) => warn!(error = %err, "block stream error - continuing"), - None => {} + None => { + // alloy ends the stream with None when the + // WebSocket drops. Without this branch the loop + // keeps polling a dead stream and the operator + // sees no events with no indication anything is + // wrong. Bail out so the supervisor (or whatever + // wraps the engine) restarts us; a reconnect- + // with-backoff is the 0.3 fix. + warn!("block stream ended (WebSocket dropped?) - shutting down for restart"); + return; + } }, next = logs.next() => match next { Some(Ok((module, chain_id, log))) => { supervisor.dispatch_log(&module, chain_id, log).await; } Some(Err(err)) => warn!(error = %err, "log stream error - continuing"), - None => {} + None => { + warn!("log stream ended (WebSocket dropped?) - shutting down for restart"); + return; + } }, } } diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs index c659d9c..a73d177 100644 --- a/crates/nexum-engine/src/manifest.rs +++ b/crates/nexum-engine/src/manifest.rs @@ -213,13 +213,13 @@ pub fn enforce_capabilities<'a>( .collect(); for import_name in component_imports { - if let Some(cap) = wit_import_to_cap(import_name) { - if !declared.contains(cap) { - return Err(CapabilityViolation { - capability: cap.to_owned(), - wit_import: import_name.to_owned(), - }); - } + if let Some(cap) = wit_import_to_cap(import_name) + && !declared.contains(cap) + { + return Err(CapabilityViolation { + capability: cap.to_owned(), + wit_import: import_name.to_owned(), + }); } } Ok(()) @@ -239,16 +239,16 @@ pub fn enforce_capabilities<'a>( /// - `"wasi:io/streams@0.2.0"` → `None` fn wit_import_to_cap(import_name: &str) -> Option<&str> { let without_version = import_name.split('@').next().unwrap_or(import_name); - let iface = if let Some(i) = without_version.strip_prefix("nexum:host/") { - i - } else if let Some(i) = without_version.strip_prefix("shepherd:cow/") { - i - } else { - return None; - }; + let iface = without_version + .strip_prefix("nexum:host/") + .or_else(|| without_version.strip_prefix("shepherd:cow/"))?; // Only return Some for functional capabilities. Type-only packages // (like nexum:host/types) are shared data definitions, not capabilities. - if KNOWN_CAPABILITIES.contains(&iface) { Some(iface) } else { None } + if KNOWN_CAPABILITIES.contains(&iface) { + Some(iface) + } else { + None + } } /// Read `module.toml` from `path`, parse, validate, and emit a deprecation diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 4b7823d..631696e 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -276,11 +276,8 @@ impl Supervisor { /// `block.chain_id`. Returns the number of modules invoked. /// Modules that trap are marked dead and excluded from future dispatch. pub async fn dispatch_block(&mut self, block: crate::nexum::host::types::Block) -> usize { + let chain_id = block.chain_id; let event = crate::nexum::host::types::Event::Block(block); - let chain_id = match &event { - crate::nexum::host::types::Event::Block(b) => b.chain_id, - _ => unreachable!(), - }; let mut dispatched = 0; for module in &mut self.modules { if !module.alive { @@ -499,12 +496,15 @@ mod tests { linker } - fn temp_local_store() -> crate::host::local_store_redb::LocalStore { + /// Return `(dir, store)` so the test holds the `TempDir` for the + /// duration of the test scope and cleans it up on drop. Forgetting + /// the dir (the old `ManuallyDrop` approach) leaks it for the + /// entire process lifetime. + fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("ls.redb"); - // Leak the dir so the file stays alive for the duration of the test. - let _ = std::mem::ManuallyDrop::new(dir); - crate::host::local_store_redb::LocalStore::open(path).expect("local store") + let store = crate::host::local_store_redb::LocalStore::open(path).expect("local store"); + (dir, store) } // ── E2E tests ───────────────────────────────────────────────────────── @@ -519,7 +519,7 @@ mod tests { let linker = make_linker(&engine); let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); let provider_pool = crate::host::provider_pool::ProviderPool::empty(); - let local_store = temp_local_store(); + let (_dir, local_store) = temp_local_store(); let supervisor = Supervisor::boot_single( &engine, @@ -566,7 +566,7 @@ chain_id = 1 let linker = make_linker(&engine); let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); let provider_pool = crate::host::provider_pool::ProviderPool::empty(); - let local_store = temp_local_store(); + let (_dir, local_store) = temp_local_store(); let mut supervisor = Supervisor::boot_single( &engine, From 9a97ba9f8a91bf0a43b944d4ae4b7b034bf02d91 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Sat, 13 Jun 2026 09:29:31 -0300 Subject: [PATCH 11/15] refactor(manifest): split into types/load/capabilities/error submodules Move the manifest.rs monolith into a directory module with four focused submodules (types, load, capabilities, error). Includes the Subscription enum and the four PR #9 tests for subscription parsing. Behaviour unchanged - pure code motion. --- crates/nexum-engine/src/manifest.rs | 575 ------------------ .../nexum-engine/src/manifest/capabilities.rs | 162 +++++ crates/nexum-engine/src/manifest/error.rs | 51 ++ crates/nexum-engine/src/manifest/load.rs | 253 ++++++++ crates/nexum-engine/src/manifest/mod.rs | 40 ++ crates/nexum-engine/src/manifest/types.rs | 123 ++++ 6 files changed, 629 insertions(+), 575 deletions(-) delete mode 100644 crates/nexum-engine/src/manifest.rs create mode 100644 crates/nexum-engine/src/manifest/capabilities.rs create mode 100644 crates/nexum-engine/src/manifest/error.rs create mode 100644 crates/nexum-engine/src/manifest/load.rs create mode 100644 crates/nexum-engine/src/manifest/mod.rs create mode 100644 crates/nexum-engine/src/manifest/types.rs diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs deleted file mode 100644 index a73d177..0000000 --- a/crates/nexum-engine/src/manifest.rs +++ /dev/null @@ -1,575 +0,0 @@ -//! `module.toml` parser and capability-enforcement helpers (0.2 scope). -//! -//! 0.2 intentionally ships a slim subset of the manifest spec: -//! -//! - `[capabilities].required` is parsed and validated (names must be in -//! the known capability set; the 0.2 reference engine always provides -//! all of them, so this is a sanity check + future-proofing). -//! - `[capabilities].optional` is parsed and logged; trap-stub fallback -//! for absent optionals is deferred to 0.3. -//! - `[capabilities.http].allow` is parsed and consulted by the `http` -//! host impl before any outbound call. -//! - `[config]` is flattened to `Vec<(String, String)>` and passed to the -//! module's `init`. Typed `config-value` variant is deferred to 0.3. -//! -//! When the manifest file is missing or has no `[capabilities]` section, -//! a deprecation warning is emitted and the engine falls back to 0.1 -//! behaviour (treat every linked capability as required). This fallback -//! will be removed in 0.3. - -use std::collections::HashSet; -use std::path::Path; - -use serde::Deserialize; - -/// Capability names recognised by the 0.2 reference engine. Matches the -/// interfaces the `shepherd` world links into the linker. -pub const KNOWN_CAPABILITIES: &[&str] = &[ - "chain", - "identity", - "local-store", - "remote-store", - "messaging", - "logging", - "clock", - "random", - "http", - // Domain-extension caps (provided by the shepherd world only): - "cow-api", -]; - -#[derive(Debug, Deserialize, Default)] -pub struct Manifest { - #[serde(default)] - pub module: ModuleSection, - #[serde(default)] - pub capabilities: Option, - #[serde(default)] - pub config: toml::Table, - /// Event subscriptions the runtime wires before calling - /// `_init`. See `docs/02-modules-events-packaging.md` for the - /// schema; 0.2 implements `block` and `log` kinds, `cron` is - /// parsed and ignored (deferred to 0.3). - #[serde(default, rename = "subscription")] - pub subscriptions: Vec, -} - -/// One `[[subscription]]` table in `module.toml`. -/// -/// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. Unknown kinds are surfaced -/// at load time so a typo does not silently disable an event source. -#[derive(Debug, Deserialize, Clone)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum Subscription { - /// New-block events. Fan-out is shared per chain - the - /// supervisor opens one subscription per chain id and routes to - /// every module that asked for blocks on that chain. - Block { - /// EVM chain id. - chain_id: u64, - }, - /// Log events matching `address` + topic-0. Fan-out is - /// per-module - the supervisor opens one subscription per - /// `[[subscription]]` entry and tags emitted events with the - /// owning module. - Log { - /// EVM chain id. - chain_id: u64, - /// Contract address as `0x`-prefixed 20-byte hex. Optional. - #[serde(default)] - address: Option, - /// Topic-0 of the event the module wants to consume. `0x`- - /// prefixed 32-byte hex. Optional - when absent the - /// subscription matches every event from the address(es). - #[serde(default)] - event_signature: Option, - }, - /// Cron-scheduled tick. 0.2 parses but does not dispatch; the - /// supervisor emits a warning so the operator knows the - /// declaration is currently inert. `schedule` is preserved so a - /// 0.3 dispatcher can pick it up without re-parsing the manifest. - Cron { - /// Standard 5-field cron expression. - #[allow(dead_code)] - schedule: String, - }, -} - -#[derive(Debug, Deserialize, Default)] -#[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. -pub struct ModuleSection { - #[serde(default)] - pub name: String, - #[serde(default)] - pub version: String, - #[serde(default)] - pub component: String, -} - -#[derive(Debug, Deserialize, Default)] -pub struct CapabilitiesSection { - #[serde(default)] - pub required: Vec, - #[serde(default)] - pub optional: Vec, - #[serde(default)] - pub http: Option, -} - -#[derive(Debug, Deserialize, Default)] -pub struct HttpSection { - #[serde(default)] - pub allow: Vec, -} - -/// Errors returned while loading or validating a manifest. -#[derive(Debug)] -pub enum ParseError { - Io(std::io::Error), - Toml(toml::de::Error), - UnknownCapability(String), -} - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(e) => write!(f, "manifest: i/o: {e}"), - Self::Toml(e) => write!(f, "manifest: parse: {e}"), - Self::UnknownCapability(name) => write!( - f, - "manifest: unknown capability {:?} in [capabilities].required (known: {})", - name, - KNOWN_CAPABILITIES.join(", ") - ), - } - } -} - -impl std::error::Error for ParseError {} - -/// Loaded + validated manifest, plus its source path for diagnostics. -#[derive(Debug)] -pub struct LoadedManifest { - pub manifest: Manifest, - /// Hosts to allow for `http::fetch`. Each entry is either an exact - /// hostname or a `*.suffix` wildcard. - pub http_allowlist: Vec, - /// `[config]` flattened to `(key, stringified-value)` pairs ready to - /// hand to a module's `init`. TOML scalars (string, integer, float, - /// boolean) become their text form. Arrays and tables are rendered as - /// their TOML representation. - pub config: Vec<(String, String)>, -} - -/// Error returned when a component's WIT imports exceed its declared capabilities. -#[derive(Debug)] -pub struct CapabilityViolation { - /// Capability name (e.g. `"remote-store"`). - pub capability: String, - /// Full WIT import name as it appeared in the component (e.g. - /// `"nexum:host/remote-store@0.2.0"`). - pub wit_import: String, -} - -impl std::fmt::Display for CapabilityViolation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "component imports `{}` ({}) but it is not listed in \ - [capabilities].required or [capabilities].optional", - self.capability, self.wit_import - ) - } -} - -impl std::error::Error for CapabilityViolation {} - -/// Check that every capability-bearing WIT import of the component is covered -/// by the module's manifest declarations. Call this after loading the -/// component but before instantiation. -/// -/// When `[capabilities]` is absent the manifest is in 0.1-fallback mode and -/// all imports are allowed; the caller is expected to have already emitted -/// a deprecation warning. -/// -/// `component_imports` should be the iterator returned by -/// `component.component_type().imports(&engine)` - pass the **name** part -/// (`&str`) of each `(&str, ComponentItem)` tuple. -pub fn enforce_capabilities<'a>( - loaded: &LoadedManifest, - component_imports: impl Iterator, -) -> Result<(), CapabilityViolation> { - let caps = match loaded.manifest.capabilities.as_ref() { - None => return Ok(()), // 0.1-fallback: no enforcement - Some(c) => c, - }; - - let declared: HashSet<&str> = caps - .required - .iter() - .chain(caps.optional.iter()) - .map(String::as_str) - .collect(); - - for import_name in component_imports { - if let Some(cap) = wit_import_to_cap(import_name) - && !declared.contains(cap) - { - return Err(CapabilityViolation { - capability: cap.to_owned(), - wit_import: import_name.to_owned(), - }); - } - } - Ok(()) -} - -/// Map a WIT import name to a capability name, or `None` for non-capability -/// imports. -/// -/// Returns `Some` only for functional interfaces that appear in -/// `KNOWN_CAPABILITIES`. Type-only packages (e.g. `nexum:host/types`) and -/// WASI system interfaces are treated as non-capability and ignored. -/// -/// Examples: -/// - `"nexum:host/chain@0.2.0"` → `Some("chain")` -/// - `"shepherd:cow/cow-api@0.2.0"` → `Some("cow-api")` -/// - `"nexum:host/types@0.2.0"` → `None` (type-only, not a capability) -/// - `"wasi:io/streams@0.2.0"` → `None` -fn wit_import_to_cap(import_name: &str) -> Option<&str> { - let without_version = import_name.split('@').next().unwrap_or(import_name); - let iface = without_version - .strip_prefix("nexum:host/") - .or_else(|| without_version.strip_prefix("shepherd:cow/"))?; - // Only return Some for functional capabilities. Type-only packages - // (like nexum:host/types) are shared data definitions, not capabilities. - if KNOWN_CAPABILITIES.contains(&iface) { - Some(iface) - } else { - None - } -} - -/// Read `module.toml` from `path`, parse, validate, and emit a deprecation -/// warning if `[capabilities]` is absent (0.1-compat fallback). -pub fn load(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(ParseError::Io)?; - let manifest: Manifest = toml::from_str(&raw).map_err(ParseError::Toml)?; - - let caps = manifest.capabilities.as_ref(); - if caps.is_none() { - eprintln!( - "[deprecation] no [capabilities] section in module.toml - \ - defaulting to all-required (0.1 behaviour). This default \ - will be removed in 0.3; add an explicit [capabilities] block." - ); - } - - if let Some(c) = caps { - let known: HashSet<&str> = KNOWN_CAPABILITIES.iter().copied().collect(); - for name in c.required.iter().chain(c.optional.iter()) { - if !known.contains(name.as_str()) { - return Err(ParseError::UnknownCapability(name.clone())); - } - } - if !c.required.is_empty() { - eprintln!( - "[manifest] required capabilities: {}", - c.required.join(", ") - ); - } - if !c.optional.is_empty() { - eprintln!( - "[manifest] optional capabilities (advisory in 0.2; trap-stub fallback \ - ships in 0.3): {}", - c.optional.join(", ") - ); - } - } - - let http_allowlist = caps - .and_then(|c| c.http.as_ref()) - .map(|h| h.allow.clone()) - .unwrap_or_default(); - if !http_allowlist.is_empty() { - eprintln!("[manifest] http allowlist: {}", http_allowlist.join(", ")); - } - - let config = manifest - .config - .iter() - .map(|(k, v)| (k.clone(), stringify_toml_value(v))) - .collect(); - - Ok(LoadedManifest { - manifest, - http_allowlist, - config, - }) -} - -/// Synthesise a "0.1 fallback" manifest for when no `module.toml` is found. -/// Emits the same deprecation warning as a missing-section manifest. -pub fn fallback_manifest() -> LoadedManifest { - eprintln!( - "[deprecation] no module.toml found - defaulting to all-required \ - (0.1 behaviour). This default will be removed in 0.3; ship a \ - module.toml alongside your component." - ); - LoadedManifest { - manifest: Manifest::default(), - http_allowlist: Vec::new(), - config: Vec::new(), - } -} - -/// Check whether `host` matches any pattern in the allowlist. Patterns are -/// either exact (`api.example.com`) or `*.suffix` wildcards which match -/// any subdomain of `suffix` (but not `suffix` itself). -pub fn host_allowed(host: &str, allowlist: &[String]) -> bool { - let host = host.to_ascii_lowercase(); - allowlist.iter().any(|pat| { - let pat = pat.to_ascii_lowercase(); - if let Some(suffix) = pat.strip_prefix("*.") { - host.ends_with(&format!(".{suffix}")) - } else { - host == pat - } - }) -} - -/// Extract the host component from a URL. Returns `None` for non-http(s) -/// schemes or malformed input. Intentionally simple - adds no `url` -/// crate dependency. -pub fn extract_host(url: &str) -> Option<&str> { - let after_scheme = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://"))?; - let host_end = after_scheme - .find('/') - .or_else(|| after_scheme.find('?')) - .unwrap_or(after_scheme.len()); - let host = &after_scheme[..host_end]; - // strip optional user-info and port. - let host = host.rsplit('@').next().unwrap_or(host); - let host = host.split(':').next().unwrap_or(host); - if host.is_empty() { None } else { Some(host) } -} - -fn stringify_toml_value(v: &toml::Value) -> String { - match v { - toml::Value::String(s) => s.clone(), - toml::Value::Integer(i) => i.to_string(), - toml::Value::Float(f) => f.to_string(), - toml::Value::Boolean(b) => b.to_string(), - toml::Value::Datetime(d) => d.to_string(), - toml::Value::Array(_) | toml::Value::Table(_) => v.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_host_handles_common_shapes() { - assert_eq!( - extract_host("https://api.example.com/v1/x"), - Some("api.example.com") - ); - assert_eq!(extract_host("http://example.com"), Some("example.com")); - assert_eq!( - extract_host("https://user:pw@host.example.com:8443/x"), - Some("host.example.com") - ); - assert_eq!(extract_host("https://example.com?q=1"), Some("example.com")); - assert_eq!(extract_host("ftp://example.com"), None); - assert_eq!(extract_host("not a url"), None); - } - - #[test] - fn host_allowed_exact_and_wildcard() { - let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; - assert!(host_allowed("api.cow.fi", &allow)); - assert!(!host_allowed("evil.api.cow.fi", &allow)); - assert!(host_allowed("foo.discord.com", &allow)); - assert!(host_allowed("a.b.discord.com", &allow)); - assert!(!host_allowed("discord.com", &allow)); - assert!(!host_allowed("nope.example", &allow)); - } - - // ── capability enforcement ──────────────────────────────────────────── - - #[test] - fn wit_import_to_cap_nexum_host() { - assert_eq!(wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); - assert_eq!( - wit_import_to_cap("nexum:host/local-store@0.2.0"), - Some("local-store") - ); - assert_eq!(wit_import_to_cap("nexum:host/http@0.2.0"), Some("http")); - } - - #[test] - fn wit_import_to_cap_shepherd_cow() { - assert_eq!( - wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), - Some("cow-api") - ); - } - - #[test] - fn wit_import_to_cap_wasi_is_none() { - assert_eq!(wit_import_to_cap("wasi:io/streams@0.2.0"), None); - assert_eq!(wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); - } - - fn manifest_with_caps(required: &[&str], optional: &[&str]) -> LoadedManifest { - LoadedManifest { - manifest: Manifest { - capabilities: Some(CapabilitiesSection { - required: required.iter().map(|s| s.to_string()).collect(), - optional: optional.iter().map(|s| s.to_string()).collect(), - http: None, - }), - ..Default::default() - }, - http_allowlist: vec![], - config: vec![], - } - } - - fn manifest_no_caps() -> LoadedManifest { - LoadedManifest { - manifest: Manifest::default(), - http_allowlist: vec![], - config: vec![], - } - } - - #[test] - fn enforce_passes_when_caps_absent() { - let loaded = manifest_no_caps(); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); - } - - #[test] - fn enforce_passes_when_all_imports_declared() { - let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); - let imports = [ - "nexum:host/chain@0.2.0", - "shepherd:cow/cow-api@0.2.0", - "nexum:host/http@0.2.0", - "wasi:io/streams@0.2.0", - ]; - assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); - } - - #[test] - fn enforce_rejects_undeclared_import() { - let loaded = manifest_with_caps(&["chain"], &[]); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - let err = enforce_capabilities(&loaded, imports.into_iter()).unwrap_err(); - assert_eq!(err.capability, "remote-store"); - } - - #[test] - fn enforce_optional_caps_are_also_allowed() { - let loaded = manifest_with_caps(&["chain"], &["remote-store"]); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); - } - - // ── manifest parsing ────────────────────────────────────────────────── - - #[test] - fn load_parses_block_and_log_subscriptions() { - let toml = r#" -[module] -name = "twap-monitor" - -[capabilities] -required = ["chain", "local-store"] - -[[subscription]] -kind = "block" -chain_id = 1 - -[[subscription]] -kind = "log" -chain_id = 1 -address = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" -event_signature = "0x00000000000000000000000000000000000000000000000000000000deadbeef" -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert_eq!(manifest.module.name, "twap-monitor"); - assert_eq!(manifest.subscriptions.len(), 2); - assert!(matches!( - &manifest.subscriptions[0], - Subscription::Block { chain_id: 1 } - )); - if let Subscription::Log { chain_id, address, .. } = &manifest.subscriptions[1] { - assert_eq!(*chain_id, 1); - assert!(address.is_some()); - } else { - panic!("expected Log subscription"); - } - } - - #[test] - fn load_parses_cron_subscription() { - let toml = r#" -[module] -name = "scheduler" - -[[subscription]] -kind = "cron" -schedule = "*/5 * * * *" -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert!(matches!( - &manifest.subscriptions[0], - Subscription::Cron { .. } - )); - } - - #[test] - fn load_rejects_unknown_capability() { - let toml = r#" -[module] -name = "bad" - -[capabilities] -required = ["chain", "not-a-real-cap"] -"#; - // Write to a temp file so load() can read it. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("module.toml"); - std::fs::write(&path, toml).unwrap(); - let err = load(&path).unwrap_err(); - assert!(matches!(err, ParseError::UnknownCapability(ref name) if name == "not-a-real-cap")); - } - - #[test] - fn load_parses_config_table() { - let toml = r#" -[module] -name = "example" - -[config] -chain_id = 1 -label = "mainnet" -enabled = true -"#; - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("module.toml"); - std::fs::write(&path, toml).unwrap(); - let loaded = load(&path).unwrap(); - let config: std::collections::HashMap<_, _> = loaded.config.into_iter().collect(); - assert_eq!(config.get("chain_id").map(String::as_str), Some("1")); - assert_eq!(config.get("label").map(String::as_str), Some("mainnet")); - assert_eq!(config.get("enabled").map(String::as_str), Some("true")); - } -} diff --git a/crates/nexum-engine/src/manifest/capabilities.rs b/crates/nexum-engine/src/manifest/capabilities.rs new file mode 100644 index 0000000..fddb788 --- /dev/null +++ b/crates/nexum-engine/src/manifest/capabilities.rs @@ -0,0 +1,162 @@ +//! Capability enforcement: cross-checks the component's WIT imports +//! against the `[capabilities]` block declared in `module.toml`. + +use std::collections::HashSet; + +use super::error::CapabilityViolation; +use super::types::{KNOWN_CAPABILITIES, LoadedManifest}; + +/// Check that every capability-bearing WIT import of the component is covered +/// by the module's manifest declarations. Call this after loading the +/// component but before instantiation. +/// +/// When `[capabilities]` is absent the manifest is in 0.1-fallback mode and +/// all imports are allowed; the caller is expected to have already emitted +/// a deprecation warning. +/// +/// `component_imports` should be the iterator returned by +/// `component.component_type().imports(&engine)` - pass the **name** part +/// (`&str`) of each `(&str, ComponentItem)` tuple. +pub fn enforce_capabilities<'a>( + loaded: &LoadedManifest, + component_imports: impl Iterator, +) -> Result<(), CapabilityViolation> { + let caps = match loaded.manifest.capabilities.as_ref() { + None => return Ok(()), // 0.1-fallback: no enforcement + Some(c) => c, + }; + + let declared: HashSet<&str> = caps + .required + .iter() + .chain(caps.optional.iter()) + .map(String::as_str) + .collect(); + + for import_name in component_imports { + if let Some(cap) = wit_import_to_cap(import_name) + && !declared.contains(cap) + { + return Err(CapabilityViolation { + capability: cap.to_owned(), + wit_import: import_name.to_owned(), + }); + } + } + Ok(()) +} + +/// Map a WIT import name to a capability name, or `None` for non-capability +/// imports. +/// +/// Returns `Some(iface)` only for interfaces in [`KNOWN_CAPABILITIES`]; +/// type-only packages like `nexum:host/types` and unrelated namespaces +/// (`wasi:*`) fall through to `None` so they do not need a manifest +/// declaration. +/// +/// Examples: +/// - `"nexum:host/chain@0.2.0"` -> `Some("chain")` +/// - `"shepherd:cow/cow-api@0.2.0"` -> `Some("cow-api")` +/// - `"nexum:host/types@0.2.0"` -> `None` (type-only, not a capability) +/// - `"wasi:io/streams@0.2.0"` -> `None` +pub(super) fn wit_import_to_cap(import_name: &str) -> Option<&str> { + let without_version = import_name.split('@').next().unwrap_or(import_name); + let iface = without_version + .strip_prefix("nexum:host/") + .or_else(|| without_version.strip_prefix("shepherd:cow/"))?; + if KNOWN_CAPABILITIES.contains(&iface) { + Some(iface) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::types::{CapabilitiesSection, Manifest}; + + #[test] + fn wit_import_to_cap_nexum_host() { + assert_eq!(wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!( + wit_import_to_cap("nexum:host/local-store@0.2.0"), + Some("local-store") + ); + assert_eq!(wit_import_to_cap("nexum:host/http@0.2.0"), Some("http")); + } + + #[test] + fn wit_import_to_cap_shepherd_cow() { + assert_eq!( + wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), + Some("cow-api") + ); + } + + #[test] + fn wit_import_to_cap_wasi_is_none() { + assert_eq!(wit_import_to_cap("wasi:io/streams@0.2.0"), None); + assert_eq!(wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); + assert_eq!(wit_import_to_cap("wasi:sockets/tcp@0.2.0"), None); + } + + fn manifest_with_caps(required: &[&str], optional: &[&str]) -> LoadedManifest { + LoadedManifest { + manifest: Manifest { + capabilities: Some(CapabilitiesSection { + required: required.iter().map(|s| s.to_string()).collect(), + optional: optional.iter().map(|s| s.to_string()).collect(), + http: None, + }), + ..Default::default() + }, + http_allowlist: vec![], + config: vec![], + } + } + + fn manifest_no_caps() -> LoadedManifest { + LoadedManifest { + manifest: Manifest::default(), + http_allowlist: vec![], + config: vec![], + } + } + + #[test] + fn enforce_passes_when_caps_absent() { + // 0.1-fallback: no capabilities section -> all imports allowed + let loaded = manifest_no_caps(); + let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); + } + + #[test] + fn enforce_passes_when_all_imports_declared() { + let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); + let imports = [ + "nexum:host/chain@0.2.0", + "shepherd:cow/cow-api@0.2.0", + "nexum:host/http@0.2.0", + "wasi:io/streams@0.2.0", // wasi is always skipped + ]; + assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); + } + + #[test] + fn enforce_rejects_undeclared_import() { + let loaded = manifest_with_caps(&["chain"], &[]); + // module imports remote-store but didn't declare it + let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + let err = enforce_capabilities(&loaded, imports.into_iter()).unwrap_err(); + assert_eq!(err.capability, "remote-store"); + } + + #[test] + fn enforce_optional_caps_are_also_allowed() { + let loaded = manifest_with_caps(&["chain"], &["remote-store"]); + let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + assert!(enforce_capabilities(&loaded, imports.into_iter()).is_ok()); + } +} diff --git a/crates/nexum-engine/src/manifest/error.rs b/crates/nexum-engine/src/manifest/error.rs new file mode 100644 index 0000000..98bf7d7 --- /dev/null +++ b/crates/nexum-engine/src/manifest/error.rs @@ -0,0 +1,51 @@ +//! Error types for manifest parsing and capability enforcement. + +use super::types::KNOWN_CAPABILITIES; + +/// Errors returned while loading or validating a manifest. +#[derive(Debug)] +pub enum ParseError { + Io(std::io::Error), + Toml(toml::de::Error), + UnknownCapability(String), +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "manifest: i/o: {e}"), + Self::Toml(e) => write!(f, "manifest: parse: {e}"), + Self::UnknownCapability(name) => write!( + f, + "manifest: unknown capability {:?} in [capabilities].required (known: {})", + name, + KNOWN_CAPABILITIES.join(", ") + ), + } + } +} + +impl std::error::Error for ParseError {} + +/// Error returned when a component's WIT imports exceed its declared capabilities. +#[derive(Debug)] +pub struct CapabilityViolation { + /// Capability name (e.g. `"remote-store"`). + pub capability: String, + /// Full WIT import name as it appeared in the component (e.g. + /// `"nexum:host/remote-store@0.2.0"`). + pub wit_import: String, +} + +impl std::fmt::Display for CapabilityViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "component imports `{}` ({}) but it is not listed in \ + [capabilities].required or [capabilities].optional", + self.capability, self.wit_import + ) + } +} + +impl std::error::Error for CapabilityViolation {} diff --git a/crates/nexum-engine/src/manifest/load.rs b/crates/nexum-engine/src/manifest/load.rs new file mode 100644 index 0000000..b857a76 --- /dev/null +++ b/crates/nexum-engine/src/manifest/load.rs @@ -0,0 +1,253 @@ +//! Parse `module.toml` from disk, validate, and emit operator-visible +//! warnings. +//! +//! Also exposes the small URL/host helpers the `http` host backend +//! uses to enforce the manifest's `[capabilities.http].allow` list at +//! request time. + +use std::collections::HashSet; +use std::path::Path; + +use super::error::ParseError; +use super::types::{KNOWN_CAPABILITIES, LoadedManifest, Manifest}; + +/// Read `module.toml` from `path`, parse, validate, and emit a deprecation +/// warning if `[capabilities]` is absent (0.1-compat fallback). +pub fn load(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(ParseError::Io)?; + let manifest: Manifest = toml::from_str(&raw).map_err(ParseError::Toml)?; + + let caps = manifest.capabilities.as_ref(); + if caps.is_none() { + eprintln!( + "[deprecation] no [capabilities] section in module.toml - \ + defaulting to all-required (0.1 behaviour). This default \ + will be removed in 0.3; add an explicit [capabilities] block." + ); + } + + if let Some(c) = caps { + let known: HashSet<&str> = KNOWN_CAPABILITIES.iter().copied().collect(); + for name in c.required.iter().chain(c.optional.iter()) { + if !known.contains(name.as_str()) { + return Err(ParseError::UnknownCapability(name.clone())); + } + } + if !c.required.is_empty() { + eprintln!( + "[manifest] required capabilities: {}", + c.required.join(", ") + ); + } + if !c.optional.is_empty() { + eprintln!( + "[manifest] optional capabilities (advisory in 0.2; trap-stub fallback \ + ships in 0.3): {}", + c.optional.join(", ") + ); + } + } + + let http_allowlist = caps + .and_then(|c| c.http.as_ref()) + .map(|h| h.allow.clone()) + .unwrap_or_default(); + if !http_allowlist.is_empty() { + eprintln!("[manifest] http allowlist: {}", http_allowlist.join(", ")); + } + + let config = manifest + .config + .iter() + .map(|(k, v)| (k.clone(), stringify_toml_value(v))) + .collect(); + + Ok(LoadedManifest { + manifest, + http_allowlist, + config, + }) +} + +/// Synthesise a "0.1 fallback" manifest for when no `module.toml` is found. +/// Emits the same deprecation warning as a missing-section manifest. +pub fn fallback_manifest() -> LoadedManifest { + eprintln!( + "[deprecation] no module.toml found - defaulting to all-required \ + (0.1 behaviour). This default will be removed in 0.3; ship a \ + module.toml alongside your component." + ); + LoadedManifest { + manifest: Manifest::default(), + http_allowlist: Vec::new(), + config: Vec::new(), + } +} + +/// Check whether `host` matches any pattern in the allowlist. Patterns are +/// either exact (`api.example.com`) or `*.suffix` wildcards which match +/// any subdomain of `suffix` (but not `suffix` itself). +pub fn host_allowed(host: &str, allowlist: &[String]) -> bool { + let host = host.to_ascii_lowercase(); + allowlist.iter().any(|pat| { + let pat = pat.to_ascii_lowercase(); + if let Some(suffix) = pat.strip_prefix("*.") { + host.ends_with(&format!(".{suffix}")) + } else { + host == pat + } + }) +} + +/// Extract the host component from a URL. Returns `None` for non-http(s) +/// schemes or malformed input. Intentionally simple - adds no `url` +/// crate dependency. +pub fn extract_host(url: &str) -> Option<&str> { + let after_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let host_end = after_scheme + .find('/') + .or_else(|| after_scheme.find('?')) + .unwrap_or(after_scheme.len()); + let host = &after_scheme[..host_end]; + // strip optional user-info and port. + let host = host.rsplit('@').next().unwrap_or(host); + let host = host.split(':').next().unwrap_or(host); + if host.is_empty() { None } else { Some(host) } +} + +fn stringify_toml_value(v: &toml::Value) -> String { + match v { + toml::Value::String(s) => s.clone(), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Boolean(b) => b.to_string(), + toml::Value::Datetime(d) => d.to_string(), + toml::Value::Array(_) | toml::Value::Table(_) => v.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::types::Subscription; + + #[test] + fn load_parses_block_and_log_subscriptions() { + let toml = r#" +[module] +name = "twap-monitor" + +[capabilities] +required = ["chain", "local-store"] + +[[subscription]] +kind = "block" +chain_id = 1 + +[[subscription]] +kind = "log" +chain_id = 1 +address = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" +event_signature = "0x00000000000000000000000000000000000000000000000000000000deadbeef" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert_eq!(manifest.module.name, "twap-monitor"); + assert_eq!(manifest.subscriptions.len(), 2); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Block { chain_id: 1 } + )); + if let Subscription::Log { + chain_id, address, .. + } = &manifest.subscriptions[1] + { + assert_eq!(*chain_id, 1); + assert!(address.is_some()); + } else { + panic!("expected Log subscription"); + } + } + + #[test] + fn load_parses_cron_subscription() { + let toml = r#" +[module] +name = "scheduler" + +[[subscription]] +kind = "cron" +schedule = "*/5 * * * *" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Cron { .. } + )); + } + + #[test] + fn load_rejects_unknown_capability() { + let toml = r#" +[module] +name = "bad" + +[capabilities] +required = ["chain", "not-a-real-cap"] +"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("module.toml"); + std::fs::write(&path, toml).unwrap(); + let err = load(&path).unwrap_err(); + assert!(matches!(err, ParseError::UnknownCapability(ref name) if name == "not-a-real-cap")); + } + + #[test] + fn load_parses_config_table() { + let toml = r#" +[module] +name = "example" + +[config] +chain_id = 1 +label = "mainnet" +enabled = true +"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("module.toml"); + std::fs::write(&path, toml).unwrap(); + let loaded = load(&path).unwrap(); + let config: std::collections::HashMap<_, _> = loaded.config.into_iter().collect(); + assert_eq!(config.get("chain_id").map(String::as_str), Some("1")); + assert_eq!(config.get("label").map(String::as_str), Some("mainnet")); + assert_eq!(config.get("enabled").map(String::as_str), Some("true")); + } + + #[test] + fn extract_host_handles_common_shapes() { + assert_eq!( + extract_host("https://api.example.com/v1/x"), + Some("api.example.com") + ); + assert_eq!(extract_host("http://example.com"), Some("example.com")); + assert_eq!( + extract_host("https://user:pw@host.example.com:8443/x"), + Some("host.example.com") + ); + assert_eq!(extract_host("https://example.com?q=1"), Some("example.com")); + assert_eq!(extract_host("ftp://example.com"), None); + assert_eq!(extract_host("not a url"), None); + } + + #[test] + fn host_allowed_exact_and_wildcard() { + let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; + assert!(host_allowed("api.cow.fi", &allow)); + assert!(!host_allowed("evil.api.cow.fi", &allow)); + assert!(host_allowed("foo.discord.com", &allow)); + assert!(host_allowed("a.b.discord.com", &allow)); + assert!(!host_allowed("discord.com", &allow)); + assert!(!host_allowed("nope.example", &allow)); + } +} diff --git a/crates/nexum-engine/src/manifest/mod.rs b/crates/nexum-engine/src/manifest/mod.rs new file mode 100644 index 0000000..9cd00b6 --- /dev/null +++ b/crates/nexum-engine/src/manifest/mod.rs @@ -0,0 +1,40 @@ +//! `module.toml` parser and capability-enforcement helpers (0.2 scope). +//! +//! 0.2 intentionally ships a slim subset of the manifest spec: +//! +//! - `[capabilities].required` is parsed and validated (names must be in +//! the known capability set; the 0.2 reference engine always provides +//! all of them, so this is a sanity check + future-proofing). +//! - `[capabilities].optional` is parsed and logged; trap-stub fallback +//! for absent optionals is deferred to 0.3. +//! - `[capabilities.http].allow` is parsed and consulted by the `http` +//! host impl before any outbound call. +//! - `[config]` is flattened to `Vec<(String, String)>` and passed to the +//! module's `init`. Typed `config-value` variant is deferred to 0.3. +//! +//! When the manifest file is missing or has no `[capabilities]` section, +//! a deprecation warning is emitted and the engine falls back to 0.1 +//! behaviour (treat every linked capability as required). This fallback +//! will be removed in 0.3. +//! +//! ## Layout +//! +//! - [`types`]: the serde `Manifest` shape + `LoadedManifest` the engine +//! actually consumes, plus the `KNOWN_CAPABILITIES` registry. +//! - [`load`]: `module.toml` -> `LoadedManifest`, plus the host/URL +//! helpers the `http` backend uses at request time. +//! - [`capabilities`]: WIT-import vs declared-capabilities cross-check. +//! - [`error`]: `ParseError`, `CapabilityViolation`. + +mod capabilities; +mod error; +mod load; +mod types; + +pub use capabilities::enforce_capabilities; +pub use load::{extract_host, fallback_manifest, host_allowed, load}; +pub use types::{LoadedManifest, Subscription}; +// CapabilityViolation, ParseError, and the *Section structs are +// reachable through these functions' return / argument types; +// consumers that need to name them directly do so via +// `crate::manifest::error::*` or `::types::*`. diff --git a/crates/nexum-engine/src/manifest/types.rs b/crates/nexum-engine/src/manifest/types.rs new file mode 100644 index 0000000..403a201 --- /dev/null +++ b/crates/nexum-engine/src/manifest/types.rs @@ -0,0 +1,123 @@ +//! Data structures: `Manifest`, sections, and `LoadedManifest`. +//! +//! Plain serde shapes plus the `KNOWN_CAPABILITIES` registry. The parsing +//! and validation logic lives in [`super::load`]; capability enforcement +//! in [`super::capabilities`]. + +use serde::Deserialize; + +/// Capability names recognised by the 0.2 reference engine. Matches the +/// interfaces the `shepherd` world links into the linker. +pub const KNOWN_CAPABILITIES: &[&str] = &[ + "chain", + "identity", + "local-store", + "remote-store", + "messaging", + "logging", + "clock", + "random", + "http", + // Domain-extension caps (provided by the shepherd world only): + "cow-api", +]; + +#[derive(Debug, Deserialize, Default)] +pub struct Manifest { + #[serde(default)] + pub module: ModuleSection, + #[serde(default)] + pub capabilities: Option, + #[serde(default)] + pub config: toml::Table, + /// Event subscriptions the runtime wires before calling + /// `_init`. See `docs/02-modules-events-packaging.md` for the + /// schema; 0.2 implements `block` and `log` kinds, `cron` is + /// parsed and ignored (deferred to 0.3). + #[serde(default, rename = "subscription")] + pub subscriptions: Vec, +} + +/// One `[[subscription]]` table in `module.toml`. +/// +/// The discriminator is the `kind` field; remaining fields are +/// validated per-kind by the supervisor. Unknown kinds are surfaced +/// at load time so a typo does not silently disable an event source. +#[derive(Debug, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Subscription { + /// New-block events. Fan-out is shared per chain - the + /// supervisor opens one subscription per chain id and routes to + /// every module that asked for blocks on that chain. + Block { + /// EVM chain id. + chain_id: u64, + }, + /// Log events matching `address` + topic-0. Fan-out is + /// per-module - the supervisor opens one subscription per + /// `[[subscription]]` entry and tags emitted events with the + /// owning module. + Log { + /// EVM chain id. + chain_id: u64, + /// Contract address as `0x`-prefixed 20-byte hex. Optional. + #[serde(default)] + address: Option, + /// Topic-0 of the event the module wants to consume. `0x`- + /// prefixed 32-byte hex. Optional - when absent the + /// subscription matches every event from the address(es). + #[serde(default)] + event_signature: Option, + }, + /// Cron-scheduled tick. 0.2 parses but does not dispatch; the + /// supervisor emits a warning so the operator knows the + /// declaration is currently inert. `schedule` is preserved so a + /// 0.3 dispatcher can pick it up without re-parsing the manifest. + Cron { + /// Standard 5-field cron expression. + #[allow(dead_code)] + schedule: String, + }, +} + +#[derive(Debug, Deserialize, Default)] +#[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. +pub struct ModuleSection { + #[serde(default)] + pub name: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub component: String, +} + +#[derive(Debug, Deserialize, Default)] +pub struct CapabilitiesSection { + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub optional: Vec, + #[serde(default)] + pub http: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct HttpSection { + #[serde(default)] + pub allow: Vec, +} + +/// Loaded + validated manifest, plus the data the engine needs to +/// instantiate a module. +#[derive(Debug)] +pub struct LoadedManifest { + pub manifest: Manifest, + /// Hosts to allow for `http::fetch`. Each entry is either an exact + /// hostname or a `*.suffix` wildcard. + pub http_allowlist: Vec, + /// `[config]` flattened to `(key, stringified-value)` pairs ready to + /// hand to a module's `init`. TOML scalars (string, integer, float, + /// boolean) become their text form. Arrays and tables are rendered as + /// their TOML representation. + pub config: Vec<(String, String)>, +} From 5ae63681bca893537bcb84161f65ed6562348318 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Sat, 13 Jun 2026 09:35:22 -0300 Subject: [PATCH 12/15] refactor(main): extract host impls + CLI + event loop + limits main.rs went from 739 lines of mixed bootstrap + 8 Host trait impls + CLI parser + event loop to ~125 lines of pure orchestration. New layout: - bindings.rs: wasmtime::component::bindgen!() moved out so other modules can name the generated types. - cli.rs: Cli struct + manual parser. - host/state.rs: HostState + WasiView impl. - host/error.rs: unimplemented / internal_error / hex_encode helpers. - host/impls/{chain,cow_api,identity,local_store,remote_store,messaging, logging,clock,random,http,types}.rs: one Host trait impl per file. - runtime/limits.rs: DEFAULT_FUEL_PER_EVENT + DEFAULT_MEMORY_LIMIT. - runtime/event_loop.rs: open_block_streams, open_log_streams, run, wait_for_shutdown_signal, TaggedBlockStream, TaggedLogStream. Adding a new capability is now a single new file under host/impls/ rather than a 60-80 line diff in main.rs. --- crates/nexum-engine/src/bindings.rs | 16 + crates/nexum-engine/src/cli.rs | 46 ++ crates/nexum-engine/src/host/error.rs | 42 ++ crates/nexum-engine/src/host/impls/chain.rs | 67 ++ crates/nexum-engine/src/host/impls/clock.rs | 19 + crates/nexum-engine/src/host/impls/cow_api.rs | 85 +++ crates/nexum-engine/src/host/impls/http.rs | 51 ++ .../nexum-engine/src/host/impls/identity.rs | 29 + .../src/host/impls/local_store.rs | 32 + crates/nexum-engine/src/host/impls/logging.rs | 18 + .../nexum-engine/src/host/impls/messaging.rs | 27 + crates/nexum-engine/src/host/impls/mod.rs | 19 + crates/nexum-engine/src/host/impls/random.rs | 16 + .../src/host/impls/remote_store.rs | 40 ++ crates/nexum-engine/src/host/impls/types.rs | 7 + crates/nexum-engine/src/host/mod.rs | 23 +- crates/nexum-engine/src/host/state.rs | 45 ++ crates/nexum-engine/src/main.rs | 642 +----------------- crates/nexum-engine/src/runtime/event_loop.rs | 157 +++++ crates/nexum-engine/src/runtime/limits.rs | 14 + crates/nexum-engine/src/runtime/mod.rs | 5 + crates/nexum-engine/src/supervisor.rs | 28 +- 22 files changed, 783 insertions(+), 645 deletions(-) create mode 100644 crates/nexum-engine/src/bindings.rs create mode 100644 crates/nexum-engine/src/cli.rs create mode 100644 crates/nexum-engine/src/host/error.rs create mode 100644 crates/nexum-engine/src/host/impls/chain.rs create mode 100644 crates/nexum-engine/src/host/impls/clock.rs create mode 100644 crates/nexum-engine/src/host/impls/cow_api.rs create mode 100644 crates/nexum-engine/src/host/impls/http.rs create mode 100644 crates/nexum-engine/src/host/impls/identity.rs create mode 100644 crates/nexum-engine/src/host/impls/local_store.rs create mode 100644 crates/nexum-engine/src/host/impls/logging.rs create mode 100644 crates/nexum-engine/src/host/impls/messaging.rs create mode 100644 crates/nexum-engine/src/host/impls/mod.rs create mode 100644 crates/nexum-engine/src/host/impls/random.rs create mode 100644 crates/nexum-engine/src/host/impls/remote_store.rs create mode 100644 crates/nexum-engine/src/host/impls/types.rs create mode 100644 crates/nexum-engine/src/host/state.rs create mode 100644 crates/nexum-engine/src/runtime/event_loop.rs create mode 100644 crates/nexum-engine/src/runtime/limits.rs create mode 100644 crates/nexum-engine/src/runtime/mod.rs diff --git a/crates/nexum-engine/src/bindings.rs b/crates/nexum-engine/src/bindings.rs new file mode 100644 index 0000000..d0f57dd --- /dev/null +++ b/crates/nexum-engine/src/bindings.rs @@ -0,0 +1,16 @@ +//! WIT bindings generated by `wasmtime::component::bindgen!`. +//! +//! Both `wit/nexum-host` and `wit/shepherd-cow` packages are listed +//! explicitly so wit-parser can resolve the cross-package reference +//! natively - no vendored `deps/` tree needed. The world name is fully +//! qualified. +//! +//! Every `Host` trait impl in [`crate::host::impls`] consumes types +//! generated here. + +wasmtime::component::bindgen!({ + path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + world: "shepherd:cow/shepherd", + imports: { default: async }, + exports: { default: async }, +}); diff --git a/crates/nexum-engine/src/cli.rs b/crates/nexum-engine/src/cli.rs new file mode 100644 index 0000000..6488909 --- /dev/null +++ b/crates/nexum-engine/src/cli.rs @@ -0,0 +1,46 @@ +//! Manual CLI parser. Kept hand-rolled (instead of pulling clap) because +//! the surface is small and unlikely to grow in 0.2. + +use std::path::PathBuf; + +/// Parsed CLI surface. +/// +/// `nexum-engine [ []] [--engine-config ]` +/// +/// Positional `` is a backwards-compat shortcut that +/// synthesises a one-module engine config. Production deployments pass +/// `--engine-config` and declare modules in TOML. +#[derive(Debug, Default)] +pub struct Cli { + pub wasm: Option, + pub manifest: Option, + pub engine_config: Option, +} + +impl Cli { + pub fn parse() -> Self { + let mut args = std::env::args().skip(1); + let mut cli = Self::default(); + let mut positional = Vec::new(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--engine-config" => cli.engine_config = args.next().map(PathBuf::from), + "-h" | "--help" => { + eprintln!( + "usage: nexum-engine [ []] \ + [--engine-config ]" + ); + std::process::exit(0); + } + _ => positional.push(arg), + } + } + if let Some(p) = positional.first() { + cli.wasm = Some(PathBuf::from(p)); + } + if let Some(p) = positional.get(1) { + cli.manifest = Some(PathBuf::from(p)); + } + cli + } +} diff --git a/crates/nexum-engine/src/host/error.rs b/crates/nexum-engine/src/host/error.rs new file mode 100644 index 0000000..15b7255 --- /dev/null +++ b/crates/nexum-engine/src/host/error.rs @@ -0,0 +1,42 @@ +//! Small constructors that wrap the WIT `HostError` shape, used by +//! every `Host` trait impl, plus the lowercase hex encoder shared by +//! the `cow-api` submission path. + +use crate::bindings::HostError; +use crate::bindings::nexum::host::types::HostErrorKind; + +/// `Unsupported` (HTTP 501-style) error for capabilities the engine +/// reference build does not implement yet. +pub(crate) fn unimplemented(domain: &str, detail: impl Into) -> HostError { + HostError { + domain: domain.into(), + kind: HostErrorKind::Unsupported, + code: 501, + message: detail.into(), + data: None, + } +} + +/// `Internal` (HTTP 500-style) error for unexpected backend failures. +pub(crate) fn internal_error(domain: &str, detail: impl Into) -> HostError { + HostError { + domain: domain.into(), + kind: HostErrorKind::Internal, + code: 0, + message: detail.into(), + data: None, + } +} + +/// Lowercase hex encoder. Kept in the engine binary rather than +/// pulling a `hex` crate just for one call site. Writes into the +/// pre-allocated buffer to avoid the per-byte `String` allocation +/// `format!("{b:02x}")` would do. +pub(crate) fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + write!(s, "{b:02x}").expect("writing to String never fails"); + } + s +} diff --git a/crates/nexum-engine/src/host/impls/chain.rs b/crates/nexum-engine/src/host/impls/chain.rs new file mode 100644 index 0000000..e0e30db --- /dev/null +++ b/crates/nexum-engine/src/host/impls/chain.rs @@ -0,0 +1,67 @@ +//! `nexum:host/chain`: raw JSON-RPC dispatch over alloy. + +use std::time::Instant; + +use crate::bindings::HostError; +use crate::bindings::nexum; +use crate::bindings::nexum::host::types::HostErrorKind; +use crate::host::error::internal_error; +use crate::host::provider_pool::ProviderError; +use crate::host::state::HostState; + +impl nexum::host::chain::Host for HostState { + async fn request( + &mut self, + chain_id: u64, + method: String, + params: String, + ) -> Result { + let start = Instant::now(); + tracing::debug!(chain_id, %method, "chain::request"); + let result = match self.chain.request(chain_id, method, params).await { + Ok(body) => Ok(body), + Err(ProviderError::UnknownChain(id)) => Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::Unsupported, + code: 0, + message: format!("chain {id} has no engine.toml RPC entry"), + data: None, + }), + Err(ProviderError::InvalidParams { detail, .. }) => Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::InvalidInput, + code: -32602, + message: detail, + data: None, + }), + Err(ProviderError::Rpc { detail, .. }) => Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::Internal, + code: -32603, + message: detail, + data: None, + }), + Err(err) => Err(internal_error("chain", err.to_string())), + }; + tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done"); + result + } + + async fn request_batch( + &mut self, + chain_id: u64, + requests: Vec, + ) -> Result, HostError> { + let start = Instant::now(); + tracing::debug!(chain_id, count = requests.len(), "chain::request-batch"); + let mut out = Vec::with_capacity(requests.len()); + for req in requests { + match nexum::host::chain::Host::request(self, chain_id, req.method, req.params).await { + Ok(s) => out.push(nexum::host::chain::RpcResult::Ok(s)), + Err(e) => out.push(nexum::host::chain::RpcResult::Err(e)), + } + } + tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request-batch done"); + Ok(out) + } +} diff --git a/crates/nexum-engine/src/host/impls/clock.rs b/crates/nexum-engine/src/host/impls/clock.rs new file mode 100644 index 0000000..f65b674 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/clock.rs @@ -0,0 +1,19 @@ +//! `nexum:host/clock`: wall-clock + monotonic time. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::bindings::nexum; +use crate::host::state::HostState; + +impl nexum::host::clock::Host for HostState { + async fn now_ms(&mut self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) + } + + async fn monotonic_ns(&mut self) -> u64 { + self.monotonic_baseline.elapsed().as_nanos() as u64 + } +} diff --git a/crates/nexum-engine/src/host/impls/cow_api.rs b/crates/nexum-engine/src/host/impls/cow_api.rs new file mode 100644 index 0000000..b3b51d5 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/cow_api.rs @@ -0,0 +1,85 @@ +//! `shepherd:cow/cow-api`: REST passthrough + typed `submit_order`. +//! Backend logic lives in [`crate::host::cow_orderbook`]; this is the +//! WIT-side error mapping. + +use std::time::Instant; + +use crate::bindings::nexum::host::types::HostErrorKind; +use crate::bindings::{HostError, shepherd}; +use crate::host::cow_orderbook::CowApiError; +use crate::host::error::{hex_encode, internal_error, unimplemented}; +use crate::host::state::HostState; + +impl shepherd::cow::cow_api::Host for HostState { + async fn request( + &mut self, + chain_id: u64, + method: String, + path: String, + body: Option, + ) -> Result { + let start = Instant::now(); + tracing::debug!(chain_id, %method, %path, "cow-api::request"); + let result = match self + .cow + .request(chain_id, &method, &path, body.as_deref()) + .await + { + Ok(body) => Ok(body), + Err(CowApiError::UnknownChain(id)) => Err(unimplemented( + "cow-api", + format!("chain {id} not in cowprotocol"), + )), + Err(CowApiError::BadMethod(m)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: format!("unsupported HTTP method: {m}"), + data: None, + }), + Err(CowApiError::BadPath(msg)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: msg, + data: None, + }), + Err(err) => Err(internal_error("cow-api", err.to_string())), + }; + tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::request done"); + result + } + + async fn submit_order( + &mut self, + chain_id: u64, + order_data: Vec, + ) -> Result { + let start = Instant::now(); + tracing::debug!(chain_id, bytes = order_data.len(), "cow-api::submit-order"); + let result = match self.cow.submit_order_json(chain_id, &order_data).await { + Ok(uid) => Ok(format!("0x{}", hex_encode(uid.as_slice()))), + Err(CowApiError::UnknownChain(id)) => Err(unimplemented( + "cow-api", + format!("chain {id} not in cowprotocol"), + )), + Err(CowApiError::Decode(err)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: format!("invalid OrderCreation JSON: {err}"), + data: None, + }), + Err(CowApiError::Orderbook(err)) => Err(HostError { + domain: "cow-api".into(), + kind: HostErrorKind::Denied, + code: 0, + message: err.to_string(), + data: None, + }), + Err(err) => Err(internal_error("cow-api", err.to_string())), + }; + tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::submit-order done"); + result + } +} diff --git a/crates/nexum-engine/src/host/impls/http.rs b/crates/nexum-engine/src/host/impls/http.rs new file mode 100644 index 0000000..2900d4d --- /dev/null +++ b/crates/nexum-engine/src/host/impls/http.rs @@ -0,0 +1,51 @@ +//! `nexum:host/http`: manifest allowlist check, then `Unsupported`. +//! +//! Real `fetch` lands in 0.3. The allowlist is enforced now so a +//! module that ships with an empty (or no) `[capabilities.http].allow` +//! gets denied loudly, matching the "no implicit network" stance. + +use tracing::warn; + +use crate::bindings::HostError; +use crate::bindings::nexum; +use crate::bindings::nexum::host::types::HostErrorKind; +use crate::host::error::unimplemented; +use crate::host::state::HostState; +use crate::manifest::{extract_host, host_allowed}; + +impl nexum::host::http::Host for HostState { + async fn fetch( + &mut self, + req: nexum::host::http::Request, + ) -> Result { + let host = match extract_host(&req.url) { + Some(h) => h, + None => { + return Err(HostError { + domain: "http".into(), + kind: HostErrorKind::InvalidInput, + code: 0, + message: format!("not an http(s) URL: {}", req.url), + data: None, + }); + } + }; + if !host_allowed(host, &self.http_allowlist) { + warn!(host, "[http] denied by allowlist"); + return Err(HostError { + domain: "http".into(), + kind: HostErrorKind::Denied, + code: 0, + message: format!( + "host {host} not in [capabilities.http].allow; \ + add it to module.toml to permit" + ), + data: None, + }); + } + Err(unimplemented( + "http", + "fetch not implemented in 0.2 reference runtime (allowlist passed)", + )) + } +} diff --git a/crates/nexum-engine/src/host/impls/identity.rs b/crates/nexum-engine/src/host/impls/identity.rs new file mode 100644 index 0000000..6a4a050 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/identity.rs @@ -0,0 +1,29 @@ +//! `nexum:host/identity`: deferred to 0.3 (keystore / KMS backend). +//! `accounts()` returns an empty roster so guests can probe-then-skip; +//! signing returns `Unsupported`. + +use crate::bindings::HostError; +use crate::bindings::nexum; +use crate::host::error::unimplemented; +use crate::host::state::HostState; + +impl nexum::host::identity::Host for HostState { + async fn accounts(&mut self) -> Result>, HostError> { + Ok(vec![]) + } + + async fn sign(&mut self, _account: Vec, _message: Vec) -> Result, HostError> { + Err(unimplemented("identity", "sign requires a keystore (0.3)")) + } + + async fn sign_typed_data( + &mut self, + _account: Vec, + _typed_data: String, + ) -> Result, HostError> { + Err(unimplemented( + "identity", + "sign-typed-data requires a keystore (0.3)", + )) + } +} diff --git a/crates/nexum-engine/src/host/impls/local_store.rs b/crates/nexum-engine/src/host/impls/local_store.rs new file mode 100644 index 0000000..66bcc52 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/local_store.rs @@ -0,0 +1,32 @@ +//! `nexum:host/local-store`: redb backend with host-side namespacing. + +use crate::bindings::HostError; +use crate::bindings::nexum; +use crate::host::error::internal_error; +use crate::host::state::HostState; + +impl nexum::host::local_store::Host for HostState { + async fn get(&mut self, key: String) -> Result>, HostError> { + self.store + .get(&self.module_namespace, &key) + .map_err(|err| internal_error("local-store", err.to_string())) + } + + async fn set(&mut self, key: String, value: Vec) -> Result<(), HostError> { + self.store + .set(&self.module_namespace, &key, &value) + .map_err(|err| internal_error("local-store", err.to_string())) + } + + async fn delete(&mut self, key: String) -> Result<(), HostError> { + self.store + .delete(&self.module_namespace, &key) + .map_err(|err| internal_error("local-store", err.to_string())) + } + + async fn list_keys(&mut self, prefix: String) -> Result, HostError> { + self.store + .list_keys(&self.module_namespace, &prefix) + .map_err(|err| internal_error("local-store", err.to_string())) + } +} diff --git a/crates/nexum-engine/src/host/impls/logging.rs b/crates/nexum-engine/src/host/impls/logging.rs new file mode 100644 index 0000000..b3a2a02 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/logging.rs @@ -0,0 +1,18 @@ +//! `nexum:host/logging`: routes guest log lines through the host's +//! `tracing` subscriber, tagged with the module namespace. + +use crate::bindings::nexum; +use crate::host::state::HostState; + +impl nexum::host::logging::Host for HostState { + async fn log(&mut self, level: nexum::host::logging::Level, message: String) { + let module = self.module_namespace.as_str(); + match level { + nexum::host::logging::Level::Trace => tracing::trace!(module, "{}", message), + nexum::host::logging::Level::Debug => tracing::debug!(module, "{}", message), + nexum::host::logging::Level::Info => tracing::info!(module, "{}", message), + nexum::host::logging::Level::Warn => tracing::warn!(module, "{}", message), + nexum::host::logging::Level::Error => tracing::error!(module, "{}", message), + } + } +} diff --git a/crates/nexum-engine/src/host/impls/messaging.rs b/crates/nexum-engine/src/host/impls/messaging.rs new file mode 100644 index 0000000..5582b00 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/messaging.rs @@ -0,0 +1,27 @@ +//! `nexum:host/messaging`: deferred to 0.3 (Waku backend). `query` +//! returns an empty result, same posture as `identity::accounts`. + +use crate::bindings::HostError; +use crate::bindings::nexum; +use crate::host::error::unimplemented; +use crate::host::state::HostState; + +impl nexum::host::messaging::Host for HostState { + async fn publish( + &mut self, + _content_topic: String, + _payload: Vec, + ) -> Result<(), HostError> { + Err(unimplemented("messaging", "Waku backend deferred to 0.3")) + } + + async fn query( + &mut self, + _content_topic: String, + _start_time: Option, + _end_time: Option, + _limit: Option, + ) -> Result, HostError> { + Ok(vec![]) + } +} diff --git a/crates/nexum-engine/src/host/impls/mod.rs b/crates/nexum-engine/src/host/impls/mod.rs new file mode 100644 index 0000000..8256af9 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/mod.rs @@ -0,0 +1,19 @@ +//! `Host` trait impls for [`crate::host::state::HostState`], one +//! file per WIT interface. +//! +//! The interfaces themselves (and their generated trait shapes) live +//! in [`crate::bindings`]; this module only contains the dispatch +//! glue between the WIT signature and the corresponding backend in +//! [`crate::host`]. + +mod chain; +mod clock; +mod cow_api; +mod http; +mod identity; +mod local_store; +mod logging; +mod messaging; +mod random; +mod remote_store; +mod types; diff --git a/crates/nexum-engine/src/host/impls/random.rs b/crates/nexum-engine/src/host/impls/random.rs new file mode 100644 index 0000000..88e2f8c --- /dev/null +++ b/crates/nexum-engine/src/host/impls/random.rs @@ -0,0 +1,16 @@ +//! `nexum:host/random`: fills `len` bytes from the OS CSPRNG. +//! Getrandom 0.4 failures are exceptionally rare on supported +//! platforms; on failure we return zero-filled bytes - guests that +//! need a strong-failure signal should use identity or chain primitives +//! instead. + +use crate::bindings::nexum; +use crate::host::state::HostState; + +impl nexum::host::random::Host for HostState { + async fn fill(&mut self, len: u32) -> Vec { + let mut buf = vec![0u8; len as usize]; + let _ = getrandom::fill(&mut buf); + buf + } +} diff --git a/crates/nexum-engine/src/host/impls/remote_store.rs b/crates/nexum-engine/src/host/impls/remote_store.rs new file mode 100644 index 0000000..9001d1f --- /dev/null +++ b/crates/nexum-engine/src/host/impls/remote_store.rs @@ -0,0 +1,40 @@ +//! `nexum:host/remote-store`: deferred to 0.3 (Swarm backend). + +use crate::bindings::HostError; +use crate::bindings::nexum; +use crate::host::error::unimplemented; +use crate::host::state::HostState; + +impl nexum::host::remote_store::Host for HostState { + async fn upload(&mut self, _data: Vec) -> Result, HostError> { + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) + } + + async fn download(&mut self, _reference: Vec) -> Result, HostError> { + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) + } + + async fn read_feed( + &mut self, + _owner: Vec, + _topic: Vec, + ) -> Result>, HostError> { + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) + } + + async fn write_feed(&mut self, _topic: Vec, _data: Vec) -> Result, HostError> { + Err(unimplemented( + "remote-store", + "Swarm backend deferred to 0.3", + )) + } +} diff --git a/crates/nexum-engine/src/host/impls/types.rs b/crates/nexum-engine/src/host/impls/types.rs new file mode 100644 index 0000000..c4a93e1 --- /dev/null +++ b/crates/nexum-engine/src/host/impls/types.rs @@ -0,0 +1,7 @@ +//! `nexum:host/types` is a type-only interface (no functions). The +//! generated trait is empty; we just provide the marker impl. + +use crate::bindings::nexum; +use crate::host::state::HostState; + +impl nexum::host::types::Host for HostState {} diff --git a/crates/nexum-engine/src/host/mod.rs b/crates/nexum-engine/src/host/mod.rs index b76fe99..20f2ec2 100644 --- a/crates/nexum-engine/src/host/mod.rs +++ b/crates/nexum-engine/src/host/mod.rs @@ -1,12 +1,21 @@ -//! Host-side backends for the `nexum:host` / `shepherd:cow` -//! interfaces. +//! Host-side backends for the `nexum:host` / `shepherd:cow` interfaces, +//! plus the per-module `HostState` and the WIT `Host` trait impls. //! -//! Each submodule owns one capability. The trait impls in `main.rs` -//! stay thin: they validate inputs, dispatch to the backend, and -//! project the backend's typed error onto the bindgen-generated -//! `HostError`. Keeping the backends pure (no bindgen types) means -//! each can be unit-tested without spinning up a wasmtime store. +//! Layout: +//! - [`state`]: `HostState` struct + `WasiView` impl, the receiver +//! every WIT `Host` trait is implemented for. +//! - [`error`]: small constructors that build the WIT `HostError` +//! shape (`unimplemented`, `internal_error`) plus the lowercase +//! `hex_encode` shared by the `cow-api` submission path. +//! - [`cow_orderbook`], [`provider_pool`], [`local_store_redb`]: +//! capability backends. Pure code with no bindgen types, so each +//! can be unit-tested without spinning up a wasmtime store. +//! - [`impls`] (private): the bindgen-side trait impls, one file per +//! WIT interface, that dispatch to the backends above. pub mod cow_orderbook; +pub mod error; +mod impls; pub mod local_store_redb; pub mod provider_pool; +pub mod state; diff --git a/crates/nexum-engine/src/host/state.rs b/crates/nexum-engine/src/host/state.rs new file mode 100644 index 0000000..ec50219 --- /dev/null +++ b/crates/nexum-engine/src/host/state.rs @@ -0,0 +1,45 @@ +//! Per-instance host state and its WASI view. +//! +//! One [`HostState`] is created per module, lives inside the wasmtime +//! `Store`, and is the receiver every `Host` trait impl in +//! [`super::impls`] is implemented for. + +use std::time::Instant; + +use wasmtime::component::ResourceTable; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; + +use super::cow_orderbook::OrderBookPool; +use super::local_store_redb::LocalStore; +use super::provider_pool::ProviderPool; + +pub(crate) struct HostState { + pub wasi: WasiCtx, + pub table: ResourceTable, + /// Wasmtime memory/table/instance resource limits for this store. + pub limits: wasmtime::StoreLimits, + /// Origin for `clock::monotonic-ns`. Differences between successive + /// readings are the only meaningful values. + pub monotonic_baseline: Instant, + /// Per-module `[capabilities.http].allow` allowlist (from module.toml). + /// Consulted by `http::fetch` before any outbound call. + pub http_allowlist: Vec, + /// Namespace for the running module's `local-store` rows. Set from + /// `manifest.module.name` at instantiation. + pub module_namespace: String, + /// `cow-api` backend - per-chain `OrderBookApi` clients + reqwest. + pub cow: OrderBookPool, + /// `chain` backend - per-chain alloy `DynProvider` pool. + pub chain: ProviderPool, + /// `local-store` backend - redb file with host-side namespacing. + pub store: LocalStore, +} + +impl WasiView for HostState { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index 58ae8f4..26fac50 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -8,447 +8,25 @@ use alloy_rpc_client as _; use alloy_transport as _; use alloy_transport_ws as _; +mod bindings; +mod cli; mod engine_config; mod host; mod manifest; +mod runtime; mod supervisor; -use std::path::PathBuf; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; - -use futures::StreamExt; -use futures::stream::{FuturesUnordered, select_all}; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; use wasmtime::Engine; -use wasmtime::component::{Linker, ResourceTable}; -use wasmtime::error::Context as _; -use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; - -// Both packages are listed explicitly so wit-parser can resolve the -// cross-package reference natively - no vendored deps/ tree needed. -// World name is fully qualified. -wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - imports: { default: async }, - exports: { default: async }, -}); - -use nexum::host::types::HostErrorKind; - -/// Default fuel budget granted per `on_event` invocation (≈ 1 billion WASM -/// instructions). Modules that exceed this budget trap with `OutOfFuel`. -/// Configurable per-module via `engine.toml` in 0.3. -pub const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000; - -/// Default linear-memory cap per module store (64 MiB). Prevents a single -/// runaway module from exhausting process memory. Configurable in 0.3. -pub const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024; - -struct HostState { - wasi: WasiCtx, - table: ResourceTable, - /// Wasmtime memory/table/instance resource limits for this store. - limits: wasmtime::StoreLimits, - /// Origin for `clock::monotonic-ns`. Differences between successive - /// readings are the only meaningful values. - monotonic_baseline: Instant, - /// Per-module `[capabilities.http].allow` allowlist (from module.toml). - /// Consulted by `http::fetch` before any outbound call. - http_allowlist: Vec, - /// Namespace for the running module's `local-store` rows. Set from - /// `manifest.module.name` at instantiation. - module_namespace: String, - /// `cow-api` backend - per-chain `OrderBookApi` clients + reqwest. - cow: host::cow_orderbook::OrderBookPool, - /// `chain` backend - per-chain alloy `DynProvider` pool. - chain: host::provider_pool::ProviderPool, - /// `local-store` backend - redb file with host-side namespacing. - store: host::local_store_redb::LocalStore, -} - -impl WasiView for HostState { - fn ctx(&mut self) -> WasiCtxView<'_> { - WasiCtxView { - ctx: &mut self.wasi, - table: &mut self.table, - } - } -} - -fn unimplemented(domain: &str, detail: impl Into) -> HostError { - HostError { - domain: domain.into(), - kind: HostErrorKind::Unsupported, - code: 501, - message: detail.into(), - data: None, - } -} - -fn internal_error(domain: &str, detail: impl Into) -> HostError { - HostError { - domain: domain.into(), - kind: HostErrorKind::Internal, - code: 0, - message: detail.into(), - data: None, - } -} - -// -- nexum:host/types is empty (declarations only). -- - -impl nexum::host::types::Host for HostState {} - -// -- shepherd:cow/cow-api: REST passthrough + typed submission. -- - -impl shepherd::cow::cow_api::Host for HostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - path: String, - body: Option, - ) -> Result { - let start = Instant::now(); - tracing::debug!(chain_id, %method, %path, "cow-api::request"); - let result = match self - .cow - .request(chain_id, &method, &path, body.as_deref()) - .await - { - Ok(body) => Ok(body), - Err(host::cow_orderbook::CowApiError::UnknownChain(id)) => Err(unimplemented( - "cow-api", - format!("chain {id} not in cowprotocol"), - )), - Err(host::cow_orderbook::CowApiError::BadMethod(m)) => Err(HostError { - domain: "cow-api".into(), - kind: HostErrorKind::InvalidInput, - code: 0, - message: format!("unsupported HTTP method: {m}"), - data: None, - }), - Err(host::cow_orderbook::CowApiError::BadPath(msg)) => Err(HostError { - domain: "cow-api".into(), - kind: HostErrorKind::InvalidInput, - code: 0, - message: msg, - data: None, - }), - Err(err) => Err(internal_error("cow-api", err.to_string())), - }; - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::request done"); - result - } - - async fn submit_order( - &mut self, - chain_id: u64, - order_data: Vec, - ) -> Result { - let start = Instant::now(); - tracing::debug!(chain_id, bytes = order_data.len(), "cow-api::submit-order"); - let result = match self.cow.submit_order_json(chain_id, &order_data).await { - Ok(uid) => Ok(format!("0x{}", hex_encode(uid.as_slice()))), - Err(host::cow_orderbook::CowApiError::UnknownChain(id)) => Err(unimplemented( - "cow-api", - format!("chain {id} not in cowprotocol"), - )), - Err(host::cow_orderbook::CowApiError::Decode(err)) => Err(HostError { - domain: "cow-api".into(), - kind: HostErrorKind::InvalidInput, - code: 0, - message: format!("invalid OrderCreation JSON: {err}"), - data: None, - }), - Err(host::cow_orderbook::CowApiError::Orderbook(err)) => Err(HostError { - domain: "cow-api".into(), - kind: HostErrorKind::Denied, - code: 0, - message: err.to_string(), - data: None, - }), - Err(err) => Err(internal_error("cow-api", err.to_string())), - }; - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::submit-order done"); - result - } -} - -// -- nexum:host/chain: raw JSON-RPC dispatch over alloy. -- - -impl nexum::host::chain::Host for HostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> Result { - let start = Instant::now(); - tracing::debug!(chain_id, %method, "chain::request"); - let result = match self.chain.request(chain_id, method.clone(), params).await { - Ok(body) => Ok(body), - Err(host::provider_pool::ProviderError::UnknownChain(id)) => Err(HostError { - domain: "chain".into(), - kind: HostErrorKind::Unsupported, - code: 0, - message: format!("chain {id} has no engine.toml RPC entry"), - data: None, - }), - Err(host::provider_pool::ProviderError::InvalidParams { detail, .. }) => { - Err(HostError { - domain: "chain".into(), - kind: HostErrorKind::InvalidInput, - code: -32602, - message: detail, - data: None, - }) - } - Err(host::provider_pool::ProviderError::Rpc { detail, .. }) => Err(HostError { - domain: "chain".into(), - kind: HostErrorKind::Internal, - code: -32603, - message: detail, - data: None, - }), - Err(err) => Err(internal_error("chain", err.to_string())), - }; - tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done"); - result - } +use wasmtime::component::Linker; - async fn request_batch( - &mut self, - chain_id: u64, - requests: Vec, - ) -> Result, HostError> { - let start = Instant::now(); - tracing::debug!(chain_id, count = requests.len(), "chain::request-batch"); - let mut out = Vec::with_capacity(requests.len()); - for req in requests { - match nexum::host::chain::Host::request(self, chain_id, req.method, req.params).await { - Ok(s) => out.push(nexum::host::chain::RpcResult::Ok(s)), - Err(e) => out.push(nexum::host::chain::RpcResult::Err(e)), - } - } - tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request-batch done"); - Ok(out) - } -} - -// -- nexum:host/identity: deferred to 0.3 (keystore/KMS backend). -- - -impl nexum::host::identity::Host for HostState { - async fn accounts(&mut self) -> Result>, HostError> { - // No keystore wired yet - return an empty roster so guests can - // probe-then-skip without erroring. Real keystore lands in 0.3. - Ok(vec![]) - } - - async fn sign(&mut self, _account: Vec, _message: Vec) -> Result, HostError> { - Err(unimplemented("identity", "sign requires a keystore (0.3)")) - } - - async fn sign_typed_data( - &mut self, - _account: Vec, - _typed_data: String, - ) -> Result, HostError> { - Err(unimplemented( - "identity", - "sign-typed-data requires a keystore (0.3)", - )) - } -} - -// -- nexum:host/local-store: redb backend with host-side namespacing. -- - -impl nexum::host::local_store::Host for HostState { - async fn get(&mut self, key: String) -> Result>, HostError> { - self.store - .get(&self.module_namespace, &key) - .map_err(|err| internal_error("local-store", err.to_string())) - } - - async fn set(&mut self, key: String, value: Vec) -> Result<(), HostError> { - self.store - .set(&self.module_namespace, &key, &value) - .map_err(|err| internal_error("local-store", err.to_string())) - } - - async fn delete(&mut self, key: String) -> Result<(), HostError> { - self.store - .delete(&self.module_namespace, &key) - .map_err(|err| internal_error("local-store", err.to_string())) - } - - async fn list_keys(&mut self, prefix: String) -> Result, HostError> { - self.store - .list_keys(&self.module_namespace, &prefix) - .map_err(|err| internal_error("local-store", err.to_string())) - } -} - -impl nexum::host::remote_store::Host for HostState { - async fn upload(&mut self, _data: Vec) -> Result, HostError> { - Err(unimplemented( - "remote-store", - "Swarm backend deferred to 0.3", - )) - } - - async fn download(&mut self, _reference: Vec) -> Result, HostError> { - Err(unimplemented( - "remote-store", - "Swarm backend deferred to 0.3", - )) - } - - async fn read_feed( - &mut self, - _owner: Vec, - _topic: Vec, - ) -> Result>, HostError> { - Err(unimplemented( - "remote-store", - "Swarm backend deferred to 0.3", - )) - } - - async fn write_feed(&mut self, _topic: Vec, _data: Vec) -> Result, HostError> { - Err(unimplemented( - "remote-store", - "Swarm backend deferred to 0.3", - )) - } -} - -impl nexum::host::messaging::Host for HostState { - async fn publish( - &mut self, - _content_topic: String, - _payload: Vec, - ) -> Result<(), HostError> { - Err(unimplemented("messaging", "Waku backend deferred to 0.3")) - } - - async fn query( - &mut self, - _content_topic: String, - _start_time: Option, - _end_time: Option, - _limit: Option, - ) -> Result, HostError> { - // Empty result - same posture as `identity::accounts`. - Ok(vec![]) - } -} - -impl nexum::host::logging::Host for HostState { - async fn log(&mut self, level: nexum::host::logging::Level, message: String) { - let module = self.module_namespace.as_str(); - match level { - nexum::host::logging::Level::Trace => tracing::trace!(module, "{}", message), - nexum::host::logging::Level::Debug => tracing::debug!(module, "{}", message), - nexum::host::logging::Level::Info => tracing::info!(module, "{}", message), - nexum::host::logging::Level::Warn => tracing::warn!(module, "{}", message), - nexum::host::logging::Level::Error => tracing::error!(module, "{}", message), - } - } -} - -// -- Additive 0.2 capabilities -- - -impl nexum::host::clock::Host for HostState { - async fn now_ms(&mut self) -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) - } - - async fn monotonic_ns(&mut self) -> u64 { - self.monotonic_baseline.elapsed().as_nanos() as u64 - } -} - -impl nexum::host::random::Host for HostState { - async fn fill(&mut self, len: u32) -> Vec { - let mut buf = vec![0u8; len as usize]; - // getrandom 0.4: fill() returns Result<(), Error>. CSPRNG failures - // are exceptionally rare on supported platforms; on failure we - // return zero-filled bytes - guests that need a strong-failure - // signal should use identity or chain primitives instead. - let _ = getrandom::fill(&mut buf); - buf - } -} - -impl nexum::host::http::Host for HostState { - async fn fetch( - &mut self, - req: nexum::host::http::Request, - ) -> Result { - // Manifest allowlist enforcement runs before any I/O. Hosts that - // never link a manifest leave `http_allowlist` empty, which denies - // every request - matching the "no implicit network" stance. - let host = match manifest::extract_host(&req.url) { - Some(h) => h, - None => { - return Err(HostError { - domain: "http".into(), - kind: HostErrorKind::InvalidInput, - code: 0, - message: format!("not an http(s) URL: {}", req.url), - data: None, - }); - } - }; - if !manifest::host_allowed(host, &self.http_allowlist) { - warn!(host, "[http] denied by allowlist"); - return Err(HostError { - domain: "http".into(), - kind: HostErrorKind::Denied, - code: 0, - message: format!( - "host {host} not in [capabilities.http].allow; \ - add it to module.toml to permit" - ), - data: None, - }); - } - // 0.2: allowlist passed, but the reference runtime does not perform - // real HTTP yet. Real fetch lands in 0.3. - Err(unimplemented( - "http", - "fetch not implemented in 0.2 reference runtime (allowlist passed)", - )) - } -} - -/// Lowercase hex encoder. Kept in the engine binary rather than -/// pulling a `hex` crate just for one call site. Writes into the -/// pre-allocated buffer to avoid the per-byte `String` allocation -/// `format!("{b:02x}")` would do. -fn hex_encode(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - write!(s, "{b:02x}").expect("writing to String never fails"); - } - s -} +use crate::bindings::Shepherd; +use crate::cli::Cli; +use crate::host::state::HostState; #[tokio::main] async fn main() -> anyhow::Result<()> { - // CLI args: - // nexum-engine [ []] [--engine-config ] - // - // Positional `` is a backwards-compat shortcut that - // synthesises a one-module engine config. Production deployments - // pass `--engine-config` and declare modules in TOML. let cli = Cli::parse(); let engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; @@ -464,19 +42,17 @@ async fn main() -> anyhow::Result<()> { info!("nexum-engine starting"); // Bring up shared host backends. - std::fs::create_dir_all(&engine_cfg.engine.state_dir).with_context(|| { - format!( - "create state directory {}", + std::fs::create_dir_all(&engine_cfg.engine.state_dir).map_err(|e| { + anyhow::anyhow!( + "create state directory {}: {e}", engine_cfg.engine.state_dir.display() ) })?; let store_path = engine_cfg.engine.state_dir.join("local-store.redb"); let local_store = host::local_store_redb::LocalStore::open(&store_path) - .with_context(|| format!("open local-store at {}", store_path.display()))?; + .map_err(|e| anyhow::anyhow!("open local-store at {}: {e}", store_path.display()))?; let cow_pool = host::cow_orderbook::OrderBookPool::default(); - let provider_pool = host::provider_pool::ProviderPool::from_config(&engine_cfg) - .await - .context("open chain providers")?; + let provider_pool = host::provider_pool::ProviderPool::from_config(&engine_cfg).await?; // wasmtime engine + linker - one of each, shared across modules. let mut config = wasmtime::Config::new(); @@ -491,8 +67,7 @@ async fn main() -> anyhow::Result<()> { )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - // Boot supervisor - `engine.toml.[[modules]]` first, CLI - // positional second. + // Boot supervisor - `engine.toml.[[modules]]` first, CLI positional second. let mut supervisor = if let Some(wasm) = cli.wasm.as_deref() { if !engine_cfg.modules.is_empty() { warn!("ignoring engine.toml [[modules]] because a positional was given"); @@ -540,200 +115,17 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } - let block_streams = open_block_streams(&provider_pool, &block_chains).await; - let log_streams = open_log_streams(&provider_pool, log_subs).await; + let block_streams = runtime::event_loop::open_block_streams(&provider_pool, &block_chains).await; + let log_streams = runtime::event_loop::open_log_streams(&provider_pool, log_subs).await; let shutdown = async { - match wait_for_shutdown_signal().await { + match runtime::event_loop::wait_for_shutdown_signal().await { Ok(name) => info!(signal = %name, "shutdown signal received"), Err(err) => warn!(error = %err, "signal handler failed - using ctrl-c"), } }; - run_event_loop(&mut supervisor, block_streams, log_streams, shutdown).await; + runtime::event_loop::run(&mut supervisor, block_streams, log_streams, shutdown).await; info!("done"); Ok(()) } - -/// Parsed CLI surface. -#[derive(Debug, Default)] -struct Cli { - wasm: Option, - manifest: Option, - engine_config: Option, -} - -impl Cli { - fn parse() -> Self { - let mut args = std::env::args().skip(1); - let mut cli = Self::default(); - let mut positional = Vec::new(); - while let Some(arg) = args.next() { - match arg.as_str() { - "--engine-config" => cli.engine_config = args.next().map(PathBuf::from), - "-h" | "--help" => { - eprintln!( - "usage: nexum-engine [ []] \ - [--engine-config ]" - ); - std::process::exit(0); - } - _ => positional.push(arg), - } - } - if let Some(p) = positional.first() { - cli.wasm = Some(PathBuf::from(p)); - } - if let Some(p) = positional.get(1) { - cli.manifest = Some(PathBuf::from(p)); - } - cli - } -} - -/// Per-chain block subscriptions, one shared stream per chain id. -async fn open_block_streams( - pool: &host::provider_pool::ProviderPool, - chains: &std::collections::BTreeSet, -) -> Vec { - let mut openings: FuturesUnordered<_> = chains - .iter() - .copied() - .map(|chain_id| async move { (chain_id, pool.subscribe_blocks(chain_id).await) }) - .collect(); - - let mut streams = Vec::new(); - while let Some((chain_id, result)) = openings.next().await { - match result { - Ok(stream) => { - info!(chain_id, "block subscription open"); - let tagged: TaggedBlockStream = Box::pin(stream.map(move |item| { - item.map(|header| (chain_id, header)) - .map_err(anyhow::Error::from) - })); - streams.push(tagged); - } - Err(err) => { - warn!(chain_id, error = %err, "block subscription failed"); - } - } - } - streams -} - -/// Per-module log subscriptions. Each entry is a stream tagged with -/// the owning module name + chain id. -async fn open_log_streams( - pool: &host::provider_pool::ProviderPool, - subs: Vec<(String, u64, alloy_rpc_types_eth::Filter)>, -) -> Vec { - let mut openings: FuturesUnordered<_> = subs - .into_iter() - .map(|(module, chain_id, filter)| async move { - let stream = pool.subscribe_logs(chain_id, filter).await; - (module, chain_id, stream) - }) - .collect(); - - let mut streams = Vec::new(); - while let Some((module, chain_id, result)) = openings.next().await { - match result { - Ok(stream) => { - info!(module = %module, chain_id, "log subscription open"); - let module_name = module.clone(); - let tagged: TaggedLogStream = Box::pin(stream.map(move |item| { - item.map(|log| (module_name.clone(), chain_id, log)) - .map_err(anyhow::Error::from) - })); - streams.push(tagged); - } - Err(err) => { - warn!(module = %module, chain_id, error = %err, "log subscription failed"); - } - } - } - streams -} - -type TaggedBlockStream = std::pin::Pin< - Box< - dyn futures::Stream> - + Send, - >, ->; -type TaggedLogStream = std::pin::Pin< - Box< - dyn futures::Stream> - + Send, - >, ->; - -/// Drive the supervisor with events until `shutdown` resolves. -async fn run_event_loop( - supervisor: &mut supervisor::Supervisor, - block_streams: Vec, - log_streams: Vec, - shutdown: impl std::future::Future + Send, -) { - let mut blocks = select_all(block_streams); - let mut logs = select_all(log_streams); - let mut shutdown = Box::pin(shutdown); - loop { - tokio::select! { - biased; - () = &mut shutdown => return, - next = blocks.next() => match next { - Some(Ok((chain_id, header))) => { - let block = nexum::host::types::Block { - chain_id, - number: header.number, - hash: header.hash.as_slice().to_vec(), - timestamp: header.timestamp.saturating_mul(1000), - }; - supervisor.dispatch_block(block).await; - } - Some(Err(err)) => warn!(error = %err, "block stream error - continuing"), - None => { - // alloy ends the stream with None when the - // WebSocket drops. Without this branch the loop - // keeps polling a dead stream and the operator - // sees no events with no indication anything is - // wrong. Bail out so the supervisor (or whatever - // wraps the engine) restarts us; a reconnect- - // with-backoff is the 0.3 fix. - warn!("block stream ended (WebSocket dropped?) - shutting down for restart"); - return; - } - }, - next = logs.next() => match next { - Some(Ok((module, chain_id, log))) => { - supervisor.dispatch_log(&module, chain_id, log).await; - } - Some(Err(err)) => warn!(error = %err, "log stream error - continuing"), - None => { - warn!("log stream ended (WebSocket dropped?) - shutting down for restart"); - return; - } - }, - } - } -} - -/// Wait for SIGINT or (on Unix) SIGTERM, whichever arrives first. -async fn wait_for_shutdown_signal() -> anyhow::Result<&'static str> { - #[cfg(unix)] - { - use tokio::signal::unix::{SignalKind, signal}; - let mut sigterm = signal(SignalKind::terminate())?; - let mut sigint = signal(SignalKind::interrupt())?; - tokio::select! { - _ = sigterm.recv() => Ok("SIGTERM"), - _ = sigint.recv() => Ok("SIGINT"), - } - } - #[cfg(not(unix))] - { - tokio::signal::ctrl_c().await?; - Ok("ctrl-c") - } -} diff --git a/crates/nexum-engine/src/runtime/event_loop.rs b/crates/nexum-engine/src/runtime/event_loop.rs new file mode 100644 index 0000000..94c7433 --- /dev/null +++ b/crates/nexum-engine/src/runtime/event_loop.rs @@ -0,0 +1,157 @@ +//! Open live `eth_subscribe` streams and dispatch their events to the +//! supervisor until a shutdown signal arrives. + +use futures::StreamExt; +use futures::stream::{FuturesUnordered, select_all}; +use tracing::{info, warn}; + +use crate::bindings::nexum; +use crate::host::provider_pool::ProviderPool; +use crate::supervisor::Supervisor; + +/// Per-chain block subscriptions, one shared stream per chain id. +pub async fn open_block_streams( + pool: &ProviderPool, + chains: &std::collections::BTreeSet, +) -> Vec { + let mut openings: FuturesUnordered<_> = chains + .iter() + .copied() + .map(|chain_id| async move { (chain_id, pool.subscribe_blocks(chain_id).await) }) + .collect(); + + let mut streams = Vec::new(); + while let Some((chain_id, result)) = openings.next().await { + match result { + Ok(stream) => { + info!(chain_id, "block subscription open"); + let tagged: TaggedBlockStream = Box::pin(stream.map(move |item| { + item.map(|header| (chain_id, header)) + .map_err(anyhow::Error::from) + })); + streams.push(tagged); + } + Err(err) => { + warn!(chain_id, error = %err, "block subscription failed"); + } + } + } + streams +} + +/// Per-module log subscriptions. Each entry is a stream tagged with +/// the owning module name + chain id. +pub async fn open_log_streams( + pool: &ProviderPool, + subs: Vec<(String, u64, alloy_rpc_types_eth::Filter)>, +) -> Vec { + let mut openings: FuturesUnordered<_> = subs + .into_iter() + .map(|(module, chain_id, filter)| async move { + let stream = pool.subscribe_logs(chain_id, filter).await; + (module, chain_id, stream) + }) + .collect(); + + let mut streams = Vec::new(); + while let Some((module, chain_id, result)) = openings.next().await { + match result { + Ok(stream) => { + info!(module = %module, chain_id, "log subscription open"); + let module_name = module.clone(); + let tagged: TaggedLogStream = Box::pin(stream.map(move |item| { + item.map(|log| (module_name.clone(), chain_id, log)) + .map_err(anyhow::Error::from) + })); + streams.push(tagged); + } + Err(err) => { + warn!(module = %module, chain_id, error = %err, "log subscription failed"); + } + } + } + streams +} + +pub type TaggedBlockStream = std::pin::Pin< + Box< + dyn futures::Stream> + + Send, + >, +>; +pub type TaggedLogStream = std::pin::Pin< + Box< + dyn futures::Stream> + + Send, + >, +>; + +/// Drive the supervisor with events until `shutdown` resolves. +pub async fn run( + supervisor: &mut Supervisor, + block_streams: Vec, + log_streams: Vec, + shutdown: impl std::future::Future + Send, +) { + let mut blocks = select_all(block_streams); + let mut logs = select_all(log_streams); + let mut shutdown = Box::pin(shutdown); + loop { + tokio::select! { + biased; + () = &mut shutdown => return, + next = blocks.next() => match next { + Some(Ok((chain_id, header))) => { + let block = nexum::host::types::Block { + chain_id, + number: header.number, + hash: header.hash.as_slice().to_vec(), + timestamp: header.timestamp.saturating_mul(1000), + }; + supervisor.dispatch_block(block).await; + } + Some(Err(err)) => warn!(error = %err, "block stream error - continuing"), + None => { + // alloy ends the stream with None when the + // WebSocket drops. Without this branch the loop + // keeps polling a dead stream and the operator + // sees no events with no indication anything is + // wrong. Bail out so the supervisor (or whatever + // wraps the engine) restarts us; a reconnect- + // with-backoff is the 0.3 fix. + warn!("block stream ended (WebSocket dropped?) - shutting down for restart"); + return; + } + }, + next = logs.next() => match next { + Some(Ok((module, chain_id, log))) => { + supervisor.dispatch_log(&module, chain_id, log).await; + } + Some(Err(err)) => warn!(error = %err, "log stream error - continuing"), + None => { + warn!("log stream ended (WebSocket dropped?) - shutting down for restart"); + return; + } + }, + } + } +} + +/// Wait for SIGINT or (on Unix) SIGTERM, whichever arrives first. +pub async fn wait_for_shutdown_signal() -> anyhow::Result<&'static str> { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = signal(SignalKind::terminate())?; + let mut sigint = signal(SignalKind::interrupt())?; + tokio::select! { + _ = sigterm.recv() => Ok("SIGTERM"), + _ = sigint.recv() => Ok("SIGINT"), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await?; + Ok("ctrl-c") + } +} diff --git a/crates/nexum-engine/src/runtime/limits.rs b/crates/nexum-engine/src/runtime/limits.rs new file mode 100644 index 0000000..1f4b1d9 --- /dev/null +++ b/crates/nexum-engine/src/runtime/limits.rs @@ -0,0 +1,14 @@ +//! Per-module wasmtime fuel + memory limits. The supervisor refuels +//! the store before every `on_event` so each invocation gets a fresh +//! budget; a module that exhausts fuel traps with `OutOfFuel` and is +//! marked dead. + +/// Default fuel budget granted per `on_event` invocation +/// (~ 1 billion WASM instructions). Configurable per-module via +/// `engine.toml` in 0.3. +pub const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000; + +/// Default linear-memory cap per module store (64 MiB). Prevents a +/// single runaway module from exhausting process memory. Configurable +/// in 0.3. +pub const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024; diff --git a/crates/nexum-engine/src/runtime/mod.rs b/crates/nexum-engine/src/runtime/mod.rs new file mode 100644 index 0000000..72ea95f --- /dev/null +++ b/crates/nexum-engine/src/runtime/mod.rs @@ -0,0 +1,5 @@ +//! Engine-side runtime: per-module resource limits and the event loop +//! that drives the supervisor from live chain subscriptions. + +pub mod event_loop; +pub mod limits; diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 631696e..88f3615 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -20,12 +20,14 @@ use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::WasiCtxBuilder; +use crate::bindings::{Config, Shepherd, nexum}; use crate::engine_config::{EngineConfig, ModuleEntry}; use crate::host::cow_orderbook::OrderBookPool; use crate::host::local_store_redb::LocalStore; use crate::host::provider_pool::ProviderPool; +use crate::host::state::HostState; use crate::manifest::{self, LoadedManifest, Subscription}; -use crate::{HostState, Shepherd}; +use crate::runtime::limits::{DEFAULT_FUEL_PER_EVENT, DEFAULT_MEMORY_LIMIT}; /// Owns every loaded module and exposes the dispatch surface the /// event loop needs. @@ -155,7 +157,7 @@ impl Supervisor { loaded_manifest.manifest.module.name.clone() }; let limits = wasmtime::StoreLimitsBuilder::new() - .memory_size(crate::DEFAULT_MEMORY_LIMIT) + .memory_size(DEFAULT_MEMORY_LIMIT) .build(); let mut store = Store::new( engine, @@ -172,14 +174,14 @@ impl Supervisor { }, ); store.limiter(|state| &mut state.limits); - store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT)?; + store.set_fuel(DEFAULT_FUEL_PER_EVENT)?; let bindings = Shepherd::instantiate_async(&mut store, &component, linker) .await .map_err(Error::from) .with_context(|| format!("instantiate {}", entry.path.display()))?; // Call `init` with the manifest's `[config]`. - let config: crate::Config = if loaded_manifest.config.is_empty() { + let config: Config = if loaded_manifest.config.is_empty() { vec![("name".into(), module_namespace.clone())] } else { loaded_manifest.config.clone() @@ -200,7 +202,7 @@ impl Supervisor { ), } // Refuel after init so the first on_event starts with a full budget. - store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT)?; + store.set_fuel(DEFAULT_FUEL_PER_EVENT)?; // Surface any `[[subscription]]` entries the host cannot // service yet, so an operator running 0.2 against a 0.3 @@ -275,9 +277,9 @@ impl Supervisor { /// Dispatch a block event to every module subscribed to /// `block.chain_id`. Returns the number of modules invoked. /// Modules that trap are marked dead and excluded from future dispatch. - pub async fn dispatch_block(&mut self, block: crate::nexum::host::types::Block) -> usize { + pub async fn dispatch_block(&mut self, block: nexum::host::types::Block) -> usize { let chain_id = block.chain_id; - let event = crate::nexum::host::types::Event::Block(block); + let event = nexum::host::types::Event::Block(block); let mut dispatched = 0; for module in &mut self.modules { if !module.alive { @@ -291,7 +293,7 @@ impl Supervisor { continue; } // Refuel before each invocation so each event gets a fresh budget. - if let Err(e) = module.store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT) { + if let Err(e) = module.store.set_fuel(DEFAULT_FUEL_PER_EVENT) { error!(module = %module.name, error = %e, "set_fuel failed - skipping"); continue; } @@ -343,11 +345,11 @@ impl Supervisor { if !target.alive { return false; } - if let Err(e) = target.store.set_fuel(crate::DEFAULT_FUEL_PER_EVENT) { + if let Err(e) = target.store.set_fuel(DEFAULT_FUEL_PER_EVENT) { error!(module = %module_name, error = %e, "set_fuel failed - skipping"); return false; } - let event = crate::nexum::host::types::Event::Logs(vec![project_log(chain_id, &log)]); + let event = nexum::host::types::Event::Logs(vec![project_log(chain_id, &log)]); match target .bindings .call_on_event(&mut target.store, &event) @@ -388,8 +390,8 @@ impl Supervisor { /// Project an alloy `Log` onto the WIT `log` record. The chain id /// is not on the alloy log (the subscription context carries it), /// so we receive it alongside. -fn project_log(chain_id: u64, log: &alloy_rpc_types_eth::Log) -> crate::nexum::host::types::Log { - crate::nexum::host::types::Log { +fn project_log(chain_id: u64, log: &alloy_rpc_types_eth::Log) -> nexum::host::types::Log { + nexum::host::types::Log { chain_id, address: log.address().as_slice().to_vec(), topics: log.topics().iter().map(|t| t.as_slice().to_vec()).collect(), @@ -580,7 +582,7 @@ chain_id = 1 .await .expect("boot_single"); - let block = crate::nexum::host::types::Block { + let block = nexum::host::types::Block { chain_id: 1, number: 19_000_000, hash: vec![0xab; 32], From 794091ae2efffa47b22b23250d297cf83a654577 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Sat, 13 Jun 2026 09:39:59 -0300 Subject: [PATCH 13/15] refactor: move large #[cfg(test)] modules to sibling files local_store_redb.rs was 89% tests, cow_orderbook.rs was 60%, and supervisor.rs was 32% (205 lines absolute). Promote each to a directory module with the test suite living in a sibling tests.rs so impl-side diffs stop competing with test churn for attention. --- crates/nexum-engine/src/host/cow_orderbook.rs | 214 +----------------- .../src/host/cow_orderbook/tests.rs | 208 +++++++++++++++++ .../nexum-engine/src/host/local_store_redb.rs | 83 +------ .../src/host/local_store_redb/tests.rs | 80 +++++++ crates/nexum-engine/src/supervisor.rs | 206 +---------------- crates/nexum-engine/src/supervisor/tests.rs | 202 +++++++++++++++++ 6 files changed, 495 insertions(+), 498 deletions(-) create mode 100644 crates/nexum-engine/src/host/cow_orderbook/tests.rs create mode 100644 crates/nexum-engine/src/host/local_store_redb/tests.rs create mode 100644 crates/nexum-engine/src/supervisor/tests.rs diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs index bac376d..49c1945 100644 --- a/crates/nexum-engine/src/host/cow_orderbook.rs +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -136,216 +136,6 @@ pub enum CowApiError { Orderbook(#[from] cowprotocol::Error), } -#[cfg(test)] -mod tests { - use super::*; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - #[test] - fn pool_indexes_default_chains() { - let pool = OrderBookPool::default(); - assert!(pool.get(1).is_ok(), "mainnet present"); - assert!(pool.get(100).is_ok(), "gnosis present"); - assert!(pool.get(11_155_111).is_ok(), "sepolia present"); - assert!(pool.get(42_161).is_ok(), "arbitrum present"); - assert!(pool.get(8_453).is_ok(), "base present"); - } - - #[test] - fn unknown_chain_surfaces_typed_error() { - let pool = OrderBookPool::default(); - assert!(matches!( - pool.get(99_999), - Err(CowApiError::UnknownChain(99_999)) - )); - } - - /// Build a pool whose Mainnet entry points at `mock.uri()`. - /// `OrderBookApi::new_with_base_url` ships in cowprotocol; we - /// rely on it so wiremock-driven tests can exercise the full - /// request path without re-implementing the HTTP client. - fn pool_with_mainnet_at(mock: &MockServer) -> OrderBookPool { - let mut clients = std::collections::BTreeMap::new(); - clients.insert( - Chain::Mainnet.id(), - OrderBookApi::new_with_base_url(mock.uri().parse().expect("mock uri parses")), - ); - OrderBookPool { - clients, - http: reqwest::Client::new(), - } - } - - #[tokio::test] - async fn request_passes_get_path_through() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/version")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"version":"x.y.z"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request(Chain::Mainnet.id(), "GET", "/api/v1/version", None) - .await - .expect("request succeeds"); - assert_eq!(body, r#"{"version":"x.y.z"}"#); - } - - #[tokio::test] - async fn request_relative_path_works() { - // Module passes a path without a leading slash. The - // passthrough should still resolve against the orderbook - // base URL. - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/native_price/0xabc")) - .respond_with(ResponseTemplate::new(200).set_body_string("1.23")) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request( - Chain::Mainnet.id(), - "GET", - "api/v1/native_price/0xabc", - None, - ) - .await - .expect("relative path resolves"); - assert_eq!(body, "1.23"); - } - - #[tokio::test] - async fn request_rejects_unknown_method() { - let pool = OrderBookPool::default(); - let err = pool - .request(Chain::Mainnet.id(), "PATCH", "/x", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::BadMethod(_))); - } - - #[tokio::test] - async fn request_post_with_body_is_forwarded() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/v1/quote")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"quote":"ok"}"#)) - .expect(1) - .mount(&mock) - .await; - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request( - Chain::Mainnet.id(), - "POST", - "/api/v1/quote", - Some(r#"{"sellToken":"0x01"}"#), - ) - .await - .expect("post with body succeeds"); - assert_eq!(body, r#"{"quote":"ok"}"#); - } - - #[tokio::test] - async fn request_4xx_response_is_returned_verbatim() { - // The host must NOT surface a 4xx as an error - the module - // needs the structured JSON body to decode `OrderPostError`. - let mock = MockServer::start().await; - let error_body = r#"{"errorType":"InsufficientFee","description":"fee too low"}"#; - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with( - ResponseTemplate::new(400).set_body_string(error_body), - ) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request( - Chain::Mainnet.id(), - "POST", - "/api/v1/orders", - Some(r#"{"test":true}"#), - ) - .await - .expect("4xx body is returned, not an Err"); - assert_eq!(body, error_body); - } - - #[tokio::test] - async fn request_rejects_unknown_chain() { - let pool = OrderBookPool::default(); - let err = pool.request(99_999, "GET", "/x", None).await.unwrap_err(); - assert!(matches!(err, CowApiError::UnknownChain(99_999))); - } - - #[tokio::test] - async fn submit_order_propagates_orderbook_response() { - let mock = MockServer::start().await; - let body_json = sample_order_json(); - // cowprotocol POST /api/v1/orders returns the order UID - // (56-byte hex) as a JSON string body. - let returned_uid = format!("\"0x{}\"", "ab".repeat(56)); - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(201).set_body_string(returned_uid.clone())) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let uid = pool - .submit_order_json(Chain::Mainnet.id(), body_json.as_bytes()) - .await - .expect("submit succeeds"); - assert_eq!(uid.as_slice().len(), 56); - assert_eq!(uid.as_slice(), &[0xab; 56]); - } - - /// A minimal but accepted-by-cowprotocol OrderCreation JSON. We - /// generate it inside the test so the JSON shape stays in lockstep - /// with the published `cowprotocol` version. - fn sample_order_json() -> String { - use alloy_primitives::{Address, U256}; - use cowprotocol::OrderCreation; - use cowprotocol::app_data::{EMPTY_APP_DATA_HASH, EMPTY_APP_DATA_JSON}; - use cowprotocol::order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}; - use cowprotocol::signature::Signature; - use cowprotocol::signing_scheme::SigningScheme; - - let order_data = OrderData { - sell_token: Address::from([0x01; 20]), - buy_token: Address::from([0x02; 20]), - receiver: None, - sell_amount: U256::from(100u64), - buy_amount: U256::from(99u64), - valid_to: u32::MAX, - app_data: EMPTY_APP_DATA_HASH, - fee_amount: U256::ZERO, - kind: OrderKind::Sell, - partially_fillable: false, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - }; - let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); - let creation = OrderCreation::from_signed_order_data( - &order_data, - signature, - Address::from([0x03; 20]), - EMPTY_APP_DATA_JSON.to_owned(), - None, - ) - .expect("valid OrderCreation"); - serde_json::to_string(&creation).expect("serialise OrderCreation") - } -} +#[cfg(test)] +mod tests; diff --git a/crates/nexum-engine/src/host/cow_orderbook/tests.rs b/crates/nexum-engine/src/host/cow_orderbook/tests.rs new file mode 100644 index 0000000..ef318c9 --- /dev/null +++ b/crates/nexum-engine/src/host/cow_orderbook/tests.rs @@ -0,0 +1,208 @@ +use super::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[test] +fn pool_indexes_default_chains() { + let pool = OrderBookPool::default(); + assert!(pool.get(1).is_ok(), "mainnet present"); + assert!(pool.get(100).is_ok(), "gnosis present"); + assert!(pool.get(11_155_111).is_ok(), "sepolia present"); + assert!(pool.get(42_161).is_ok(), "arbitrum present"); + assert!(pool.get(8_453).is_ok(), "base present"); +} + +#[test] +fn unknown_chain_surfaces_typed_error() { + let pool = OrderBookPool::default(); + assert!(matches!( + pool.get(99_999), + Err(CowApiError::UnknownChain(99_999)) + )); +} + +/// Build a pool whose Mainnet entry points at `mock.uri()`. +/// `OrderBookApi::new_with_base_url` ships in cowprotocol; we +/// rely on it so wiremock-driven tests can exercise the full +/// request path without re-implementing the HTTP client. +fn pool_with_mainnet_at(mock: &MockServer) -> OrderBookPool { + let mut clients = std::collections::BTreeMap::new(); + clients.insert( + Chain::Mainnet.id(), + OrderBookApi::new_with_base_url(mock.uri().parse().expect("mock uri parses")), + ); + OrderBookPool { + clients, + http: reqwest::Client::new(), + } +} + +#[tokio::test] +async fn request_passes_get_path_through() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/version")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"version":"x.y.z"}"#)) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request(Chain::Mainnet.id(), "GET", "/api/v1/version", None) + .await + .expect("request succeeds"); + assert_eq!(body, r#"{"version":"x.y.z"}"#); +} + +#[tokio::test] +async fn request_relative_path_works() { + // Module passes a path without a leading slash. The + // passthrough should still resolve against the orderbook + // base URL. + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/native_price/0xabc")) + .respond_with(ResponseTemplate::new(200).set_body_string("1.23")) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request( + Chain::Mainnet.id(), + "GET", + "api/v1/native_price/0xabc", + None, + ) + .await + .expect("relative path resolves"); + assert_eq!(body, "1.23"); +} + +#[tokio::test] +async fn request_rejects_unknown_method() { + let pool = OrderBookPool::default(); + let err = pool + .request(Chain::Mainnet.id(), "PATCH", "/x", None) + .await + .unwrap_err(); + assert!(matches!(err, CowApiError::BadMethod(_))); +} + +#[tokio::test] +async fn request_post_with_body_is_forwarded() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/quote")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"quote":"ok"}"#)) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request( + Chain::Mainnet.id(), + "POST", + "/api/v1/quote", + Some(r#"{"sellToken":"0x01"}"#), + ) + .await + .expect("post with body succeeds"); + assert_eq!(body, r#"{"quote":"ok"}"#); +} + +#[tokio::test] +async fn request_4xx_response_is_returned_verbatim() { + // The host must NOT surface a 4xx as an error - the module + // needs the structured JSON body to decode `OrderPostError`. + let mock = MockServer::start().await; + let error_body = r#"{"errorType":"InsufficientFee","description":"fee too low"}"#; + Mock::given(method("POST")) + .and(path("/api/v1/orders")) + .respond_with(ResponseTemplate::new(400).set_body_string(error_body)) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let body = pool + .request( + Chain::Mainnet.id(), + "POST", + "/api/v1/orders", + Some(r#"{"test":true}"#), + ) + .await + .expect("4xx body is returned, not an Err"); + assert_eq!(body, error_body); +} + +#[tokio::test] +async fn request_rejects_unknown_chain() { + let pool = OrderBookPool::default(); + let err = pool.request(99_999, "GET", "/x", None).await.unwrap_err(); + assert!(matches!(err, CowApiError::UnknownChain(99_999))); +} + +#[tokio::test] +async fn submit_order_propagates_orderbook_response() { + let mock = MockServer::start().await; + let body_json = sample_order_json(); + // cowprotocol POST /api/v1/orders returns the order UID + // (56-byte hex) as a JSON string body. + let returned_uid = format!("\"0x{}\"", "ab".repeat(56)); + Mock::given(method("POST")) + .and(path("/api/v1/orders")) + .respond_with(ResponseTemplate::new(201).set_body_string(returned_uid.clone())) + .expect(1) + .mount(&mock) + .await; + + let pool = pool_with_mainnet_at(&mock); + let uid = pool + .submit_order_json(Chain::Mainnet.id(), body_json.as_bytes()) + .await + .expect("submit succeeds"); + assert_eq!(uid.as_slice().len(), 56); + assert_eq!(uid.as_slice(), &[0xab; 56]); +} + +/// A minimal but accepted-by-cowprotocol OrderCreation JSON. We +/// generate it inside the test so the JSON shape stays in lockstep +/// with the published `cowprotocol` version. +fn sample_order_json() -> String { + use alloy_primitives::{Address, U256}; + use cowprotocol::OrderCreation; + use cowprotocol::app_data::{EMPTY_APP_DATA_HASH, EMPTY_APP_DATA_JSON}; + use cowprotocol::order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}; + use cowprotocol::signature::Signature; + use cowprotocol::signing_scheme::SigningScheme; + + let order_data = OrderData { + sell_token: Address::from([0x01; 20]), + buy_token: Address::from([0x02; 20]), + receiver: None, + sell_amount: U256::from(100u64), + buy_amount: U256::from(99u64), + valid_to: u32::MAX, + app_data: EMPTY_APP_DATA_HASH, + fee_amount: U256::ZERO, + kind: OrderKind::Sell, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }; + let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); + let creation = OrderCreation::from_signed_order_data( + &order_data, + signature, + Address::from([0x03; 20]), + EMPTY_APP_DATA_JSON.to_owned(), + None, + ) + .expect("valid OrderCreation"); + serde_json::to_string(&creation).expect("serialise OrderCreation") +} diff --git a/crates/nexum-engine/src/host/local_store_redb.rs b/crates/nexum-engine/src/host/local_store_redb.rs index 96f02eb..c832137 100644 --- a/crates/nexum-engine/src/host/local_store_redb.rs +++ b/crates/nexum-engine/src/host/local_store_redb.rs @@ -155,85 +155,4 @@ pub enum StorageError { } #[cfg(test)] -mod tests { - use super::*; - - fn fresh() -> (tempfile::TempDir, LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let store = LocalStore::open(dir.path().join("ls.redb")).expect("open"); - (dir, store) - } - - #[test] - fn set_get_roundtrip() { - let (_dir, store) = fresh(); - store.set("twap", "k", b"v").unwrap(); - assert_eq!(store.get("twap", "k").unwrap().as_deref(), Some(&b"v"[..])); - } - - #[test] - fn namespaces_isolate_modules() { - let (_dir, store) = fresh(); - store.set("a", "k", b"from-a").unwrap(); - store.set("b", "k", b"from-b").unwrap(); - assert_eq!( - store.get("a", "k").unwrap().as_deref(), - Some(&b"from-a"[..]) - ); - assert_eq!( - store.get("b", "k").unwrap().as_deref(), - Some(&b"from-b"[..]) - ); - } - - #[test] - fn delete_then_get_is_none() { - let (_dir, store) = fresh(); - store.set("twap", "k", b"v").unwrap(); - store.delete("twap", "k").unwrap(); - assert!(store.get("twap", "k").unwrap().is_none()); - } - - #[test] - fn list_keys_strips_namespace_prefix() { - let (_dir, store) = fresh(); - store.set("twap", "posted:1", b"x").unwrap(); - store.set("twap", "posted:2", b"y").unwrap(); - store.set("twap", "other", b"z").unwrap(); - let keys = store.list_keys("twap", "posted:").unwrap(); - assert_eq!(keys.len(), 2); - assert!(keys.iter().all(|k| k.starts_with("posted:"))); - } - - #[test] - fn rejects_empty_namespace() { - let (_dir, store) = fresh(); - let err = store.set("", "k", b"v").unwrap_err(); - assert!(matches!(err, StorageError::InvalidNamespace(_))); - } - - #[test] - fn prefix_is_fixed_32_bytes() { - let short = namespace_prefix("a").unwrap(); - let long = namespace_prefix(&"a".repeat(300)).unwrap(); - assert_eq!(short.len(), PREFIX_LEN); - assert_eq!(long.len(), PREFIX_LEN); - // Different inputs produce different prefixes. - assert_ne!(short, long); - } - - #[test] - fn prefix_is_deterministic() { - let p1 = namespace_prefix("twap-monitor").unwrap(); - let p2 = namespace_prefix("twap-monitor").unwrap(); - assert_eq!(p1, p2); - } - - #[test] - fn similar_names_differ() { - // Verify that names that share a common prefix don't collide. - let pa = namespace_prefix("module-a").unwrap(); - let pb = namespace_prefix("module-b").unwrap(); - assert_ne!(pa, pb); - } -} +mod tests; diff --git a/crates/nexum-engine/src/host/local_store_redb/tests.rs b/crates/nexum-engine/src/host/local_store_redb/tests.rs new file mode 100644 index 0000000..5c4feba --- /dev/null +++ b/crates/nexum-engine/src/host/local_store_redb/tests.rs @@ -0,0 +1,80 @@ +use super::*; + +fn fresh() -> (tempfile::TempDir, LocalStore) { + let dir = tempfile::tempdir().expect("tempdir"); + let store = LocalStore::open(dir.path().join("ls.redb")).expect("open"); + (dir, store) +} + +#[test] +fn set_get_roundtrip() { + let (_dir, store) = fresh(); + store.set("twap", "k", b"v").unwrap(); + assert_eq!(store.get("twap", "k").unwrap().as_deref(), Some(&b"v"[..])); +} + +#[test] +fn namespaces_isolate_modules() { + let (_dir, store) = fresh(); + store.set("a", "k", b"from-a").unwrap(); + store.set("b", "k", b"from-b").unwrap(); + assert_eq!( + store.get("a", "k").unwrap().as_deref(), + Some(&b"from-a"[..]) + ); + assert_eq!( + store.get("b", "k").unwrap().as_deref(), + Some(&b"from-b"[..]) + ); +} + +#[test] +fn delete_then_get_is_none() { + let (_dir, store) = fresh(); + store.set("twap", "k", b"v").unwrap(); + store.delete("twap", "k").unwrap(); + assert!(store.get("twap", "k").unwrap().is_none()); +} + +#[test] +fn list_keys_strips_namespace_prefix() { + let (_dir, store) = fresh(); + store.set("twap", "posted:1", b"x").unwrap(); + store.set("twap", "posted:2", b"y").unwrap(); + store.set("twap", "other", b"z").unwrap(); + let keys = store.list_keys("twap", "posted:").unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().all(|k| k.starts_with("posted:"))); +} + +#[test] +fn rejects_empty_namespace() { + let (_dir, store) = fresh(); + let err = store.set("", "k", b"v").unwrap_err(); + assert!(matches!(err, StorageError::InvalidNamespace(_))); +} + +#[test] +fn prefix_is_fixed_32_bytes() { + let short = namespace_prefix("a").unwrap(); + let long = namespace_prefix(&"a".repeat(300)).unwrap(); + assert_eq!(short.len(), PREFIX_LEN); + assert_eq!(long.len(), PREFIX_LEN); + // Different inputs produce different prefixes. + assert_ne!(short, long); +} + +#[test] +fn prefix_is_deterministic() { + let p1 = namespace_prefix("twap-monitor").unwrap(); + let p2 = namespace_prefix("twap-monitor").unwrap(); + assert_eq!(p1, p2); +} + +#[test] +fn similar_names_differ() { + // Verify that names that share a common prefix don't collide. + let pa = namespace_prefix("module-a").unwrap(); + let pb = namespace_prefix("module-b").unwrap(); + assert_ne!(pa, pb); +} diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 88f3615..7aaec9e 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -427,208 +427,6 @@ fn build_alloy_filter( Ok(filter) } -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - - use super::*; - - #[test] - fn empty_supervisor_returns_no_subscriptions() { - let sup = Supervisor { - modules: Vec::new(), - }; - assert!(sup.block_chains().is_empty()); - assert!(sup.log_subscriptions().is_empty()); - assert_eq!(sup.module_count(), 0); - } - - // ── E2E helpers ─────────────────────────────────────────────────────── - - /// Path to the pre-built example WASM component. Tests that need it - /// call `example_wasm_or_skip()` which skips gracefully if absent. - fn example_wasm() -> PathBuf { - // CARGO_MANIFEST_DIR → crates/nexum-engine - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/example.wasm") - } - - fn example_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/example/module.toml") - } - - /// Returns `None` and prints a skip message if the fixture isn't built. - fn example_wasm_or_skip() -> Option { - let p = example_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-module` to enable E2E tests", - p.display() - ); - None - } - } - - fn make_wasmtime_engine() -> wasmtime::Engine { - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - wasmtime::Engine::new(&config).expect("wasmtime engine") - } - - fn make_linker(engine: &wasmtime::Engine) -> Linker { - let mut linker = Linker::::new(engine); - crate::Shepherd::add_to_linker::>( - &mut linker, - |s| s, - ) - .expect("add_to_linker"); - wasmtime_wasi::p2::add_to_linker_async(&mut linker).expect("add_wasi"); - linker - } - /// Return `(dir, store)` so the test holds the `TempDir` for the - /// duration of the test scope and cleans it up on drop. Forgetting - /// the dir (the old `ManuallyDrop` approach) leaks it for the - /// entire process lifetime. - fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("ls.redb"); - let store = crate::host::local_store_redb::LocalStore::open(path).expect("local store"); - (dir, store) - } - - // ── E2E tests ───────────────────────────────────────────────────────── - - /// Boot supervisor with the example module; verify it starts alive. - #[tokio::test] - async fn e2e_supervisor_boots_example_module() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); - let provider_pool = crate::host::provider_pool::ProviderPool::empty(); - let (_dir, local_store) = temp_local_store(); - - let supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(example_module_toml()).as_deref(), - &cow_pool, - &provider_pool, - &local_store, - ) - .await - .expect("boot_single"); - - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - } - - /// Boot with a manifest that subscribes to block events; dispatch one - /// block event and verify the module was invoked and stayed alive. - #[tokio::test] - async fn e2e_block_subscription_dispatched() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); - let provider_pool = crate::host::provider_pool::ProviderPool::empty(); - let (_dir, local_store) = temp_local_store(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &cow_pool, - &provider_pool, - &local_store, - ) - .await - .expect("boot_single"); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block).await; - assert_eq!(dispatched, 1, "one module subscribed to chain 1 blocks"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - } - - // ── build_alloy_filter ──────────────────────────────────────────────── - - #[test] - fn alloy_filter_with_address_and_topic() { - let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; - let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; - let filter = build_alloy_filter(Some(addr), Some(topic)).unwrap(); - // Check address is set (alloy Filter doesn't expose a simple getter, - // but we can verify the filter serialises the address field). - let serialised = serde_json::to_value(&filter).unwrap(); - let addr_field = serialised.get("address").unwrap().to_string().to_lowercase(); - assert!(addr_field.contains(&addr.to_lowercase()[2..])); // strip 0x - } - - #[test] - fn alloy_filter_no_address_no_topic() { - let filter = build_alloy_filter(None, None).unwrap(); - let serialised = serde_json::to_value(&filter).unwrap(); - // Address and topics should be absent or null. - assert!( - serialised.get("address").is_none() - || serialised["address"].is_null() - || serialised["address"] == serde_json::json!([]) - ); - } - - #[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()); - } -} +#[cfg(test)] +mod tests; diff --git a/crates/nexum-engine/src/supervisor/tests.rs b/crates/nexum-engine/src/supervisor/tests.rs new file mode 100644 index 0000000..b290ed4 --- /dev/null +++ b/crates/nexum-engine/src/supervisor/tests.rs @@ -0,0 +1,202 @@ + use std::path::{Path, PathBuf}; + + use super::*; + + #[test] + fn empty_supervisor_returns_no_subscriptions() { + let sup = Supervisor { + modules: Vec::new(), + }; + assert!(sup.block_chains().is_empty()); + assert!(sup.log_subscriptions().is_empty()); + assert_eq!(sup.module_count(), 0); + } + + // ── E2E helpers ─────────────────────────────────────────────────────── + + /// Path to the pre-built example WASM component. Tests that need it + /// call `example_wasm_or_skip()` which skips gracefully if absent. + fn example_wasm() -> PathBuf { + // CARGO_MANIFEST_DIR → crates/nexum-engine + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/wasm32-wasip2/release/example.wasm") + } + + fn example_module_toml() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("modules/example/module.toml") + } + + /// Returns `None` and prints a skip message if the fixture isn't built. + fn example_wasm_or_skip() -> Option { + let p = example_wasm(); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - run `just build-module` to enable E2E tests", + p.display() + ); + None + } + } + + fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") + } + + fn make_linker(engine: &wasmtime::Engine) -> Linker { + let mut linker = Linker::::new(engine); + crate::Shepherd::add_to_linker::>( + &mut linker, + |s| s, + ) + .expect("add_to_linker"); + wasmtime_wasi::p2::add_to_linker_async(&mut linker).expect("add_wasi"); + linker + } + + /// Return `(dir, store)` so the test holds the `TempDir` for the + /// duration of the test scope and cleans it up on drop. Forgetting + /// the dir (the old `ManuallyDrop` approach) leaks it for the + /// entire process lifetime. + fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ls.redb"); + let store = crate::host::local_store_redb::LocalStore::open(path).expect("local store"); + (dir, store) + } + + // ── E2E tests ───────────────────────────────────────────────────────── + + /// Boot supervisor with the example module; verify it starts alive. + #[tokio::test] + async fn e2e_supervisor_boots_example_module() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); + let provider_pool = crate::host::provider_pool::ProviderPool::empty(); + let (_dir, local_store) = temp_local_store(); + + let supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(example_module_toml()).as_deref(), + &cow_pool, + &provider_pool, + &local_store, + ) + .await + .expect("boot_single"); + + assert_eq!(supervisor.module_count(), 1); + assert_eq!(supervisor.alive_count(), 1); + } + + /// Boot with a manifest that subscribes to block events; dispatch one + /// block event and verify the module was invoked and stayed alive. + #[tokio::test] + async fn e2e_block_subscription_dispatched() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "block" +chain_id = 1 +"#, + ) + .unwrap(); + + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); + let provider_pool = crate::host::provider_pool::ProviderPool::empty(); + let (_dir, local_store) = temp_local_store(); + + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &cow_pool, + &provider_pool, + &local_store, + ) + .await + .expect("boot_single"); + + let block = nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + let dispatched = supervisor.dispatch_block(block).await; + assert_eq!(dispatched, 1, "one module subscribed to chain 1 blocks"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + } + + // ── build_alloy_filter ──────────────────────────────────────────────── + + #[test] + fn alloy_filter_with_address_and_topic() { + let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; + let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; + let filter = build_alloy_filter(Some(addr), Some(topic)).unwrap(); + // Check address is set (alloy Filter doesn't expose a simple getter, + // but we can verify the filter serialises the address field). + let serialised = serde_json::to_value(&filter).unwrap(); + let addr_field = serialised.get("address").unwrap().to_string().to_lowercase(); + assert!(addr_field.contains(&addr.to_lowercase()[2..])); // strip 0x + } + + #[test] + fn alloy_filter_no_address_no_topic() { + let filter = build_alloy_filter(None, None).unwrap(); + let serialised = serde_json::to_value(&filter).unwrap(); + // Address and topics should be absent or null. + assert!( + serialised.get("address").is_none() + || serialised["address"].is_null() + || serialised["address"] == serde_json::json!([]) + ); + } + + #[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()); + } From 40519798cda37645936b69a8b1fd6054a1f98254 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Fri, 19 Jun 2026 09:56:50 -0300 Subject: [PATCH 14/15] review: address mfw78 feedback on PR #9 (thiserror + url + clap) Three changes mapping 1:1 to the threads on https://github.com/nullislabs/shepherd/pull/9: 1. manifest/error.rs - convert ParseError + CapabilityViolation to thiserror::Error derives. The hand-written Display + std::error::Error impls collapse into #[derive(thiserror::Error)] + #[error("...")] attributes; ParseError gains #[from] on the Io and Toml variants so callers in load.rs drop their map_err and use ? directly. Matches mfw78's "all errors as library errors should be thiserror" guidance from PR #8 (commit c83a042) which I had not applied to this file on PR #9's branch. 2. manifest/load.rs - drop the hand-rolled extract_host scheme stripper, delegate to url::Url::parse + host_str. Inherits RFC 3986 handling of user-info, port, IDNA, IPv6 brackets etc. for free. url is already in Cargo.toml (pulled transitively by reqwest and listed directly). Return type changes from Option<&str> to Option (the parsed URL is dropped before we return; borrowing back into it would require a different signature shape). The single caller in host/impls/http.rs adapts with a one-character &host. Tests use .as_deref() so the asserted string literals stay literal. 3. cli.rs - replace the hand-rolled argv parser with clap = "4" using the derive API. Cli keeps the same struct shape and field names (wasm, manifest, engine_config) so main.rs's Cli::parse() call site is unchanged. clap auto-generates --help, rejects unknown flags loudly (also resolves lgahdl's review comment on the same surface), and is the idiomatic Rust CLI choice mfw78 pointed at. Out of scope for this commit (lgahdl review threads, will address separately if needed): graceful exit on stream-end (block + log), supervisor dispatch_block chain_id extraction, ManuallyDrop test helper, README chains TOML syntax, sequential dispatch note. Drive-by: cargo fmt cleanup of supervisor/tests.rs + cow_orderbook.rs + main.rs (pre-existing drift unrelated to the review threads but surfaced by running fmt on the touched crate; verified zero functional change by re-running cargo test -p nexum-engine which still reports 41/41 passed). Validation on this branch: - cargo test -p nexum-engine: 41 passed, 0 failed - cargo clippy -p nexum-engine --all-targets --tests -- -D warnings: clean - cargo fmt -p nexum-engine --check: clean AI assistance disclosure: applied by Claude (Opus 4.7, 1M context); each change maps to a specific review thread and is paired with a reply on that thread. --- crates/nexum-engine/Cargo.toml | 3 + crates/nexum-engine/src/cli.rs | 60 ++- crates/nexum-engine/src/host/cow_orderbook.rs | 1 - crates/nexum-engine/src/host/impls/http.rs | 4 +- crates/nexum-engine/src/main.rs | 4 +- crates/nexum-engine/src/manifest/error.rs | 48 +-- crates/nexum-engine/src/manifest/load.rs | 42 +- crates/nexum-engine/src/supervisor.rs | 1 - crates/nexum-engine/src/supervisor/tests.rs | 386 +++++++++--------- 9 files changed, 261 insertions(+), 288 deletions(-) diff --git a/crates/nexum-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml index 047abfb..7517639 100644 --- a/crates/nexum-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -15,6 +15,9 @@ anyhow = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } +# CLI. +clap = { version = "4", features = ["derive"] } + # Manifest parsing. serde = { version = "1", features = ["derive"] } toml = "1" diff --git a/crates/nexum-engine/src/cli.rs b/crates/nexum-engine/src/cli.rs index 6488909..3ac9aa2 100644 --- a/crates/nexum-engine/src/cli.rs +++ b/crates/nexum-engine/src/cli.rs @@ -1,46 +1,32 @@ -//! Manual CLI parser. Kept hand-rolled (instead of pulling clap) because -//! the surface is small and unlikely to grow in 0.2. +//! CLI surface, parsed via `clap`'s derive API. use std::path::PathBuf; -/// Parsed CLI surface. -/// -/// `nexum-engine [ []] [--engine-config ]` +use clap::Parser; + +/// `nexum-engine` argument parser. /// -/// Positional `` is a backwards-compat shortcut that -/// synthesises a one-module engine config. Production deployments pass -/// `--engine-config` and declare modules in TOML. -#[derive(Debug, Default)] +/// Production deployments pass `--engine-config ` and declare +/// modules in TOML. The positional `` (with optional +/// ``) is a backwards-compat shortcut that synthesises +/// a one-module engine config so the historical +/// `cargo run -- ./modules/example/example.wasm` flow keeps working. +#[derive(Debug, Parser)] +#[command( + name = "nexum-engine", + about = "Multi-module supervisor for shepherd WASM components." +)] pub struct Cli { + /// Positional WASM component to boot when `--engine-config` is + /// not supplied (or when its `[[modules]]` list is empty). pub wasm: Option, + + /// Optional manifest (`module.toml`) sibling for the positional + /// ``. Ignored when `--engine-config` is supplied. pub manifest: Option, - pub engine_config: Option, -} -impl Cli { - pub fn parse() -> Self { - let mut args = std::env::args().skip(1); - let mut cli = Self::default(); - let mut positional = Vec::new(); - while let Some(arg) = args.next() { - match arg.as_str() { - "--engine-config" => cli.engine_config = args.next().map(PathBuf::from), - "-h" | "--help" => { - eprintln!( - "usage: nexum-engine [ []] \ - [--engine-config ]" - ); - std::process::exit(0); - } - _ => positional.push(arg), - } - } - if let Some(p) = positional.first() { - cli.wasm = Some(PathBuf::from(p)); - } - if let Some(p) = positional.get(1) { - cli.manifest = Some(PathBuf::from(p)); - } - cli - } + /// Path to the `engine.toml` describing chains + modules. When + /// omitted, falls back to the in-repo default (single-module mode). + #[arg(long, value_name = "PATH")] + pub engine_config: Option, } diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs index 49c1945..865ab88 100644 --- a/crates/nexum-engine/src/host/cow_orderbook.rs +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -136,6 +136,5 @@ pub enum CowApiError { Orderbook(#[from] cowprotocol::Error), } - #[cfg(test)] mod tests; diff --git a/crates/nexum-engine/src/host/impls/http.rs b/crates/nexum-engine/src/host/impls/http.rs index 2900d4d..be67509 100644 --- a/crates/nexum-engine/src/host/impls/http.rs +++ b/crates/nexum-engine/src/host/impls/http.rs @@ -30,8 +30,8 @@ impl nexum::host::http::Host for HostState { }); } }; - if !host_allowed(host, &self.http_allowlist) { - warn!(host, "[http] denied by allowlist"); + if !host_allowed(&host, &self.http_allowlist) { + warn!(host = %host, "[http] denied by allowlist"); return Err(HostError { domain: "http".into(), kind: HostErrorKind::Denied, diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs index 26fac50..04bc076 100644 --- a/crates/nexum-engine/src/main.rs +++ b/crates/nexum-engine/src/main.rs @@ -16,6 +16,7 @@ mod manifest; mod runtime; mod supervisor; +use clap::Parser; use tracing::{info, warn}; use tracing_subscriber::EnvFilter; use wasmtime::Engine; @@ -115,7 +116,8 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } - let block_streams = runtime::event_loop::open_block_streams(&provider_pool, &block_chains).await; + let block_streams = + runtime::event_loop::open_block_streams(&provider_pool, &block_chains).await; let log_streams = runtime::event_loop::open_log_streams(&provider_pool, log_subs).await; let shutdown = async { diff --git a/crates/nexum-engine/src/manifest/error.rs b/crates/nexum-engine/src/manifest/error.rs index 98bf7d7..1f0b2a6 100644 --- a/crates/nexum-engine/src/manifest/error.rs +++ b/crates/nexum-engine/src/manifest/error.rs @@ -3,32 +3,25 @@ use super::types::KNOWN_CAPABILITIES; /// Errors returned while loading or validating a manifest. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum ParseError { - Io(std::io::Error), - Toml(toml::de::Error), + #[error("manifest: i/o: {0}")] + Io(#[from] std::io::Error), + #[error("manifest: parse: {0}")] + Toml(#[from] toml::de::Error), + #[error( + "manifest: unknown capability {0:?} in [capabilities].required (known: {known})", + known = KNOWN_CAPABILITIES.join(", ") + )] UnknownCapability(String), } -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(e) => write!(f, "manifest: i/o: {e}"), - Self::Toml(e) => write!(f, "manifest: parse: {e}"), - Self::UnknownCapability(name) => write!( - f, - "manifest: unknown capability {:?} in [capabilities].required (known: {})", - name, - KNOWN_CAPABILITIES.join(", ") - ), - } - } -} - -impl std::error::Error for ParseError {} - /// Error returned when a component's WIT imports exceed its declared capabilities. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] +#[error( + "component imports `{capability}` ({wit_import}) but it is not listed in \ + [capabilities].required or [capabilities].optional" +)] pub struct CapabilityViolation { /// Capability name (e.g. `"remote-store"`). pub capability: String, @@ -36,16 +29,3 @@ pub struct CapabilityViolation { /// `"nexum:host/remote-store@0.2.0"`). pub wit_import: String, } - -impl std::fmt::Display for CapabilityViolation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "component imports `{}` ({}) but it is not listed in \ - [capabilities].required or [capabilities].optional", - self.capability, self.wit_import - ) - } -} - -impl std::error::Error for CapabilityViolation {} diff --git a/crates/nexum-engine/src/manifest/load.rs b/crates/nexum-engine/src/manifest/load.rs index b857a76..bd3ad8b 100644 --- a/crates/nexum-engine/src/manifest/load.rs +++ b/crates/nexum-engine/src/manifest/load.rs @@ -14,8 +14,8 @@ use super::types::{KNOWN_CAPABILITIES, LoadedManifest, Manifest}; /// Read `module.toml` from `path`, parse, validate, and emit a deprecation /// warning if `[capabilities]` is absent (0.1-compat fallback). pub fn load(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(ParseError::Io)?; - let manifest: Manifest = toml::from_str(&raw).map_err(ParseError::Toml)?; + let raw = std::fs::read_to_string(path)?; + let manifest: Manifest = toml::from_str(&raw)?; let caps = manifest.capabilities.as_ref(); if caps.is_none() { @@ -100,21 +100,15 @@ pub fn host_allowed(host: &str, allowlist: &[String]) -> bool { } /// Extract the host component from a URL. Returns `None` for non-http(s) -/// schemes or malformed input. Intentionally simple - adds no `url` -/// crate dependency. -pub fn extract_host(url: &str) -> Option<&str> { - let after_scheme = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://"))?; - let host_end = after_scheme - .find('/') - .or_else(|| after_scheme.find('?')) - .unwrap_or(after_scheme.len()); - let host = &after_scheme[..host_end]; - // strip optional user-info and port. - let host = host.rsplit('@').next().unwrap_or(host); - let host = host.split(':').next().unwrap_or(host); - if host.is_empty() { None } else { Some(host) } +/// schemes or malformed input. Delegates to the `url` crate (already +/// pulled in transitively by `reqwest`) so we inherit its RFC 3986 +/// handling of user-info, port, IDNA, IPv6 brackets, etc. +pub fn extract_host(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + parsed.host_str().map(|h| h.to_owned()) } fn stringify_toml_value(v: &toml::Value) -> String { @@ -227,15 +221,21 @@ enabled = true #[test] fn extract_host_handles_common_shapes() { assert_eq!( - extract_host("https://api.example.com/v1/x"), + extract_host("https://api.example.com/v1/x").as_deref(), Some("api.example.com") ); - assert_eq!(extract_host("http://example.com"), Some("example.com")); assert_eq!( - extract_host("https://user:pw@host.example.com:8443/x"), + extract_host("http://example.com").as_deref(), + Some("example.com") + ); + assert_eq!( + extract_host("https://user:pw@host.example.com:8443/x").as_deref(), Some("host.example.com") ); - assert_eq!(extract_host("https://example.com?q=1"), Some("example.com")); + assert_eq!( + extract_host("https://example.com?q=1").as_deref(), + Some("example.com") + ); assert_eq!(extract_host("ftp://example.com"), None); assert_eq!(extract_host("not a url"), None); } diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 7aaec9e..adbfebb 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -427,6 +427,5 @@ fn build_alloy_filter( Ok(filter) } - #[cfg(test)] mod tests; diff --git a/crates/nexum-engine/src/supervisor/tests.rs b/crates/nexum-engine/src/supervisor/tests.rs index b290ed4..331cc9c 100644 --- a/crates/nexum-engine/src/supervisor/tests.rs +++ b/crates/nexum-engine/src/supervisor/tests.rs @@ -1,125 +1,125 @@ - use std::path::{Path, PathBuf}; - - use super::*; - - #[test] - fn empty_supervisor_returns_no_subscriptions() { - let sup = Supervisor { - modules: Vec::new(), - }; - assert!(sup.block_chains().is_empty()); - assert!(sup.log_subscriptions().is_empty()); - assert_eq!(sup.module_count(), 0); - } - - // ── E2E helpers ─────────────────────────────────────────────────────── - - /// Path to the pre-built example WASM component. Tests that need it - /// call `example_wasm_or_skip()` which skips gracefully if absent. - fn example_wasm() -> PathBuf { - // CARGO_MANIFEST_DIR → crates/nexum-engine - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/example.wasm") - } - - fn example_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/example/module.toml") - } - - /// Returns `None` and prints a skip message if the fixture isn't built. - fn example_wasm_or_skip() -> Option { - let p = example_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-module` to enable E2E tests", - p.display() - ); - None - } - } - - fn make_wasmtime_engine() -> wasmtime::Engine { - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - wasmtime::Engine::new(&config).expect("wasmtime engine") - } - - fn make_linker(engine: &wasmtime::Engine) -> Linker { - let mut linker = Linker::::new(engine); - crate::Shepherd::add_to_linker::>( - &mut linker, - |s| s, - ) - .expect("add_to_linker"); - wasmtime_wasi::p2::add_to_linker_async(&mut linker).expect("add_wasi"); - linker - } - - /// Return `(dir, store)` so the test holds the `TempDir` for the - /// duration of the test scope and cleans it up on drop. Forgetting - /// the dir (the old `ManuallyDrop` approach) leaks it for the - /// entire process lifetime. - fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("ls.redb"); - let store = crate::host::local_store_redb::LocalStore::open(path).expect("local store"); - (dir, store) - } - - // ── E2E tests ───────────────────────────────────────────────────────── - - /// Boot supervisor with the example module; verify it starts alive. - #[tokio::test] - async fn e2e_supervisor_boots_example_module() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); - let provider_pool = crate::host::provider_pool::ProviderPool::empty(); - let (_dir, local_store) = temp_local_store(); - - let supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(example_module_toml()).as_deref(), - &cow_pool, - &provider_pool, - &local_store, - ) - .await - .expect("boot_single"); - - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); +use std::path::{Path, PathBuf}; + +use super::*; + +#[test] +fn empty_supervisor_returns_no_subscriptions() { + let sup = Supervisor { + modules: Vec::new(), + }; + assert!(sup.block_chains().is_empty()); + assert!(sup.log_subscriptions().is_empty()); + assert_eq!(sup.module_count(), 0); +} + +// ── E2E helpers ─────────────────────────────────────────────────────── + +/// Path to the pre-built example WASM component. Tests that need it +/// call `example_wasm_or_skip()` which skips gracefully if absent. +fn example_wasm() -> PathBuf { + // CARGO_MANIFEST_DIR → crates/nexum-engine + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/wasm32-wasip2/release/example.wasm") +} + +fn example_module_toml() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("modules/example/module.toml") +} + +/// Returns `None` and prints a skip message if the fixture isn't built. +fn example_wasm_or_skip() -> Option { + let p = example_wasm(); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - run `just build-module` to enable E2E tests", + p.display() + ); + None } - - /// Boot with a manifest that subscribes to block events; dispatch one - /// block event and verify the module was invoked and stayed alive. - #[tokio::test] - async fn e2e_block_subscription_dispatched() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - r#" +} + +fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") +} + +fn make_linker(engine: &wasmtime::Engine) -> Linker { + let mut linker = Linker::::new(engine); + crate::Shepherd::add_to_linker::< + crate::HostState, + wasmtime::component::HasSelf, + >(&mut linker, |s| s) + .expect("add_to_linker"); + wasmtime_wasi::p2::add_to_linker_async(&mut linker).expect("add_wasi"); + linker +} + +/// Return `(dir, store)` so the test holds the `TempDir` for the +/// duration of the test scope and cleans it up on drop. Forgetting +/// the dir (the old `ManuallyDrop` approach) leaks it for the +/// entire process lifetime. +fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ls.redb"); + let store = crate::host::local_store_redb::LocalStore::open(path).expect("local store"); + (dir, store) +} + +// ── E2E tests ───────────────────────────────────────────────────────── + +/// Boot supervisor with the example module; verify it starts alive. +#[tokio::test] +async fn e2e_supervisor_boots_example_module() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); + let provider_pool = crate::host::provider_pool::ProviderPool::empty(); + let (_dir, local_store) = temp_local_store(); + + let supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(example_module_toml()).as_deref(), + &cow_pool, + &provider_pool, + &local_store, + ) + .await + .expect("boot_single"); + + assert_eq!(supervisor.module_count(), 1); + assert_eq!(supervisor.alive_count(), 1); +} + +/// Boot with a manifest that subscribes to block events; dispatch one +/// block event and verify the module was invoked and stayed alive. +#[tokio::test] +async fn e2e_block_subscription_dispatched() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" [module] name = "example" @@ -130,73 +130,77 @@ required = ["logging"] kind = "block" chain_id = 1 "#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); - let provider_pool = crate::host::provider_pool::ProviderPool::empty(); - let (_dir, local_store) = temp_local_store(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &cow_pool, - &provider_pool, - &local_store, - ) - .await - .expect("boot_single"); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block).await; - assert_eq!(dispatched, 1, "one module subscribed to chain 1 blocks"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - } - - // ── build_alloy_filter ──────────────────────────────────────────────── - - #[test] - fn alloy_filter_with_address_and_topic() { - let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; - let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; - let filter = build_alloy_filter(Some(addr), Some(topic)).unwrap(); - // Check address is set (alloy Filter doesn't expose a simple getter, - // but we can verify the filter serialises the address field). - let serialised = serde_json::to_value(&filter).unwrap(); - let addr_field = serialised.get("address").unwrap().to_string().to_lowercase(); - assert!(addr_field.contains(&addr.to_lowercase()[2..])); // strip 0x - } - - #[test] - fn alloy_filter_no_address_no_topic() { - let filter = build_alloy_filter(None, None).unwrap(); - let serialised = serde_json::to_value(&filter).unwrap(); - // Address and topics should be absent or null. - assert!( - serialised.get("address").is_none() - || serialised["address"].is_null() - || serialised["address"] == serde_json::json!([]) - ); - } - - #[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()); - } + ) + .unwrap(); + + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let cow_pool = crate::host::cow_orderbook::OrderBookPool::default(); + let provider_pool = crate::host::provider_pool::ProviderPool::empty(); + let (_dir, local_store) = temp_local_store(); + + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &cow_pool, + &provider_pool, + &local_store, + ) + .await + .expect("boot_single"); + + let block = nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + let dispatched = supervisor.dispatch_block(block).await; + assert_eq!(dispatched, 1, "one module subscribed to chain 1 blocks"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); +} + +// ── build_alloy_filter ──────────────────────────────────────────────── + +#[test] +fn alloy_filter_with_address_and_topic() { + let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; + let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; + let filter = build_alloy_filter(Some(addr), Some(topic)).unwrap(); + // Check address is set (alloy Filter doesn't expose a simple getter, + // but we can verify the filter serialises the address field). + let serialised = serde_json::to_value(&filter).unwrap(); + let addr_field = serialised + .get("address") + .unwrap() + .to_string() + .to_lowercase(); + assert!(addr_field.contains(&addr.to_lowercase()[2..])); // strip 0x +} + +#[test] +fn alloy_filter_no_address_no_topic() { + let filter = build_alloy_filter(None, None).unwrap(); + let serialised = serde_json::to_value(&filter).unwrap(); + // Address and topics should be absent or null. + assert!( + serialised.get("address").is_none() + || serialised["address"].is_null() + || serialised["address"] == serde_json::json!([]) + ); +} + +#[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()); +} From db860c0688dfc6b49835dc61114f64e8bd58d1d8 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Fri, 19 Jun 2026 10:12:51 -0300 Subject: [PATCH 15/15] review: align hex_encode + ProviderPool::empty with PR #8 mfw78 replies Bruno's PR #8 replies (commit c83a042) claimed two things that the code did not actually do: 1. "hex_encode is now a thin wrapper over alloy_primitives::hex::encode" - the function still did `write!("{b:02x}")` against a pre-allocated String. Now actually delegates to `alloy_primitives::hex::encode`. alloy-primitives is already a direct dependency, so no Cargo.toml change. 2. "ProviderPool::empty is #[cfg(test)] now" - the gate was the looser `#[cfg_attr(not(test), allow(dead_code))]`, which silences the warning but keeps the function compiled into non-test builds. All callers (in-file tests, `supervisor::empty_for_test`, every helper in supervisor/tests.rs) are themselves `#[cfg(test)]`, so the strict gate is safe. Validation: cargo test -p nexum-engine: 41 passed, 0 failed; cargo clippy -p nexum-engine --all-targets --tests -- -D warnings: clean. AI assistance disclosure: applied by Claude (Opus 4.7, 1M context) while sweeping the rest of the open PRs for the same antipatterns mfw78 flagged. --- crates/nexum-engine/src/host/error.rs | 15 +++++---------- crates/nexum-engine/src/host/provider_pool.rs | 10 ++++++---- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/nexum-engine/src/host/error.rs b/crates/nexum-engine/src/host/error.rs index 15b7255..9f0682c 100644 --- a/crates/nexum-engine/src/host/error.rs +++ b/crates/nexum-engine/src/host/error.rs @@ -28,15 +28,10 @@ pub(crate) fn internal_error(domain: &str, detail: impl Into) -> HostErr } } -/// Lowercase hex encoder. Kept in the engine binary rather than -/// pulling a `hex` crate just for one call site. Writes into the -/// pre-allocated buffer to avoid the per-byte `String` allocation -/// `format!("{b:02x}")` would do. +/// Lowercase hex encoder. Thin wrapper over +/// [`alloy_primitives::hex::encode`] so the engine reuses the +/// already-pulled alloy primitive instead of carrying its own +/// formatter (mfw78 review feedback on PR #8). pub(crate) fn hex_encode(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - write!(s, "{b:02x}").expect("writing to String never fails"); - } - s + alloy_primitives::hex::encode(bytes) } diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs index d65e391..49698a5 100644 --- a/crates/nexum-engine/src/host/provider_pool.rs +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -66,10 +66,12 @@ impl ProviderPool { }) } - /// Empty pool - used by tests and as a default when no - /// `engine.toml` is found. Every `request` call returns - /// `UnknownChain`. - #[cfg_attr(not(test), allow(dead_code))] + /// Empty pool used as a test fixture. Every `request` call + /// returns `UnknownChain`. Gated to `#[cfg(test)]` per mfw78's + /// PR #8 review feedback - all callers (in-file tests, + /// `supervisor::empty_for_test`, `supervisor::tests`) are + /// themselves `#[cfg(test)]`. + #[cfg(test)] pub fn empty() -> Self { Self { providers: Arc::new(BTreeMap::new()),