From 2054584ca20f55430a2c5abf8e131b930f615189 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 1 Jun 2026 14:19:42 -0300 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] =?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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 81ee7342e4d4d1442d112eb72282d267ef37f17d Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 1 Jun 2026 16:12:50 -0300 Subject: [PATCH 14/14] chore(deps): patch cowprotocol to bleu/cow-rs main (post-alpha.3) --- Cargo.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index d14c23e..b71581a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,18 @@ edition = "2024" license = "AGPL-3.0" repository = "https://github.com/nullisLabs/shepherd" +# `cowprotocol` v1.0.0-alpha.3 (the crates.io release the engine +# depends on) was cut from `cowdao-grants/cow-rs` PR #5 at commit +# `1742ffa`. `bleu/cow-rs` main has 18 commits since, including the +# `composable::Proof` width fix (relevant to the TWAP poll path), +# `OrderCreation` zero-from-address fast-fail (closes a MEDIUM +# review finding from PR #5), and the `order_book` / `composable` +# submodule splits. Patching to that commit picks them up without +# waiting for an alpha.4 publish. Drop once `cowprotocol >= 1.0.0-alpha.4` +# ships. +[patch.crates-io] +cowprotocol = { git = "https://github.com/bleu/cow-rs", rev = "c012404ffefc411bff543d2290e19ba7fbef2516" } + [profile.dev] panic = "abort"