diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 65e55d8..98ec03f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -24,7 +24,7 @@ body: id: scope attributes: label: Scope - description: Universal `web3:runtime` change, domain extension (e.g. `shepherd:cow`), runtime-only, or SDK-only? + description: Universal `nexum:host` change, domain extension (e.g. `shepherd:cow`), runtime-only, or SDK-only? - type: textarea id: extra attributes: diff --git a/Cargo.toml b/Cargo.toml index ef89fef..d14c23e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ - "crates/nxm-engine", + "crates/nexum-engine", "modules/example", ] resolver = "2" diff --git a/README.md b/README.md index a494e80..d146082 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ Shepherd is the [CoW Protocol](https://cow.fi) distribution of **Nexum**, a WebAssembly Component Model runtime for secure, sandboxed execution of capability-scoped modules. -A module compiled against the universal `web3:runtime/headless-module` world runs on any Nexum-compatible host. A module compiled against `shepherd:cow/shepherd-module` additionally gains access to CoW Protocol APIs and order submission — and requires a Shepherd host. +A module compiled against the universal `nexum:host/event-module` world runs on any Nexum-compatible host. A module compiled against `shepherd:cow/shepherd` additionally gains access to CoW Protocol APIs and order submission — and requires a Shepherd host. + +> **Upgrading from 0.1?** See the [Migration Guide](docs/migration/0.1-to-0.2.md) for the full rename table, the new `host-error` model, and the manifest-driven capability negotiation introduced in 0.2. ## Why @@ -20,12 +22,14 @@ A module compiled against the universal `web3:runtime/headless-module` world run | Path | Purpose | | --- | --- | -| `crates/nxm-engine/` | Host runtime — wasmtime-based component loader and host implementations. | +| `crates/nexum-engine/` | The **engine** — a wasmtime-based host *implementation* of the `nexum:host` contract. The reference server runtime. | +| `wit/nexum-host/` | The **`nexum:host` WIT package** — the host/guest *contract* (interfaces, types, worlds) that every engine implements and every module imports. | +| `wit/shepherd-cow/` | `shepherd:cow` WIT package — CoW Protocol-specific extensions on top of `nexum:host`. | | `modules/example/` | Reference guest module demonstrating the module ABI. | -| `wit/web3-runtime/` | Universal `web3:runtime` WIT package (csn, identity, local-store, remote-store, msg, logging). | -| `wit/shepherd-cow/` | `shepherd:cow` WIT package — CoW Protocol-specific extensions. | | `docs/` | Architecture, design notes, and the universal primitive taxonomy. Start with [`docs/00-overview.md`](docs/00-overview.md). | +> **Engine vs. host.** "Engine" is a concrete implementation that runs WASM components (today: `nexum-engine`, a wasmtime-based daemon). The `nexum:host` WIT package is the *contract* — the host-imports surface a guest sees. Other engines (mobile, browser) can implement the same `nexum:host` contract; modules built against the contract run on any compliant engine. + ## Building Shepherd uses [Nix](https://nixos.org/) flakes to pin the toolchain and [just](https://github.com/casey/just) as a task runner. @@ -51,14 +55,15 @@ Without Nix, you need: Rust (edition 2024, see `rust-toolchain.toml` if present) The `docs/` directory contains the design corpus: - [`00-overview.md`](docs/00-overview.md) — architecture, primitives, WIT worlds -- [`01-runtime-environment.md`](docs/01-runtime-environment.md) — host runtime +- [`01-runtime-environment.md`](docs/01-runtime-environment.md) — engine internals (wasmtime, fuel, epoch, ResourceLimiter) - [`02-modules-events-packaging.md`](docs/02-modules-events-packaging.md) — module ABI, events, packaging - [`03-module-discovery.md`](docs/03-module-discovery.md) — static / ENS / on-chain registry - [`04-state-store.md`](docs/04-state-store.md) — local + remote state - [`05-sdk-design.md`](docs/05-sdk-design.md) — guest SDK - [`06-production-hardening.md`](docs/06-production-hardening.md) — operational concerns -- [`07-rpc-namespace-design.md`](docs/07-rpc-namespace-design.md) — `csn` namespace +- [`07-rpc-namespace-design.md`](docs/07-rpc-namespace-design.md) — `chain` namespace - [`08-platform-generalisation.md`](docs/08-platform-generalisation.md) — beyond CoW +- [`migration/0.1-to-0.2.md`](docs/migration/0.1-to-0.2.md) — upgrading from Nexum 0.1 ## Contributing diff --git a/crates/nxm-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml similarity index 67% rename from crates/nxm-engine/Cargo.toml rename to crates/nexum-engine/Cargo.toml index 91eb722..65768c3 100644 --- a/crates/nxm-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "nxm-engine" -version = "0.1.0" +name = "nexum-engine" +version = "0.2.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -10,3 +10,6 @@ wasmtime = { version = "45", features = ["component-model"] } wasmtime-wasi = "45" anyhow = "1" tokio = { version = "1", features = ["full"] } +getrandom = "0.4" +serde = { version = "1", features = ["derive"] } +toml = "1" diff --git a/crates/nexum-engine/src/main.rs b/crates/nexum-engine/src/main.rs new file mode 100644 index 0000000..f013f22 --- /dev/null +++ b/crates/nexum-engine/src/main.rs @@ -0,0 +1,440 @@ +mod manifest; + +use std::path::PathBuf; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use wasmtime::component::{Component, Linker, ResourceTable}; +use wasmtime::error::Context as _; +use wasmtime::{Engine, Store}; +use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, 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; + +struct HostState { + wasi: WasiCtx, + table: ResourceTable, + /// 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). + /// Consulted by `http::fetch` before any outbound call. + http_allowlist: Vec, +} + +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, + } +} + +// -- Stub implementations for host interfaces -- + +impl nexum::host::types::Host for 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(); + eprintln!("[cow-api] {method} {path}"); + let result = Err(unimplemented( + "cow-api", + format!("not implemented: {method} {path}"), + )); + eprintln!("[timing] cow-api::request: {:?}", start.elapsed()); + result + } + + async fn submit_order( + &mut self, + _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()); + result + } +} + +impl nexum::host::chain::Host for HostState { + async fn request( + &mut self, + _chain_id: u64, + method: 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()); + result + } + + async fn request_batch( + &mut self, + chain_id: u64, + requests: Vec, + ) -> Result, HostError> { + let start = Instant::now(); + eprintln!("[chain] request-batch: {} calls", requests.len()); + let mut out = Vec::with_capacity(requests.len()); + for req in requests { + match self.request(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()); + Ok(out) + } +} + +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 + } + + 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 + } + + async fn sign_typed_data( + &mut self, + _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 + } +} + +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 + } + + 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 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 + } + + 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 + } +} + +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 + } + + 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 + } + + async fn read_feed( + &mut self, + _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 + } + + 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 + } +} + +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 query( + &mut self, + 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 + } +} + +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()); + } +} + +// -- 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 { + 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, + code: 0, + message: format!("not an http(s) URL: {}", req.url), + data: None, + }); + } + }; + if !manifest::host_allowed(host, &self.http_allowlist) { + eprintln!("[http] denied by allowlist: {host}"); + eprintln!("[timing] http::fetch: {:?}", start.elapsed()); + return Err(HostError { + domain: "http".into(), + kind: HostErrorKind::Denied, + code: 0, + message: format!( + "host {host} not in [capabilities.http].allow; \ + add it to nexum.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. + let result = Err(unimplemented( + "http", + "fetch not implemented in 0.2 reference runtime (allowlist passed)", + )); + eprintln!("[timing] http::fetch: {:?}", start.elapsed()); + result + } +} + +#[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); + + println!("nexum-engine: loading component from {wasm_path}"); + + // Load the manifest from the explicit path if given, otherwise from + // `nexum.toml` next to the component file. Missing → fallback (with + // deprecation warning). + 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() => { + println!("nexum-engine: loading manifest from {}", p.display()); + manifest::load(p)? + } + _ => manifest::fallback_manifest(), + }; + + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + let engine = Engine::new(&config)?; + + let start = Instant::now(); + let component = + Component::from_file(&engine, &wasm_path).context("failed to load component")?; + eprintln!("[timing] component load: {:?}", start.elapsed()); + + let mut linker = Linker::::new(&engine); + Shepherd::add_to_linker::>( + &mut linker, + |state| state, + )?; + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + + let wasi = WasiCtxBuilder::new().inherit_stdio().build(); + + let mut store = Store::new( + &engine, + HostState { + wasi, + table: ResourceTable::new(), + monotonic_baseline: Instant::now(), + http_allowlist: loaded.http_allowlist, + }, + ); + + let start = Instant::now(); + let bindings = Shepherd::instantiate_async(&mut store, &component, &linker) + .await + .context("failed to instantiate component")?; + eprintln!("[timing] component instantiate: {:?}", start.elapsed()); + + 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. + let config_entries: Config = if loaded.config.is_empty() { + vec![("name".into(), loaded.manifest.module.name.clone())] + } else { + loaded.config + }; + let 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 + ), + } + 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..."); + 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 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 + ), + } + eprintln!("[timing] call_on_event: {:?}", start.elapsed()); + + println!("nexum-engine: done"); + Ok(()) +} diff --git a/crates/nexum-engine/src/manifest.rs b/crates/nexum-engine/src/manifest.rs new file mode 100644 index 0000000..522e168 --- /dev/null +++ b/crates/nexum-engine/src/manifest.rs @@ -0,0 +1,265 @@ +//! Minimal `nexum.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: +//! +//! - `[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 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. + +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, +} + +#[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. +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)>, +} + +/// Read `nexum.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 nexum.toml — \ + defaulting to all-required (0.1 behaviour). This default \ + will be removed in 0.3; add an explicit [capabilities] block \ + now." + ); + } + + 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 `nexum.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 \ + (0.1 behaviour). This default will be removed in 0.3; ship a \ + nexum.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)); + } +} diff --git a/crates/nxm-engine/src/main.rs b/crates/nxm-engine/src/main.rs deleted file mode 100644 index 6771d42..0000000 --- a/crates/nxm-engine/src/main.rs +++ /dev/null @@ -1,286 +0,0 @@ -use std::time::Instant; -use wasmtime::component::{Component, Linker, ResourceTable}; -use wasmtime::{Engine, Store}; -use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; - -wasmtime::component::bindgen!({ - path: "../../wit/shepherd-cow", - world: "shepherd-module", - imports: { default: async }, - exports: { default: async }, -}); - -struct HostState { - wasi: WasiCtx, - table: ResourceTable, -} - -impl WasiView for HostState { - fn ctx(&mut self) -> WasiCtxView<'_> { - WasiCtxView { - ctx: &mut self.wasi, - table: &mut self.table, - } - } -} - -// -- Stub implementations for host interfaces -- - -impl web3::runtime::types::Host for HostState {} - -impl shepherd::cow::cow::Host for HostState { - async fn request( - &mut self, - _chain_id: u64, - method: String, - path: String, - _body: Option, - ) -> Result { - let start = Instant::now(); - eprintln!("[cow] {method} {path}"); - let result = Err(shepherd::cow::cow::ApiError { - status: 501, - message: "not implemented".into(), - body: None, - }); - eprintln!("[timing] cow::request: {:?}", start.elapsed()); - result - } -} - -impl shepherd::cow::order::Host for HostState { - async fn submit(&mut self, _chain_id: u64, _order_data: Vec) -> Result { - let start = Instant::now(); - eprintln!("[order] submit"); - let result = Err("not implemented".into()); - eprintln!("[timing] order::submit: {:?}", start.elapsed()); - result - } -} - -impl web3::runtime::csn::Host for HostState { - async fn request( - &mut self, - _chain_id: u64, - method: String, - _params: String, - ) -> Result { - let start = Instant::now(); - eprintln!("[csn] request: {method}"); - let result = Err(web3::runtime::csn::JsonRpcError { - code: -32601, - message: format!("method not implemented: {method}"), - data: None, - }); - eprintln!("[timing] csn::request: {:?}", start.elapsed()); - result - } -} - -impl web3::runtime::local_store::Host for HostState { - async fn get(&mut self, key: String) -> Result>, String> { - let start = Instant::now(); - eprintln!("[local-store] get: {key}"); - let result = Ok(None); - eprintln!("[timing] local-store::get: {:?}", start.elapsed()); - result - } - - async fn set(&mut self, key: String, _value: Vec) -> Result<(), String> { - let start = Instant::now(); - eprintln!("[local-store] set: {key}"); - let result = Ok(()); - eprintln!("[timing] local-store::set: {:?}", start.elapsed()); - result - } - - async fn delete(&mut self, key: String) -> Result<(), String> { - let start = Instant::now(); - eprintln!("[local-store] delete: {key}"); - let result = Ok(()); - eprintln!("[timing] local-store::delete: {:?}", start.elapsed()); - result - } - - async fn list_keys(&mut self, prefix: String) -> Result, String> { - let start = Instant::now(); - eprintln!("[local-store] list-keys: {prefix}"); - let result = Ok(vec![]); - eprintln!("[timing] local-store::list-keys: {:?}", start.elapsed()); - result - } -} - -impl web3::runtime::remote_store::Host for HostState { - async fn upload( - &mut self, - _data: Vec, - ) -> Result, web3::runtime::remote_store::StoreError> { - let start = Instant::now(); - let result = Err(web3::runtime::remote_store::StoreError { - code: 501, - message: "not implemented".into(), - }); - eprintln!("[timing] remote-store::upload: {:?}", start.elapsed()); - result - } - - async fn download( - &mut self, - _reference: Vec, - ) -> Result, web3::runtime::remote_store::StoreError> { - let start = Instant::now(); - let result = Err(web3::runtime::remote_store::StoreError { - code: 501, - message: "not implemented".into(), - }); - eprintln!("[timing] remote-store::download: {:?}", start.elapsed()); - result - } - - async fn feed_get( - &mut self, - _owner: Vec, - _topic: Vec, - ) -> Result>, web3::runtime::remote_store::StoreError> { - let start = Instant::now(); - let result = Err(web3::runtime::remote_store::StoreError { - code: 501, - message: "not implemented".into(), - }); - eprintln!("[timing] remote-store::feed-get: {:?}", start.elapsed()); - result - } - - async fn feed_set( - &mut self, - _topic: Vec, - _data: Vec, - ) -> Result, web3::runtime::remote_store::StoreError> { - let start = Instant::now(); - let result = Err(web3::runtime::remote_store::StoreError { - code: 501, - message: "not implemented".into(), - }); - eprintln!("[timing] remote-store::feed-set: {:?}", start.elapsed()); - result - } -} - -impl web3::runtime::msg::Host for HostState { - async fn publish( - &mut self, - content_topic: String, - _payload: Vec, - ) -> Result<(), web3::runtime::msg::MsgError> { - let start = Instant::now(); - eprintln!("[msg] publish: {content_topic}"); - let result = Err(web3::runtime::msg::MsgError { - code: 501, - message: "not implemented".into(), - }); - eprintln!("[timing] msg::publish: {:?}", start.elapsed()); - result - } - - async fn query( - &mut self, - content_topic: String, - _start_time: Option, - _end_time: Option, - _limit: Option, - ) -> Result, web3::runtime::msg::MsgError> { - let start = Instant::now(); - eprintln!("[msg] query: {content_topic}"); - let result = Ok(vec![]); - eprintln!("[timing] msg::query: {:?}", start.elapsed()); - result - } -} - -impl web3::runtime::logging::Host for HostState { - async fn log(&mut self, level: web3::runtime::logging::Level, message: String) { - let start = Instant::now(); - let level_str = match level { - web3::runtime::logging::Level::Trace => "TRACE", - web3::runtime::logging::Level::Debug => "DEBUG", - web3::runtime::logging::Level::Info => "INFO", - web3::runtime::logging::Level::Warn => "WARN", - web3::runtime::logging::Level::Error => "ERROR", - }; - eprintln!("[{level_str}] {message}"); - eprintln!("[timing] logging::log: {:?}", start.elapsed()); - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let wasm_path = std::env::args() - .nth(1) - .ok_or_else(|| anyhow::anyhow!("usage: nxm-engine "))?; - - println!("nxm-engine: loading component from {wasm_path}"); - - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - let engine = Engine::new(&config)?; - - let start = Instant::now(); - let component = - Component::from_file(&engine, &wasm_path).context("failed to load component")?; - eprintln!("[timing] component load: {:?}", start.elapsed()); - - let mut linker = Linker::::new(&engine); - ShepherdModule::add_to_linker::>( - &mut linker, - |state| state, - )?; - wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - - let wasi = WasiCtxBuilder::new().inherit_stdio().build(); - - let mut store = Store::new( - &engine, - HostState { - wasi, - table: ResourceTable::new(), - }, - ); - - let start = Instant::now(); - let bindings = ShepherdModule::instantiate_async(&mut store, &component, &linker) - .await - .context("failed to instantiate component")?; - eprintln!("[timing] component instantiate: {:?}", start.elapsed()); - - // Call init with config - println!("nxm-engine: calling init..."); - let config_entries: Config = vec![("name".into(), "example".into())]; - let start = Instant::now(); - match bindings.call_init(&mut store, &config_entries).await? { - Ok(()) => println!("nxm-engine: init succeeded"), - Err(e) => println!("nxm-engine: init failed: {e}"), - } - eprintln!("[timing] call_init: {:?}", start.elapsed()); - - // Dispatch a test block event - println!("nxm-engine: dispatching test block event..."); - let block = web3::runtime::types::BlockData { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000, - }; - let event = web3::runtime::types::Event::Block(block); - let start = Instant::now(); - match bindings.call_on_event(&mut store, &event).await? { - Ok(()) => println!("nxm-engine: on-event succeeded"), - Err(e) => println!("nxm-engine: on-event failed: {e}"), - } - eprintln!("[timing] call_on_event: {:?}", start.elapsed()); - - println!("nxm-engine: done"); - Ok(()) -} - -use wasmtime::error::Context as _; diff --git a/docs/00-overview.md b/docs/00-overview.md index 939e9e5..a09de9b 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -2,7 +2,20 @@ Nexum is a WASM Component Model runtime that provides secure, sandboxed execution for WebAssembly modules. Modules react to blockchain events, read chain state, persist data locally and to decentralised storage, communicate via decentralised messaging — all within a capability-based sandbox with zero implicit permissions. -**Shepherd** is the Nexum distribution that includes CoW Protocol extensions (`shepherd:cow` WIT package). A module compiled against the universal `web3:runtime/headless-module` world runs on any Nexum-compatible host. A module compiled against `shepherd:cow/shepherd-module` additionally gains access to CoW Protocol APIs and order submission — and requires a Shepherd host. +**Shepherd** is the Nexum distribution that includes CoW Protocol extensions (`shepherd:cow` WIT package). A module compiled against the universal `nexum:host/event-module` world runs on any Nexum-compatible host. A module compiled against `shepherd:cow/shepherd` additionally gains access to CoW Protocol APIs and order submission — and requires a Shepherd host. + +### Vocabulary: engine vs. host (`nexum-engine` vs. `nexum:host`) + +Two project names look similar but mean different things — keeping them straight is load-bearing for everything that follows: + +| Term | What it is | Where you find it | +|---|---|---| +| **engine** (`nexum-engine`) | A concrete *implementation* that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. Mobile / browser / embedded engines could exist later — each is a separate engine. | `crates/nexum-engine/`, the binary, `cargo run -p nexum-engine` | +| **host** (`nexum:host`) | The WIT *contract* — the set of host-imported interfaces (chain, identity, local-store, etc.), types, and worlds that every engine must implement and every module imports. The contract is one; engines are many. | `wit/nexum-host/`, `package nexum:host@0.2.0`, Rust path `nexum::host::*` | + +The relationship: an engine *implements* `nexum:host` so that modules *built against* `nexum:host` can run on it. The `nexum:host` package itself does not run anything — it's a specification. When this doc says "the host", it means whichever engine the module currently runs on, as seen through the `nexum:host` contract. + +> **Upgrading from 0.1?** See the [Migration Guide](migration/0.1-to-0.2.md) for the full rename table (`web3:runtime` → `nexum:host`, `csn` → `chain`, `msg` → `messaging`, `headless-module` → `event-module`, etc.), the unified `host-error` model, and the manifest-driven capability negotiation introduced in 0.2. ## Architecture @@ -20,8 +33,8 @@ flowchart TB end subgraph host["Host API — WIT Interfaces"] - uni["web3:runtime\ncsn · identity · local-store · remote-store · msg · logging"] - ext["shepherd:cow\ncow · order"] + uni["nexum:host\nchain · identity · local-store · remote-store · messaging · logging"] + ext["shepherd:cow\ncow-api"] end subgraph back["Backends"] @@ -53,74 +66,85 @@ flowchart TB ## The Six Primitives -Every module has access to six orthogonal capabilities through the `web3:runtime` WIT package: +Every module has access to six orthogonal capabilities through the `nexum:host` WIT package: | Primitive | Interface | Purpose | Scope | Backend (Server) | |-----------|-----------|---------|-------|-------------------| -| **Consensus** | `csn` | Read/write blockchain state via JSON-RPC | Global (per chain) | alloy Provider | +| **Chain** | `chain` | Read/write blockchain state via JSON-RPC | Global (per chain) | alloy Provider | | **Identity** | `identity` | Key management and message signing | Per-account | Keystore / KMS / HSM | | **Local Store** | `local-store` | Per-module key-value persistence | Device-local, per-module | redb | | **Remote Store** | `remote-store` | Decentralised content-addressed storage | Global (content-addressed) | Ethereum Swarm | -| **Messaging** | `msg` | Decentralised pub/sub messaging | Topic-based | Waku | +| **Messaging** | `messaging` | Decentralised pub/sub messaging | Topic-based | Waku | | **Logging** | `logging` | Diagnostic output | Per-module | tracing | These primitives are orthogonal: -- **Consensus** is the source of truth — the blockchain. Modules read chain state and (indirectly) write to it via order submission or transactions. -- **Identity** is cryptographic identity — key management and signing. The `csn` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) delegate to the identity backend. Modules can also import `identity` directly for raw signing operations. +- **Chain** is the source of truth — the blockchain consensus state. Modules read chain state and (indirectly) write to it via order submission or transactions. +- **Identity** is cryptographic identity — key management and signing. The `chain` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) delegate to the identity backend. Modules can also import `identity` directly for raw signing operations. - **Local Store** is the module's private scratchpad — fast, local, scoped to one module on one device. Does not replicate. - **Remote Store** is shared persistent content — content-addressed, decentralised, survives independent of any device. Any module on any device can read what another module wrote. - **Messaging** is real-time communication — ephemeral pub/sub messages between modules, devices, or users. Transient and topic-based. - **Logging** is diagnostics — one-way output for debugging and monitoring. Not a data channel. +## Additive 0.2 Capabilities + +In addition to the six core primitives, the 0.2 WIT introduces three optional capabilities that modules can declare in their manifest: + +- **`clock`** — wall-clock (`now-ms`, UTC milliseconds since Unix epoch) and monotonic (`monotonic-ns`) time, replacing the 0.1 workaround of reading `block.timestamp` inside `on_block`. +- **`random`** — a CSPRNG (`fill(len)`), since 0.1 modules had no source of secure randomness at all. +- **`http`** — an allowlisted outbound HTTP client (`fetch(request)`), gated by a `[capabilities.http].allow` domain list. The host MUST enforce the allowlist. This replaces the 0.1 anti-pattern of tunnelling notifications through Waku. + +0.2 also publishes (but does not yet host) the experimental **`query-module`** world for request/response modules (wallet rule evaluators, signature validators, pricing oracles). The WIT is stable enough to target with `MockHost` tests; production host support lands in 0.3. See the migration guide for the full WIT. + ## WIT Worlds -The WIT is split into layered packages. The universal layer (`web3:runtime`) provides blockchain-agnostic capabilities. Domain extensions (e.g. `shepherd:cow`) add protocol-specific interfaces. +The WIT is split into layered packages. The universal layer (`nexum:host`) provides blockchain-agnostic capabilities. Domain extensions (e.g. `shepherd:cow`) add protocol-specific interfaces. ```mermaid graph TB subgraph l3["Layer 3 — Domain Extensions"] - cow["shepherd:cow\ncow · order"] + cow["shepherd:cow\ncow-api"] other["future:domain\nvault · strategy · …"] end subgraph l1["Layer 1 — Universal Runtime"] - pkg["web3:runtime"] - ifaces["csn · identity · local-store · remote-store · msg · logging"] + pkg["nexum:host"] + ifaces["chain · identity · local-store · remote-store · messaging · logging"] exports["Exports: init · on-event"] end - cow -->|include headless-module| l1 - other -->|include headless-module| l1 + cow -->|include event-module| l1 + other -->|include event-module| l1 ``` ``` // Universal layer — any platform, any blockchain app -package web3:runtime@0.1.0 +package nexum:host@0.2.0 -world headless-module { - import csn — consensus access (JSON-RPC passthrough) +world event-module { + import chain — consensus access (JSON-RPC passthrough) import identity — key management and message signing import local-store — local key-value persistence import remote-store — decentralised storage (Swarm) - import msg — decentralised messaging (Waku) + import messaging — decentralised messaging (Waku) import logging — log (trace/debug/info/warn/error) export init(config) — called once on load - export on_event(event)— called per subscribed event (block, logs, timer, message) + export on_event(event)— called per subscribed event (block, logs, tick, message) } // CoW Protocol extension -package shepherd:cow@0.1.0 +package shepherd:cow@0.2.0 -world shepherd-module { - include headless-module - import cow — CoW Protocol REST API access - import order — submit orders +world shepherd { + include event-module + import cow-api — CoW Protocol REST API + order submission } ``` -No WASI interfaces are imported. All I/O is mediated through host interfaces. The `csn` interface exposes a single generic `request` function — the SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API (80+ methods) with zero WIT churn. The `identity` interface provides key management and signing — `csn` depends on it internally for signing RPC methods, and modules can also use it directly. +The `event-module` world imports **six** interfaces — chain, identity, local-store, remote-store, messaging, logging. The 0.1 WIT framing claimed six primitives but only actually imported five; 0.2 brings `identity` into the world definition so the contract matches the documentation. + +No WASI interfaces are imported. All I/O is mediated through host interfaces. The `chain` interface exposes a single generic `request` function (plus an additive `request-batch` in 0.2) — the SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API (80+ methods) with zero WIT churn. > Design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md) | Platform generalisation: [08-platform-generalisation.md](08-platform-generalisation.md) @@ -131,9 +155,9 @@ No WASI interfaces are imported. All I/O is mediated through host interfaces. Th | Concern | Choice | Version | |---------|--------|---------| | Language | Rust | 1.90+ | -| WASM runtime | wasmtime (Component Model) | 41.x | -| API contract | WIT (`web3:runtime@0.1.0`, `shepherd:cow@0.1.0`) | — | -| Guest bindings | wit-bindgen | 0.53.x | +| WASM runtime | wasmtime (Component Model) | 45.x | +| API contract | WIT (`nexum:host@0.2.0`, `shepherd:cow@0.2.0`) | — | +| Guest bindings | wit-bindgen | 0.57.x | | Async | Tokio | — | | Ethereum RPC | alloy | 1.5.x | | Local store | redb | 3.1.x | @@ -150,8 +174,8 @@ A module ships as a **bundle**: a manifest (`nexum.toml`) plus a compiled WASM c # nexum.toml [module] name = "twap-monitor" -version = "0.2.0" -wasm = "sha256:9f86d081…" # content hash of module.wasm +version = "0.3.0" +component = "sha256:9f86d081…" # content hash of module.wasm [module.resources] max_memory_bytes = 10_485_760 # 10 MB @@ -161,15 +185,20 @@ max_state_bytes = 52_428_800 # 50 MB [chains] required = [42161] # must have RPC for these chains -[[subscribe]] -type = "block" +[capabilities] +required = ["chain", "local-store", "logging"] +optional = ["messaging", "remote-store"] + +[[subscription]] +kind = "block" chain_id = 42161 [config] cow_api_url = "https://api.cow.fi/arbitrum" +slippage_bps = 50 # integers stay integers in 0.2 ``` -The manifest declares identity, resource caps, chain requirements, event subscriptions, and opaque module config — everything the runtime needs to load and run the module. +The manifest declares identity, resource caps, chain requirements, event subscriptions, capability grants, and typed module config — everything the runtime needs to load and run the module. In 0.2, `[capabilities]` is the canonical place to declare what host primitives a module needs; imports listed as `optional` install trap stubs that return `host-error { kind: unsupported }` on call rather than failing instantiation. Omitting `[capabilities]` falls back to "all imports required" with a deprecation warning. -> Full spec: [02-modules-events-packaging.md](02-modules-events-packaging.md) @@ -219,7 +248,7 @@ stateDiagram-v2 - **Sources**: `block` (new heads via `eth_subscribe`), `log` (filtered contract events), `cron` (schedule-based), `message` (Waku content topics). - **Shared subscriptions**: one block subscription per chain, fanned out to all subscribed modules. - **Dispatch**: concurrent across modules, sequential within a module (ordered delivery). -- **Declared in manifest**: `[[subscribe]]` blocks — the runtime wires sources, not the module. +- **Declared in manifest**: `[[subscription]]` blocks — the runtime wires sources, not the module. -> Full design: [02-modules-events-packaging.md](02-modules-events-packaging.md) @@ -241,20 +270,20 @@ The SDK mirrors the WIT layering: `nexum-sdk` (universal) and `shepherd-sdk` (Co | Crate | Provides | |-------|----------| | `nexum-sdk` | `provider(chain_id)` — full alloy `Provider` backed by host RPC via `HostTransport` | -| | `Identity` — signing client (get accounts, sign messages, sign EIP-712 typed data) | +| | `Signer` — signing client (get accounts, sign messages, sign EIP-712 typed data) | | | `TypedState` — serde-based typed local state (postcard serialisation) | | | `RemoteStore` — typed decentralised storage client (upload, download, feeds) | -| | `MsgClient` — typed messaging client (publish, query) | +| | `Messaging` — typed messaging client (publish, query) | | | `abi::sol!` — compile-time Ethereum ABI codec (alloy-sol-types) | | | `log::{info!, …}` — formatted logging macros | -| | `Error` / `Result` — proper error type with `?` support | +| | `HostError` / `HostErrorKind` — unified host error type with `?` support | | | `#[nexum::module]` — proc macro for universal modules | -| `shepherd-sdk` | `CowClient` — typed CoW Protocol API client backed by host `cow` interface | +| `shepherd-sdk` | `Cow` — typed CoW Protocol API client backed by host `cow-api` interface | | | `#[shepherd::module]` — proc macro for CoW modules (extends `#[nexum::module]`) | | | `prelude::*` — all types, interfaces, helpers in one import | | Both | `testing::MockHost` — native-Rust unit tests with mock host | | | `testing::WasmTestHarness` — integration tests in real wasmtime | -| | `cargo nexum` — CLI: new / build / package / publish | +| | `cargo nexum` — CLI: new / build / package / publish / check / migrate | Multi-language support: module authors can use Rust, C/C++, Go, JavaScript, or Python — all compile to valid components against the same WIT world. @@ -269,12 +298,16 @@ Multi-language support: module authors can use Rust, C/C++, Go, JavaScript, or P | CPU (deterministic) | Fuel | Trap -> rollback -> restart | | CPU (wall-clock) | Epoch interruption | Yield to Tokio | | Memory | `ResourceLimiter` | `memory.grow` denied | -| Storage | Host-side tracking | `local-store::set` returns `Err` | +| Storage | Host-side tracking | `local-store::set` returns `host-error { kind: quota-like }` | ### RPC Resilience Tower layer stack per chain: timeout -> retry (exponential + jitter) -> rate limit -> fallback endpoint. WebSocket subscriptions auto-reconnect with missed-block backfill. +### Error Model + +All host functions return `result` in 0.2. `host-error` carries a `domain` string (e.g. `"chain"`, `"store"`, `"messaging"`), a normative `host-error-kind` discriminant (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`), a numeric `code`, a `message`, and optional JSON `data`. Modules match on `kind` for retry/backoff decisions; the per-protocol error types from 0.1 (`json-rpc-error`, `msg-error`, `store-error`, `api-error`) are gone. See the [migration guide](migration/0.1-to-0.2.md#2-error-model-unification-both) for the full shape and the embedder mapping table. + ### Observability | Signal | Stack | Endpoint | @@ -289,16 +322,18 @@ Metrics cover three groups: runtime-level (modules loaded/dead), per-module (eve ## Platform Generalisation -The WIT contract is the universal interface — any host that implements it can run modules unchanged. The architecture generalises beyond the server runtime to four platform targets: +Nexum is **designed** to be portable to mobile and browser hosts: the WIT contract is the universal interface and any host that implements it can run modules unchanged. The **0.2 reference runtime ships server-only** — a Rust/Tokio/wasmtime binary. The mobile, WebView, and super-app targets remain on the roadmap and live in the docs as architectural direction, not shipping artifacts. + +| Platform | WASM Engine | Local Store | RPC Backend | Status | +|----------|-------------|-------------|-------------|--------| +| **Server** (reference) | wasmtime | redb | alloy provider | **Shipping in 0.2** | +| **Mobile** (Flutter/Dart) | wasmtime C API / wasm3 | SQLite | HTTP client | Planned — see roadmap | +| **WebView** | Browser engine + `jco` | IndexedDB | JS bridge / wallet | Planned — see roadmap | +| **Super app** | All of the above | SQLite | HTTP + wallet | Planned — see roadmap | -| Platform | WASM Engine | Local Store | RPC Backend | Use Case | -|----------|-------------|-------------|-------------|----------| -| **Server** (reference) | wasmtime | redb | alloy provider | Headless automation | -| **Mobile** (Flutter/Dart) | wasmtime C API / wasm3 | SQLite | HTTP client | On-device automation | -| **WebView** | Browser engine + `jco` | IndexedDB | JS bridge / wallet | Rich web UIs | -| **Super app** | All of the above | SQLite | HTTP + wallet | Decentralised mini-programs | +The mobile/wallet host story — including the experimental `query-module` world's production support, the C ABI for non-Rust embedders, and the `nexum-host` embedder facade — is on the 0.3 roadmap, conditional on a named design partner. --> Full design: [08-platform-generalisation.md](08-platform-generalisation.md) +-> Full design (and the design rationale for each target): [08-platform-generalisation.md](08-platform-generalisation.md) ## Grant Milestones @@ -315,17 +350,17 @@ The WIT contract is the universal interface — any host that implements it can ``` nexum/ ├── crates/ -│ ├── nxm-engine/ Core WASM host (server), event system, local store -│ ├── nexum-sdk/ Universal Rust SDK (HostTransport, Identity, TypedState, RemoteStore, MsgClient) -│ ├── shepherd-sdk/ CoW Protocol SDK (CowClient, extends nexum-sdk) +│ ├── nexum-engine/ Core WASM host (server), event system, local store +│ ├── nexum-sdk/ Universal Rust SDK (HostTransport, Signer, TypedState, RemoteStore, Messaging) +│ ├── shepherd-sdk/ CoW Protocol SDK (Cow, extends nexum-sdk) │ ├── cli/ nexum operator CLI (run, module, state) -│ └── cargo-nexum/ cargo subcommand for module authors (new, build, package, publish) +│ └── cargo-nexum/ cargo subcommand for module authors (new, build, package, publish, check, migrate) ├── modules/ │ ├── twap-monitor/ TWAP order monitoring module │ └── ethflow-watcher/ Ethflow order monitoring module ├── wit/ -│ ├── web3-runtime/ Universal WIT package (csn, identity, local-store, remote-store, msg, logging) -│ └── shepherd-cow/ CoW Protocol WIT package (cow, order, shepherd-module) +│ ├── nexum-host/ Universal WIT package (chain, identity, local-store, remote-store, messaging, logging) +│ └── shepherd-cow/ CoW Protocol WIT package (cow-api, shepherd) ├── docker/ │ └── Dockerfile └── docs/ @@ -337,5 +372,7 @@ nexum/ ├── 05-sdk-design.md ├── 06-production-hardening.md ├── 07-rpc-namespace-design.md - └── 08-platform-generalisation.md + ├── 08-platform-generalisation.md + └── migration/ + └── 0.1-to-0.2.md ``` diff --git a/docs/01-runtime-environment.md b/docs/01-runtime-environment.md index d87263f..3f72ab0 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -2,7 +2,7 @@ ## Version Target -**wasmtime 41.x** (latest stable as of Feb 2026). +**wasmtime 45.x** (latest stable as of Feb 2026). - Release cadence: new major on the 20th of each month. - LTS every 12th version (24 months support). Nearest LTS: v36. @@ -24,7 +24,7 @@ ### Rationale -The Component Model is **production-viable in wasmtime 41** and gives us critical advantages over raw core modules: +The Component Model is **production-viable in wasmtime 45** and gives us critical advantages over raw core modules: 1. **Structural sandboxing.** A component compiled against a WIT world with no filesystem import literally *cannot* access the filesystem — enforced at the type level, not just by omission of host functions. This is stronger than core module sandboxing where imports are stringly-typed. @@ -34,14 +34,14 @@ The Component Model is **production-viable in wasmtime 41** and gives us critica 4. **Multi-language guests from day 1.** Module authors can use Rust, C/C++, Go, JavaScript (ComponentizeJS), or Python (componentize-py) — all producing valid components against the same WIT world. This dramatically lowers the barrier for community modules. -5. **No WASI required.** The Component Model and WASI are architecturally separate. We define a pure `web3:runtime` world with exactly our host APIs. Zero WASI imports means zero implicit capabilities. +5. **No WASI required.** The Component Model and WASI are architecturally separate. We define a pure `nexum:host` world with exactly our host APIs. Zero WASI imports means zero implicit capabilities. 6. **Acceptable overhead.** The canonical ABI adds marshalling for strings/lists (memory copy across boundary), but for a plugin system with coarse-grained calls this is negligible. `InstancePre` front-loads validation costs. ### What we give up -- **Tooling churn.** `wit-bindgen` (v0.53) and `cargo-component` (v0.21) are functional but APIs are not yet stable. Pin versions in the SDK. -- **Native async Component Model** (`stream`, `future`) is still evolving (v41 had breaking changes to the async canonical ABI). We use basic async host functions (`func_wrap_async`) which are stable. +- **Tooling churn.** `wit-bindgen` (v0.57) and `cargo-component` (v0.21) are functional but APIs are not yet stable. Pin versions in the SDK. +- **Native async Component Model** (`stream`, `future`) is still evolving. We use basic async host functions (`func_wrap_async`) which are stable. ### Risk assessment @@ -90,64 +90,90 @@ store.epoch_deadline_async_yield_and_update(10); // yield after 10 epochs (~1s a ```rust let component = Component::from_file(&engine, "twap_monitor.wasm")?; let mut linker = Linker::new(&engine); -HeadlessModule::add_to_linker(&mut linker, |state| state)?; +EventModule::add_to_linker(&mut linker, |state| state)?; // Pre-validate once, instantiate many times (one per store) let pre = linker.instantiate_pre(&component)?; -let bindings = HeadlessModule::instantiate_pre(&mut store, &pre)?; +let bindings = EventModule::instantiate_pre(&mut store, &pre)?; ``` ## WIT Worlds: Universal and CoW-Specific -Nexum uses a two-layer WIT architecture. The **universal** package `web3:runtime` defines platform-agnostic interfaces and the `headless-module` world. The **CoW-specific** package `shepherd:cow` extends it with CoW Protocol interfaces and the `shepherd-module` world. +Nexum uses a two-layer WIT architecture. The **universal** package `nexum:host` defines platform-agnostic interfaces and the `event-module` world. The **CoW-specific** package `shepherd:cow` extends it with CoW Protocol interfaces and the `shepherd` world. -### Universal Package: `web3:runtime@0.1.0` +### Universal Package: `nexum:host@0.2.0` -The `web3:runtime` package is the single source of truth for the universal host-guest contract. It defines a custom world with **no WASI imports**: +The `nexum:host` package is the single source of truth for the universal host-guest contract. It defines a custom world with **no WASI imports**: ```wit -package web3:runtime@0.1.0; +package nexum:host@0.2.0; interface types { type chain-id = u64; - record block-data { + record block { chain-id: chain-id, number: u64, hash: list, - timestamp: u64, + timestamp: u64, // milliseconds since Unix epoch, UTC } - record log-entry { + record log { chain-id: chain-id, address: list, topics: list>, data: list, block-number: u64, - tx-hash: list, + transaction-hash: list, log-index: u32, } + record tick { + fired-at: u64, // milliseconds since Unix epoch, UTC + } + + record message { + content-topic: string, + payload: list, + timestamp: u64, // milliseconds since Unix epoch, UTC + sender: option>, + } + variant event { - block(block-data), - logs(list), - timer(u64), + block(block), + logs(list), + tick(tick), + message(message), } - /// Opaque config from nexum.toml [config] section. + /// Opaque config from nexum.toml [config] section. All TOML scalars are + /// flattened to their string form by the host. A typed `config-value` + /// variant is on the 0.3 roadmap, bundled with the manifest parser work. type config = list>; -} - -interface csn { - use types.{chain-id}; - /// JSON-RPC error returned by the provider or the host. - record json-rpc-error { - code: s64, + /// Unified error type returned by every host function in 0.2. + record host-error { + domain: string, // "chain" | "store" | "messaging" | "identity" | "cow" | ... + kind: host-error-kind, // normative discriminant + code: s32, // domain-specific message: string, - data: option, + data: option, // JSON for richer context } + variant host-error-kind { + unsupported, // host does not implement this capability + unavailable, // capability exists, backend is down/offline + denied, // user or policy rejected + rate-limited, + timeout, + invalid-input, + internal, + } +} + +interface chain { + use types.{chain-id, host-error}; + /// Execute a JSON-RPC request against the specified chain. /// /// The host forwards the request to the configured alloy provider for @@ -166,35 +192,61 @@ interface csn { /// Note: signing RPC methods (eth_sendTransaction, eth_accounts, /// eth_signTypedData_v4, personal_sign) are intercepted by the host and /// delegated to the identity backend. The module does not need to handle - /// key material directly when using csn for transactions. + /// key material directly when using chain for transactions. request: func(chain-id: chain-id, method: string, params: string) - -> result; + -> result; + + /// A single JSON-RPC request to be executed as part of a batch. + record rpc-request { + method: string, + params: string, + } + + /// Result of a single request inside a batch. Each entry is independent; + /// one failing call does not abort the others. + variant rpc-result { + ok(string), + err(host-error), + } + + /// Additive 0.2 method: batched JSON-RPC. The alloy-backed HostTransport + /// routes RequestPacket::Batch through this — `provider.multicall(...)` + /// actually batches on the wire in 0.2. Hosts that cannot batch natively + /// MUST fall back to sequential `request` calls; the returned list is + /// the same length as `requests` and in the same order. + request-batch: func(chain-id: chain-id, requests: list) + -> result, host-error>; } interface identity { - record identity-error { - code: u16, - message: string, - } + use types.{host-error}; /// Get available signing accounts (20-byte Ethereum addresses). - accounts: func() -> result>, identity-error>; + accounts: func() -> result>, host-error>; - /// Sign raw bytes with the specified account. + /// Sign a message with `personal_sign` semantics. The host MUST prepend + /// the EIP-191 prefix (`\x19Ethereum Signed Message:\n`) before + /// hashing and signing. Hosts MUST NOT expose a raw-bytes signing path + /// through this function — a raw signer can be tricked into signing + /// EIP-155 transactions or EIP-712 payloads disguised as plain bytes. + /// /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - /// Extensible to other signing schemes in future versions. - sign: func(account: list, data: list) -> result, identity-error>; + /// + /// A separate raw-bytes signing primitive, gated by an explicit + /// capability, is on the 0.3 roadmap. + sign: func(account: list, message: list) -> result, host-error>; /// Sign EIP-712 typed data with the specified account. /// `typed-data` is the JSON-encoded EIP-712 TypedData structure. - sign-typed-data: func(account: list, typed-data: string) -> result, identity-error>; + sign-typed-data: func(account: list, typed-data: string) -> result, host-error>; } interface local-store { - get: func(key: string) -> result>, string>; - set: func(key: string, value: list) -> result<_, string>; - delete: func(key: string) -> result<_, string>; - list-keys: func(prefix: string) -> result, string>; + use types.{host-error}; + get: func(key: string) -> result>, host-error>; + set: func(key: string, value: list) -> result<_, host-error>; + delete: func(key: string) -> result<_, host-error>; + list-keys: func(prefix: string) -> result, host-error>; } interface logging { @@ -202,37 +254,39 @@ interface logging { log: func(level: level, message: string); } -/// The universal headless module world. Platform-agnostic: no CoW, +/// The universal event-driven module world. Platform-agnostic: no CoW, /// no domain-specific imports. Suitable for any web3 automation. -world headless-module { - import csn; +/// +/// In 0.2 this imports all six primitives — the identity import was +/// missing from the 0.1 WIT despite being part of the documented primitive +/// taxonomy, and is now present. +world event-module { + import chain; import identity; import local-store; + import remote-store; + import messaging; import logging; - /// Called once on load. Receives config from nexum.toml. - export init: func(config: types.config) -> result<_, string>; + /// Called once on load. Receives typed config from nexum.toml. + export init: func(config: types.config) -> result<_, host-error>; /// Called for each subscribed event. - export on-event: func(event: types.event) -> result<_, string>; + export on-event: func(event: types.event) -> result<_, host-error>; } ``` -### CoW-Specific Package: `shepherd:cow@0.1.0` +In addition to the six core imports, 0.2 publishes three additive optional capabilities — `clock` (`now-ms` / `monotonic-ns`), `random` (CSPRNG `fill`), and `http` (allowlisted outbound HTTP) — which modules can declare in their `nexum.toml` `[capabilities]` section. The migration guide carries the full WIT for each. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. -The `shepherd:cow` package extends the universal world with CoW Protocol interfaces: +### CoW-Specific Package: `shepherd:cow@0.2.0` -```wit -package shepherd:cow@0.1.0; +The `shepherd:cow` package extends the universal world with CoW Protocol interfaces. In 0.2 the two 0.1 interfaces (`cow` + `order`) merge into a single `cow-api` interface to eliminate the `cow::cow::request` triple-stutter: -interface cow { - use web3:runtime/types.{chain-id}; +```wit +package shepherd:cow@0.2.0; - record api-error { - status: u16, - message: string, - body: option, - } +interface cow-api { + use nexum:host/types.{chain-id, host-error}; /// HTTP-style request to the CoW Protocol API. /// @@ -245,52 +299,50 @@ interface cow { method: string, path: string, body: option, - ) -> result; -} - -interface order { - use web3:runtime/types.{chain-id}; + ) -> result; - submit: func(chain-id: chain-id, order-data: list) - -> result; + /// Submit a serialised order to the CoW Protocol. + /// (Replaces the 0.1 `order::submit` interface.) + submit-order: func(chain-id: chain-id, order-data: list) + -> result; } -/// CoW Protocol module world. Extends the universal headless-module -/// with CoW-specific imports (cow API, order submission). -world shepherd-module { - include web3:runtime/headless-module; +/// CoW Protocol module world. Extends the universal event-module +/// with CoW-specific imports. +world shepherd { + include nexum:host/event-module; - import cow; - import order; + import cow-api; } ``` ### Key properties -- **No WASI** — modules cannot access FS, network, clocks, or random. +- **No WASI** — by default, modules cannot access FS, network, clocks, or random. The additive 0.2 capabilities (`clock`, `random`, `http`) provide controlled access to time, entropy, and allowlisted HTTP — but only when declared in the manifest's `[capabilities]` section. - **All I/O through our interfaces** — RPC reads, identity/signing, CoW API, local-store, order submission, logging. -- **Generic JSON-RPC passthrough** — the `csn` interface exposes a single `request` function. The SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API. See doc 07 for details. -- **Identity as a first-class primitive** — the `identity` interface provides key management and signing. The `csn` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) are intercepted and delegated to the identity backend. Modules can also import `identity` directly for raw signing operations (sign arbitrary messages, get accounts). +- **Generic JSON-RPC passthrough** — the `chain` interface exposes a single `request` function (plus an additive `request-batch`). The SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API. See doc 07 for details. +- **Identity as a first-class primitive** — the `identity` interface provides key management and signing. The `chain` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) are intercepted and delegated to the identity backend. Modules can also import `identity` directly for `personal_sign`-style message signing, EIP-712 typed data signing, and listing accounts. (Raw-bytes signing, gated by an explicit capability, is on the 0.3 roadmap; the current `sign` MUST prepend the EIP-191 prefix.) +- **Unified `host-error` taxonomy** — every host function returns `result`. The 0.1 per-protocol error types (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) are gone. Modules match on `host-error-kind` (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`) for retry/backoff decisions. - **`list` for raw bytes** — local-store values, order payloads, signatures, accounts, etc. The SDK provides typed wrappers. - **Resource types** can be added later (e.g. subscription handles, cursor-based log iteration). -- **Two worlds** — `web3:runtime/headless-module` for platform-agnostic modules; `shepherd:cow/shepherd-module` for CoW Protocol modules that need `cow` and `order` imports. +- **Two worlds in 0.2's reference runtime** — `nexum:host/event-module` for platform-agnostic modules; `shepherd:cow/shepherd` for CoW Protocol modules that need the `cow-api` import. The experimental `nexum:host/query-module` world is published but not yet hosted. ## Host-Side Embedding -The host uses `wasmtime::component::bindgen!` to generate Rust traits from the WIT. For universal interfaces, the generated traits live under `web3::runtime::`. For CoW-specific interfaces, they live under `shepherd::cow::`. +The host uses `wasmtime::component::bindgen!` to generate Rust traits from the WIT. For universal interfaces, the generated traits live under `nexum::host::`. For CoW-specific interfaces, they live under `shepherd::cow::`. ```rust -// Universal headless-module world +// Universal event-module world wasmtime::component::bindgen!({ - path: "wit/web3-runtime", - world: "headless-module", + path: "wit/nexum-host", + world: "event-module", async: true, }); -// CoW-specific shepherd-module world (extends headless-module) +// CoW-specific shepherd world (extends event-module) wasmtime::component::bindgen!({ path: "wit/shepherd-cow", - world: "shepherd-module", + world: "shepherd", async: true, }); ``` @@ -307,18 +359,18 @@ trait Identity { } ``` -### Consensus depends on Identity +### Chain depends on Identity -The `csn` host implementation depends on `Identity` internally. When a module calls a signing RPC method through `csn::request` (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host intercepts the call and delegates to the identity backend instead of forwarding to the RPC provider: +The `chain` host implementation depends on `Identity` internally. When a module calls a signing RPC method through `chain::request` (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host intercepts the call and delegates to the identity backend instead of forwarding to the RPC provider: ```rust -impl web3::runtime::csn::Host for NexumHostState { +impl nexum::host::chain::Host for NexumHostState { async fn request( &mut self, chain_id: u64, method: String, params: String, - ) -> Result> { + ) -> Result> { // Signing methods are intercepted and delegated to identity. match method.as_str() { "eth_accounts" => { @@ -345,7 +397,9 @@ impl web3::runtime::csn::Host for NexumHostState { } if !self.is_method_allowed(&method) { - return Ok(Err(JsonRpcError { + return Ok(Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::Denied, code: -32601, message: format!("method not allowed: {method}"), data: None, @@ -359,7 +413,7 @@ impl web3::runtime::csn::Host for NexumHostState { // stack (timeout, retry, rate-limit, fallback) applies transparently. match provider.raw_request_dyn(method.into(), &raw_params).await { Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), + Err(e) => Ok(Err(HostError::from_transport("chain", e))), } } } @@ -367,16 +421,19 @@ impl web3::runtime::csn::Host for NexumHostState { ### Identity Host Implementation -The `identity::Host` implementation delegates to the platform-specific `Identity` trait: +The `identity::Host` implementation delegates to the platform-specific `Identity` trait. Errors map to the unified `HostError`: ```rust -impl web3::runtime::identity::Host for NexumHostState { - async fn accounts(&mut self) -> Result>, IdentityError>> { +impl nexum::host::identity::Host for NexumHostState { + async fn accounts(&mut self) -> Result>, HostError>> { match self.identity.accounts() { Ok(addrs) => Ok(Ok(addrs.into_iter().map(|a| a.to_vec()).collect())), - Err(e) => Ok(Err(IdentityError { + Err(e) => Ok(Err(HostError { + domain: "identity".into(), + kind: HostErrorKind::Internal, code: 1, message: e.to_string(), + data: None, })), } } @@ -385,39 +442,36 @@ impl web3::runtime::identity::Host for NexumHostState { &mut self, account: Vec, data: Vec, - ) -> Result, IdentityError>> { + ) -> Result, HostError>> { let address = Address::from_slice(&account); match self.identity.sign(address, &data) { Ok(sig) => Ok(Ok(sig.to_vec())), - Err(e) => Ok(Err(IdentityError { + Err(IdentityBackendError::UserRejected) => Ok(Err(HostError { + domain: "identity".into(), + kind: HostErrorKind::Denied, code: 2, - message: e.to_string(), + message: "user rejected".into(), + data: None, })), - } - } - - async fn sign_typed_data( - &mut self, - account: Vec, - typed_data: String, - ) -> Result, IdentityError>> { - let address = Address::from_slice(&account); - match self.identity.sign_typed_data(address, &typed_data) { - Ok(sig) => Ok(Ok(sig.to_vec())), - Err(e) => Ok(Err(IdentityError { + Err(e) => Ok(Err(HostError { + domain: "identity".into(), + kind: HostErrorKind::Internal, code: 3, message: e.to_string(), + data: None, })), } } + + // sign_typed_data follows the same pattern. } ``` ### Local Store Host Implementation ```rust -impl web3::runtime::local_store::Host for NexumHostState { - async fn get(&mut self, key: String) -> Result>, String>> { +impl nexum::host::local_store::Host for NexumHostState { + async fn get(&mut self, key: String) -> Result>, HostError>> { // Read from the in-flight WriteTransaction (not a new ReadTransaction) // so the module sees its own uncommitted writes within a single on_event. let table = self.write_txn.open_table(self.local_store_table())?; @@ -426,19 +480,19 @@ impl web3::runtime::local_store::Host for NexumHostState { // ... } -impl shepherd::cow::cow::Host for NexumHostState { +impl shepherd::cow::cow_api::Host for NexumHostState { // CoW-specific host implementation // ... } ``` -See doc 07 for the full `csn` and `cow` host implementations, method allowlisting, and the `HostTransport` that bridges this to alloy's `Provider` API on the guest side. +See doc 07 for the full `chain` and `cow-api` host implementations, method allowlisting, and the `HostTransport` that bridges this to alloy's `Provider` API on the guest side. ## Guest-Side (Module Author) Experience ### Universal modules (`nexum-sdk`) -Module authors targeting the universal `headless-module` world add the `nexum-sdk` crate and use the `#[nexum::module]` proc macro. Modules can access identity for signing operations — either indirectly through `csn` (signing RPC methods are handled transparently) or directly via the `identity` interface for raw signing: +Module authors targeting the universal `event-module` world add the `nexum-sdk` crate and use the `#[nexum::module]` proc macro. Modules can access identity for signing operations — either indirectly through `chain` (signing RPC methods are handled transparently) or directly via the `identity` interface for raw signing: ```rust use nexum_sdk::prelude::*; @@ -452,7 +506,7 @@ impl BlockLogger { Ok(()) } - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { let block_num = provider.get_block_number().await?; info!("New block: {block_num}"); @@ -464,7 +518,7 @@ impl BlockLogger { ### CoW Protocol modules (`shepherd-sdk`) -Module authors targeting the CoW-specific `shepherd-module` world add the `shepherd-sdk` crate and use the `#[shepherd::module]` proc macro. The macro provides **named event handlers** (`on_block`, `on_logs`, `on_timer`) — it generates the `on_event` match dispatch, WIT export wrapper, and optional provider injection. Handlers can be `async fn` for natural `.await`: +Module authors targeting the CoW-specific `shepherd` world add the `shepherd-sdk` crate and use the `#[shepherd::module]` proc macro. The macro provides **named event handlers** (`on_block`, `on_logs`, `on_tick`, `on_message`) — it generates the `on_event` match dispatch, WIT export wrapper, and optional provider injection. Handlers can be `async fn` for natural `.await`: ```rust use shepherd_sdk::prelude::*; @@ -487,7 +541,7 @@ impl TwapMonitor { // Named handler — macro generates on_event match dispatch. // provider is injected from block.chain_id. // async fn — macro wraps in block_on (single-poll, zero overhead). - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { // Full alloy Provider API — natural .await let block_num = provider.get_block_number().await?; let balance = provider.get_balance(owner).latest().await?; @@ -502,7 +556,7 @@ impl TwapMonitor { let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?; // CoW API via typed client - let cow = CowClient::new(block.chain_id); + let cow = Cow::new(block.chain_id); cow.submit_order(&order)?; // State persistence @@ -511,7 +565,7 @@ impl TwapMonitor { } // Only define handlers for events you subscribe to. - // No on_logs or on_timer → those events are silently ignored. + // No on_logs, on_tick, or on_message → those events are silently ignored. } ``` @@ -530,7 +584,7 @@ See doc 05 for the full macro design (named handlers, provider injection, escape | **Python** | componentize-py (CPython) | Maturing | | **C#** | `wit-bindgen-csharp` | Emerging | -All produce valid components against the same WIT worlds (`web3:runtime/headless-module` for universal, `shepherd:cow/shepherd-module` for CoW). +All produce valid components against the same WIT worlds (`nexum:host/event-module` for universal, `shepherd:cow/shepherd` for CoW). ## Execution Metering @@ -569,29 +623,20 @@ All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges **Note:** We use wasmtime's basic async support (stable), *not* the Component Model native async (`stream`, `future`) which is still evolving. -## WASI: Intentionally Excluded (for now) +## WASI: Intentionally Excluded - WASI 0.2.1 is stable in wasmtime. WASI 0.3 (native async) is in preview. -- The `headless-module` world imports **zero WASI interfaces**. -- This is a security feature: components structurally cannot access FS/network/clocks. -- If a future use case needs selective WASI (e.g. `wasi:clocks` for timing), we can define an extended world: - -```wit -world headless-module-extended { - include headless-module; - import wasi:clocks/monotonic-clock@0.2.0; -} -``` - -The host only adds WASI to the linker for modules that request it — capability-based. +- The `event-module` world imports **zero WASI interfaces**. +- This is a security feature: components structurally cannot access FS/network/clocks via WASI. +- The 0.2 additive capabilities (`clock`, `random`, `http`) cover the common needs that would otherwise drive a WASI import, but as first-class Nexum interfaces — capability-negotiated via the manifest, allowlisted (in the HTTP case), and consistent with the rest of the host surface (`host-error` returns, no panics on capability absence). ## Summary: Nexum <-> wasmtime Mapping | Nexum Concept | wasmtime Primitive | |------------------|--------------------| | Runtime process | `Engine` (one, shared) | -| Universal API contract | WIT world (`web3:runtime/headless-module`) | -| CoW API contract | WIT world (`shepherd:cow/shepherd-module`) | +| Universal API contract | WIT world (`nexum:host/event-module`) | +| CoW API contract | WIT world (`shepherd:cow/shepherd`) | | Compiled module | `Component` (cached, thread-safe) | | Pre-validated module | `InstancePre` (linker + component) | | Running instance | `Store` + `Instance` | diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index 5de2901..bebdb96 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -11,12 +11,12 @@ Every module ships with a manifest: ```toml [module] name = "twap-monitor" -version = "0.2.0" +version = "0.3.0" description = "Monitors and posts TWAP order parts" authors = ["mfw78.eth"] # Content hash of the compiled .wasm component -wasm = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" +component = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" [module.resources] max_memory_bytes = 10_485_760 # 10 MB @@ -31,34 +31,48 @@ max_consecutive_failures = 10 # Dead after this many consecutive failures required = [42161] # Arbitrum (must have) optional = [1, 100] # Mainnet, Gnosis (used if available) +# Capability negotiation (new in 0.2) — which host primitives the module needs. +# Optional imports trap with host-error { kind: unsupported } on call rather +# than failing instantiation. Omitting this section falls back to +# "all imports required" with a deprecation warning. +[capabilities] +required = ["chain", "local-store", "logging"] +optional = ["messaging", "remote-store"] +denied = [] + +[capabilities.http] +allow = ["api.cow.fi"] # outbound HTTP domain allowlist + # Event subscriptions — declares what the runtime should feed this module -[[subscribe]] -type = "block" +[[subscription]] +kind = "block" chain_id = 42161 -[[subscribe]] -type = "log" +[[subscription]] +kind = "log" chain_id = 42161 address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" topics = ["0x…"] # ComposableCoW ConditionalOrderCreated -[[subscribe]] -type = "cron" +[[subscription]] +kind = "cron" schedule = "*/5 * * * *" # every 5 minutes -# Arbitrary key-value config passed to the module at init +# Typed config — TOML values preserve their type at the guest (0.2) [config] cow_api_url = "https://api.cow.fi/arbitrum" -min_twap_interval_secs = 120 +min_twap_interval_secs = 120 # integer stays integer +enable_alerts = true # boolean stays boolean ``` Key design points: -- **`wasm` is a content hash**, not a filename. The runtime resolves it via the content store (see below). -- **`subscribe` blocks are declarative.** The module doesn't set up its own subscriptions imperatively — the runtime reads the manifest and wires up event sources before calling `init`. +- **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). (Was `wasm = ...` in 0.1 — see the migration guide.) +- **`[[subscription]]` blocks are declarative.** The module doesn't set up its own subscriptions imperatively — the runtime reads the manifest and wires up event sources before calling `init`. The 0.1 spelling was `[[subscribe]]` with `type = ...`; 0.2 uses `[[subscription]]` with `kind = ...` because `type` is a reserved word in several binding languages. +- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. See the migration guide for the full schema (including `[capabilities.http]` allowlists and `[capabilities.identity].methods` subsets). - **`resources` are caps**, not requests. The runtime enforces them via wasmtime's `ResourceLimiter` and fuel system. - **`chains.required`** — if the runtime doesn't have an RPC endpoint for a required chain, the module fails to load (fast, clear error). -- **`config`** is opaque to the runtime. All values are **stringified** before being passed to the module's `init` export as `list>` (e.g. TOML integer `120` becomes the string `"120"`). Modules are responsible for parsing values from strings. +- **`config`** is opaque to the runtime. 0.2 keeps 0.1's stringly-typed shape (`list>`); the host flattens TOML scalars (numbers, booleans) to their string form on the way through. A typed `config-value` variant is on the 0.3 roadmap, bundled with the manifest-parser work. ### Bundle Format @@ -67,10 +81,10 @@ A bundle is a **directory** with a fixed layout: ``` twap-monitor/ ├── nexum.toml # manifest -└── module.wasm # compiled component (matches wasm hash) +└── module.wasm # compiled component (matches component hash) ``` -The runtime validates that `sha256(module.wasm)` matches the hash in the manifest's `wasm` field (after stripping the `sha256:` scheme prefix). This integrity check applies regardless of transport. +The runtime validates that `sha256(module.wasm)` matches the hash in the manifest's `component` field (after stripping the `sha256:` scheme prefix). This integrity check applies regardless of transport. How the directory is represented depends on the content backend: @@ -97,7 +111,7 @@ Distribution is **agnostic** — the runtime resolves content by hash through pl | `sha256` | `sha256:9f86d08…` | Local content store lookup | | `bzz` | `bzz:22cbb9cedc…` | Ethereum Swarm (64-char hex, 256-bit) | | `ipfs` | `ipfs:QmYwAPJz…` | IPFS CID | -| `oci` | `oci:ghcr.io/org/twap:0.2.0` | OCI registry (CNCF WASM artifact format) | +| `oci` | `oci:ghcr.io/org/twap:0.3.0` | OCI registry (CNCF WASM artifact format) | | `https` | `https://example.com/twap.wasm` | Direct HTTP fetch (hash-verified after download) | ### Runtime Content Store @@ -106,7 +120,7 @@ The runtime maintains a local content-addressed store (a directory of blobs keye ```mermaid flowchart TD - A[Read manifest] --> B[Extract wasm content reference] + A[Read manifest] --> B[Extract component content reference] B --> C{Hash in local store?} C -->|Hit| F[Return path to verified .wasm] C -->|Miss| D[Resolve via configured backend] @@ -162,10 +176,10 @@ stateDiagram-v2 | State | Description | |-------|-------------| -| **Resolve** | Content store resolves `wasm` hash to local path. Fail -> `Dead`. | -| **Load** | `Component::from_file`, create `InstancePre`. Validates that the component satisfies the target WIT world (`web3:runtime/headless-module` or `shepherd:cow/shepherd-module`). Fail -> `Dead`. | +| **Resolve** | Content store resolves `component` hash to local path. Fail -> `Dead`. | +| **Load** | `Component::from_file`, create `InstancePre`. Validates that the component satisfies the target WIT world (`nexum:host/event-module` or `shepherd:cow/shepherd`). Installs trap stubs for capabilities the manifest declares `optional` but the host does not provide. Fail -> `Dead`. | | **Init** | Create `Store`, instantiate, call `init(config)` inside an implicit write transaction (same semantics as `on_event` — commit on success, rollback on failure). Module sets up internal state. Fail -> `Restart` (might be transient). | -| **Run** | Runtime dispatches events to `on_event`. Each call gets a fuel budget. Module processes events and may call host imports (csn, local-store, identity, cow, order). | +| **Run** | Runtime dispatches events to `on_event`. Each call gets a fuel budget. Module processes events and may call host imports (chain, local-store, identity, cow-api, etc.). | | **Restart** | After a trap or error. Backoff: 1s -> 2s -> 4s -> ... -> 5min cap. A fresh `Store` is created (clean memory), but **local-store data persists** (it's in redb, external to the WASM instance). | | **Dead** | After N consecutive failures (poison pill detection) or explicit operator shutdown. No further event dispatch. Requires manual intervention. | @@ -243,7 +257,7 @@ When an event fires: - **Sequential within a module.** Events for the same module are dispatched in order. A module sees block N before block N+1. This is enforced by a per-module dispatch queue (Tokio `mpsc` channel). - **Best-effort delivery.** If a module is in Restart state when an event arrives, the event is queued (bounded buffer). If the buffer fills, oldest events are dropped and a warning is logged. - **No acknowledgement.** A successful return from `on_event` is not an ack. The module is responsible for using the local-store to track its own progress (e.g. "last processed block"). -- **Catch-up after gaps.** Events can be dropped during restart (bounded buffer overflow). Modules should query for missed data on startup — e.g. in `init`, read `last_block` from local-store, use the alloy `Provider` (backed by `csn::request`) to call `get_block_number()` and `get_logs()` to backfill any gap. This is a best practice, not enforced by the runtime. +- **Catch-up after gaps.** Events can be dropped during restart (bounded buffer overflow). Modules should query for missed data on startup — e.g. in `init`, read `last_block` from local-store, use the alloy `Provider` (backed by `chain::request`) to call `get_block_number()` and `get_logs()` to backfill any gap. This is a best practice, not enforced by the runtime. ### Event Type Encoding @@ -251,79 +265,111 @@ Events cross the WASM boundary as the `event` variant defined in the WIT: ```wit variant event { - block(block-data), - logs(list), - timer(u64), + block(block), + logs(list), + tick(tick), + message(message), } -record block-data { +record block { chain-id: u64, number: u64, hash: list, - timestamp: u64, + timestamp: u64, // milliseconds since Unix epoch, UTC +} + +record tick { + fired-at: u64, // milliseconds since Unix epoch, UTC } ``` -The runtime serialises event data via the canonical ABI (handled automatically by `bindgen!`). +The runtime serialises event data via the canonical ABI (handled automatically by `bindgen!`). Note the 0.2 semantic change: all `u64` timestamps in 0.2 are **milliseconds since Unix epoch, UTC**. The 0.1 WIT did not specify a unit and several sources used seconds — audit any timestamp arithmetic. The `tick` variant (formerly `timer(u64)`) is now a record so bindings read `event.tick.firedAt` instead of comparing a bare integer. ## Updated WIT Worlds -The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The architecture uses two packages: `web3:runtime` for universal interfaces and `shepherd:cow` for CoW Protocol extensions. +The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The architecture uses two packages: `nexum:host` for universal interfaces and `shepherd:cow` for CoW Protocol extensions. -### Universal Package: `web3:runtime@0.1.0` +### Universal Package: `nexum:host@0.2.0` ```wit -package web3:runtime@0.1.0; +package nexum:host@0.2.0; interface types { type chain-id = u64; - record block-data { + record block { chain-id: chain-id, number: u64, hash: list, - timestamp: u64, + timestamp: u64, // ms since Unix epoch, UTC } - record log-entry { + record log { chain-id: chain-id, address: list, topics: list>, data: list, block-number: u64, - tx-hash: list, + transaction-hash: list, log-index: u32, } + record tick { + fired-at: u64, // ms since Unix epoch, UTC + } + + record message { + content-topic: string, + payload: list, + timestamp: u64, // ms since Unix epoch, UTC + sender: option>, + } + variant event { - block(block-data), - logs(list), - timer(u64), + block(block), + logs(list), + tick(tick), + message(message), } - /// Opaque config map from nexum.toml [config] section. + /// Opaque config from nexum.toml [config] section. TOML scalars are + /// flattened to strings by the host. A typed config-value variant is + /// on the 0.3 roadmap, bundled with the manifest-parser work. type config = list>; -} -interface csn { - use types.{chain-id}; - - record json-rpc-error { - code: s64, + /// Unified host error (replaces the five per-protocol errors from 0.1). + record host-error { + domain: string, + kind: host-error-kind, + code: s32, message: string, data: option, } + variant host-error-kind { + unsupported, unavailable, denied, rate-limited, + timeout, invalid-input, internal, + } +} + +interface chain { + use types.{chain-id, host-error}; + /// Generic JSON-RPC passthrough. See doc 07 for full design rationale. request: func(chain-id: chain-id, method: string, params: string) - -> result; + -> result; + + /// Additive 0.2: batched JSON-RPC. + request-batch: func(chain-id: chain-id, calls: list>) + -> result>, host-error>; } interface local-store { - get: func(key: string) -> result>, string>; - set: func(key: string, value: list) -> result<_, string>; - delete: func(key: string) -> result<_, string>; - list-keys: func(prefix: string) -> result, string>; + use types.{host-error}; + get: func(key: string) -> result>, host-error>; + set: func(key: string, value: list) -> result<_, host-error>; + delete: func(key: string) -> result<_, host-error>; + list-keys: func(prefix: string) -> result, host-error>; } interface logging { @@ -332,40 +378,38 @@ interface logging { } interface identity { - record identity-error { code: u16, message: string } - accounts: func() -> result>, identity-error>; - sign: func(account: list, data: list) -> result, identity-error>; - sign-typed-data: func(account: list, typed-data: string) -> result, identity-error>; + use types.{host-error}; + accounts: func() -> result>, host-error>; + sign: func(account: list, data: list) -> result, host-error>; + sign-typed-data: func(account: list, typed-data: string) -> result, host-error>; } -/// Universal headless module world — platform-agnostic. -world headless-module { - import csn; +/// Universal event-driven module world — platform-agnostic. Imports the six +/// primitives in 0.2 (identity was missing from the 0.1 WIT despite being +/// part of the primitive taxonomy). +world event-module { + import chain; + import identity; import local-store; + import remote-store; + import messaging; import logging; - import identity; - /// Called once on load. Receives config from nexum.toml. - export init: func(config: types.config) -> result<_, string>; + /// Called once on load. Receives typed config from nexum.toml. + export init: func(config: types.config) -> result<_, host-error>; /// Called for each subscribed event. - export on-event: func(event: types.event) -> result<_, string>; + export on-event: func(event: types.event) -> result<_, host-error>; } ``` -### CoW-Specific Package: `shepherd:cow@0.1.0` +### CoW-Specific Package: `shepherd:cow@0.2.0` ```wit -package shepherd:cow@0.1.0; +package shepherd:cow@0.2.0; -interface cow { - use web3:runtime/types.{chain-id}; - - record api-error { - status: u16, - message: string, - body: option, - } +interface cow-api { + use nexum:host/types.{chain-id, host-error}; /// HTTP-style request to the CoW Protocol API. request: func( @@ -373,23 +417,18 @@ interface cow { method: string, path: string, body: option, - ) -> result; -} - -interface order { - use web3:runtime/types.{chain-id}; + ) -> result; - submit: func(chain-id: chain-id, order-data: list) - -> result; + /// Submit a serialised order. (Merged in from the 0.1 `order` interface.) + submit-order: func(chain-id: chain-id, order-data: list) + -> result; } -/// CoW Protocol module world — extends headless-module with -/// CoW-specific imports (cow API, order submission). -world shepherd-module { - include web3:runtime/headless-module; +/// CoW Protocol module world — extends event-module with cow-api. +world shepherd { + include nexum:host/event-module; - import cow; - import order; + import cow-api; } ``` @@ -404,19 +443,20 @@ Operator deploys a module: manifest = "/var/nexum/twap-monitor/nexum.toml" 2. Runtime reads manifest: - - Resolves wasm content hash → fetches from Swarm/local/OCI + - Resolves component content hash → fetches from Swarm/local/OCI - Verifies integrity (sha256 match) 3. Runtime compiles Component, creates InstancePre: - Validates component satisfies target world - (web3:runtime/headless-module or shepherd:cow/shepherd-module) + (nexum:host/event-module or shepherd:cow/shepherd) + - Installs trap stubs for any [capabilities].optional imports the host doesn't provide - Enforces resource limits from manifest 4. Runtime calls init(config): - - Module receives [config] section as key-value pairs + - Module receives [config] section as typed key-value pairs - Module sets up internal state, logs readiness -5. Runtime wires event sources from [[subscribe]] blocks: +5. Runtime wires event sources from [[subscription]] blocks: - Creates/reuses block subscriber for chain 42161 - Creates log watcher with address + topic filter - Registers cron schedule @@ -425,7 +465,7 @@ Operator deploys a module: Block 19_000_001 on Arbitrum → Router → twap-monitor's dispatch queue → Tokio task calls on_event(Event::Block(…)) - → Module calls csn::request (via alloy Provider), local_store_get, order_submit + → Module calls chain::request (via alloy Provider), local-store get, cow-api submit-order → Returns Ok(()) — runtime logs success 7. On crash: diff --git a/docs/04-state-store.md b/docs/04-state-store.md index 0f2f8a4..1eff78c 100755 --- a/docs/04-state-store.md +++ b/docs/04-state-store.md @@ -48,20 +48,27 @@ This per-file design ensures concurrent modules never contend on write locks (se ```wit interface local-store { + use nexum:host/types.{host-error}; + /// Get a value by key. Returns none if key doesn't exist. - get: func(key: string) -> result>, string>; + get: func(key: string) -> result>, host-error>; /// Set a key-value pair. Overwrites existing value. - set: func(key: string, value: list) -> result<_, string>; + /// Returns host-error { domain: "store", kind: invalid-input | internal | ... } on failure. + /// Quota exhaustion surfaces as host-error { domain: "store", kind: invalid-input } + /// (or a future dedicated `quota-exceeded` kind) — see the migration guide. + set: func(key: string, value: list) -> result<_, host-error>; /// Delete a key. No-op if key doesn't exist. - delete: func(key: string) -> result<_, string>; + delete: func(key: string) -> result<_, host-error>; /// List keys matching a prefix. Returns keys only (not values). - list-keys: func(prefix: string) -> result, string>; + list-keys: func(prefix: string) -> result, host-error>; } ``` +In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the unified `host-error` type (see [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) so modules can match on `host-error-kind` rather than parsing error strings. + Keys are UTF-8 strings. Values are opaque bytes — the SDK provides typed wrappers (see doc 05). `list-keys` enables prefix-based namespacing within a module's state: @@ -138,13 +145,19 @@ The manifest declares `max_state_bytes`. The runtime tracks total bytes stored p ```rust // Host-side enforcement (simplified) impl local_store::Host for NexumHostState { - async fn set(&mut self, key: String, value: Vec) -> Result> { + async fn set(&mut self, key: String, value: Vec) -> Result> { let new_size = self.state_bytes_used - self.current_value_size(&key) + key.len() + value.len(); if new_size > self.module_config.max_state_bytes { - return Ok(Err("state quota exceeded".into())); + return Ok(Err(HostError { + domain: "store".into(), + kind: HostErrorKind::InvalidInput, + code: 1, + message: "state quota exceeded".into(), + data: None, + })); } self.write_txn.insert(&*key, value.as_slice())?; @@ -163,7 +176,7 @@ The tracking is approximate (doesn't account for B-tree overhead) but sufficient On first load, the module's table is empty. The module's `init` function should handle this: ```rust -fn init(config: Vec<(String, String)>) -> Result<(), String> { +fn init(config: Config) -> Result<(), HostError> { if local_store::get("initialized")?.is_none() { // First run — set up initial state local_store::set("initialized", &[1])?; @@ -180,7 +193,7 @@ On restart, the module gets a fresh WASM instance but the **same state table**. The module should read its checkpoint from state in `init` and resume: ```rust -fn init(_config: Vec<(String, String)>) -> Result<(), String> { +fn init(_config: Config) -> Result<(), HostError> { let last_block = local_store::get("last_block")? .map(|b| u64::from_le_bytes(b.try_into().unwrap())) .unwrap_or(0); @@ -194,7 +207,7 @@ fn init(_config: Vec<(String, String)>) -> Result<(), String> { When a module is updated (new WASM binary, same `name` in manifest), the new version inherits the existing state table. The new version's `init` is responsible for any migration: ```rust -fn init(config: Vec<(String, String)>) -> Result<(), String> { +fn init(config: Config) -> Result<(), HostError> { let version = local_store::get("schema_version")? .map(|b| u64::from_le_bytes(b.try_into().unwrap())) .unwrap_or(0); diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 84c8f19..758a5d2 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -4,20 +4,21 @@ The SDK is split into two layers: -1. **`nexum-sdk`** -- the universal SDK for any `web3:runtime/headless-module`. It provides: +1. **`nexum-sdk`** -- the universal SDK for any `nexum:host/event-module`. It provides: - WIT bindings (re-exported, version-pinned) - A proc macro (`#[nexum::module]`) that eliminates boilerplate (supports `async fn` for natural `.await`) - A full alloy `Provider` backed by the host's RPC stack (`HostTransport`) - Typed local-store helpers (serde over raw bytes) - - Typed identity helpers (`IdentityClient` for key management and signing) + - A typed `Signer` for key management and signing - Ethereum ABI helpers (alloy-sol-types integration) - A test harness with a mock host (`MockHost`) - A logging convenience layer + - The unified `HostError` / `HostErrorKind` error model 2. **`shepherd-sdk`** -- the CoW Protocol extension. It re-exports everything from `nexum-sdk` and adds: - CoW-specific WIT bindings (`shepherd:cow`) - - A typed CoW Protocol API client (`CowClient`) - - A proc macro (`#[shepherd::module]`) that targets the `shepherd:cow/shepherd-module` world + - A typed CoW Protocol API client (`Cow`) + - A proc macro (`#[shepherd::module]`) that targets the `shepherd:cow/shepherd` world - CoW-specific mock testing utilities Module authors should never interact with `wit-bindgen` or the canonical ABI directly. @@ -28,14 +29,14 @@ Module authors should never interact with `wit-bindgen` or the canonical ABI dir nexum-sdk/ ├── Cargo.toml ├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor, block_on() +│ ├── lib.rs # re-exports, prelude, provider() constructor (block_on is internal) │ ├── bindings.rs # generated by wit-bindgen (checked in or build.rs) -│ ├── transport.rs # HostTransport -- alloy Transport impl over csn::request +│ ├── transport.rs # HostTransport -- alloy Transport impl over chain::request / chain::request-batch │ ├── local_store.rs # typed local-store helpers -│ ├── identity.rs # IdentityClient -- typed identity helpers (accounts, signing) +│ ├── signer.rs # Signer -- typed identity helpers (accounts, signing) │ ├── abi.rs # Ethereum ABI encoding/decoding │ ├── log.rs # logging convenience -│ ├── error.rs # error types +│ ├── error.rs # HostError / HostErrorKind │ └── testing.rs # mock host, test harness └── macros/ └── src/ @@ -46,19 +47,19 @@ shepherd-sdk/ ├── src/ │ ├── lib.rs # re-exports nexum-sdk, adds CoW-specific API │ ├── bindings.rs # generated CoW WIT bindings (shepherd:cow) -│ ├── cow.rs # CowClient -- typed CoW Protocol API wrapper +│ ├── cow.rs # Cow -- typed CoW Protocol API wrapper │ └── testing.rs # CoW-specific mock utilities └── macros/ └── src/ └── lib.rs # #[shepherd::module] proc macro (CoW variant) ``` -The workspace root `wit/web3-runtime/` is the **universal WIT definition**. The `wit/shepherd-cow/` directory extends it with CoW Protocol interfaces. The SDKs reference these via path (not a copy) to prevent drift: +The workspace root `wit/nexum-host/` is the **universal WIT definition**. The `wit/shepherd-cow/` directory extends it with CoW Protocol interfaces. The SDKs reference these via path (not a copy) to prevent drift: ```toml # nexum-sdk/Cargo.toml [package.metadata.component.target] -path = "../wit/web3-runtime" +path = "../wit/nexum-host" ``` ```toml @@ -73,20 +74,21 @@ Both SDKs pin a specific `wit-bindgen` version so module authors are insulated f ### Universal: `#[nexum::module]` -Without the macro, a module author writes: +Without the macro, a module author writes (against the typed 0.2 config): ```rust -wit_bindgen::generate!({ world: "headless-module", path: "..." }); +wit_bindgen::generate!({ world: "event-module", path: "..." }); struct MyModule; impl Guest for MyModule { - fn init(config: Vec<(String, String)>) -> Result<(), String> { ... } - fn on_event(event: Event) -> Result<(), String> { + fn init(config: Config) -> Result<(), HostError> { ... } + fn on_event(event: Event) -> Result<(), HostError> { match event { Event::Block(block) => { ... } Event::Logs(logs) => { ... } - Event::Timer(ts) => { ... } + Event::Tick(tick) => { ... } + Event::Message(msg) => { ... } } } } @@ -107,28 +109,28 @@ impl TwapMonitor { Ok(()) } - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { let num = provider.get_block_number().await?; // ... Ok(()) } - async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { + async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { for log in &logs { // ... } Ok(()) } - // on_timer not defined -> timer events are silently ignored + // on_tick / on_message not defined -> those events are silently ignored } ``` -The `#[nexum::module]` macro generates code against the `web3:runtime/headless-module` world. +The `#[nexum::module]` macro generates code against the `nexum:host/event-module` world. ### CoW Protocol: `#[shepherd::module]` -For CoW Protocol modules, the `#[shepherd::module]` macro targets the `shepherd:cow/shepherd-module` world, which extends `headless-module` with CoW-specific imports: +For CoW Protocol modules, the `#[shepherd::module]` macro targets the `shepherd:cow/shepherd` world, which extends `event-module` with the merged `cow-api` import: ```rust use shepherd_sdk::prelude::*; @@ -141,8 +143,8 @@ impl CowTwapMonitor { Ok(()) } - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { - let cow = CowClient::new(block.chain_id); + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { + let cow = Cow::new(block.chain_id); let quote = cow.get_quote(&OrderQuoteRequest { /* ... */ })?; // ... Ok(()) @@ -155,14 +157,14 @@ impl CowTwapMonitor { For the universal `#[nexum::module]`: ```rust -wit_bindgen::generate!({ world: "headless-module", path: "..." }); +wit_bindgen::generate!({ world: "event-module", path: "..." }); impl Guest for TwapMonitor { - fn init(config: Vec<(String, String)>) -> Result<(), String> { - TwapMonitor::init(config.into()).map_err(|e| e.to_string()) + fn init(config: Config) -> Result<(), HostError> { + TwapMonitor::init(config.into()).map_err(HostError::from) } - fn on_event(event: types::Event) -> Result<(), String> { + fn on_event(event: types::Event) -> Result<(), HostError> { nexum_sdk::block_on(async { match event { Event::Block(block) => { @@ -173,24 +175,26 @@ impl Guest for TwapMonitor { let provider = nexum_sdk::provider(logs[0].chain_id); TwapMonitor::on_logs(logs, &provider).await } - Event::Timer(_) => Ok(()), // no handler defined + Event::Tick(_) => Ok(()), // no handler defined + Event::Message(_) => Ok(()), // no handler defined } - }).map_err(|e| e.to_string()) + }).map_err(HostError::from) } } export!(TwapMonitor); ``` -For the CoW `#[shepherd::module]`, the generated code additionally imports `shepherd:cow` interfaces (cow, order) alongside the `web3:runtime` base. +For the CoW `#[shepherd::module]`, the generated code additionally imports `shepherd:cow/cow-api` alongside the `nexum:host` base. ### Named event handlers | Handler | Payload | Optional injectable context | |---|---|---| -| `on_block(block)` | `BlockData` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_logs(logs)` | `Vec` | `provider: &RootProvider` (from `logs[0].chain_id`) | -| `on_timer(timestamp)` | `u64` | None (no chain context) | +| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | +| `on_logs(logs)` | `Vec` | `provider: &RootProvider` (from `logs[0].chain_id`) | +| `on_tick(tick)` | `Tick` (`tick.fired_at`) | None (no chain context) | +| `on_message(message)` | `Message` | None | The macro inspects each handler's signature: @@ -224,7 +228,7 @@ impl CustomModule { Resolution order: 1. `on_event` defined -> use it directly (wrap in `block_on` if async) -2. Any of `on_block` / `on_logs` / `on_timer` defined -> generate the match dispatch +2. Any of `on_block` / `on_logs` / `on_tick` / `on_message` defined -> generate the match dispatch 3. Neither -> compile error > Full async design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md#eliminating-block_on-async-module-functions) @@ -235,19 +239,19 @@ Resolution order: ```rust // nexum_sdk::prelude -pub use crate::bindings::web3::runtime::types::*; -pub use crate::bindings::web3::runtime::csn; -pub use crate::bindings::web3::runtime::identity; -pub use crate::bindings::web3::runtime::local_store; -pub use crate::bindings::web3::runtime::remote_store; -pub use crate::bindings::web3::runtime::msg; -pub use crate::bindings::web3::runtime::logging; +pub use crate::bindings::nexum::host::types::*; +pub use crate::bindings::nexum::host::chain; +pub use crate::bindings::nexum::host::identity; +pub use crate::bindings::nexum::host::local_store; +pub use crate::bindings::nexum::host::remote_store; +pub use crate::bindings::nexum::host::messaging; +pub use crate::bindings::nexum::host::logging; pub use crate::log::{trace, debug, info, warn, error}; pub use crate::local_store::TypedState; -pub use crate::identity::IdentityClient; +pub use crate::signer::Signer; pub use crate::transport::HostTransport; -pub use crate::{provider, block_on}; -pub use crate::error::{Result, Error}; +pub use crate::provider; +pub use crate::error::{Result, HostError, HostErrorKind}; // Re-export alloy essentials so modules don't need direct alloy dependencies pub use alloy_primitives::{Address, B256, U256, Bytes}; @@ -256,19 +260,20 @@ pub use alloy_rpc_types::*; pub use alloy_provider::Provider; ``` -One `use nexum_sdk::prelude::*;` gives module authors everything they need -- including the alloy `Provider` trait, primitive types, `sol!` macro, and `IdentityClient` for signing. +One `use nexum_sdk::prelude::*;` gives module authors everything they need -- including the alloy `Provider` trait, primitive types, `sol!` macro, and `Signer` for signing. + +`block_on` is no longer a public re-export in 0.2 -- it's hidden behind the `#[nexum::module]` macro. See the [migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) for the full SDK rename table. ### CoW Protocol: `shepherd_sdk::prelude` ```rust // shepherd_sdk::prelude pub use nexum_sdk::prelude::*; // re-export universal prelude -pub use crate::bindings::shepherd::cow::cow; -pub use crate::bindings::shepherd::cow::order; -pub use crate::cow::CowClient; +pub use crate::bindings::shepherd::cow::cow_api; +pub use crate::cow::Cow; ``` -One `use shepherd_sdk::prelude::*;` gives CoW module authors everything from the universal SDK plus CoW-specific types and the `CowClient`. +One `use shepherd_sdk::prelude::*;` gives CoW module authors everything from the universal SDK plus the merged CoW `cow-api` interface and the typed `Cow` client. ## Typed Local-Store Helpers @@ -332,26 +337,26 @@ impl TypedState { Serialisation uses **postcard** (compact, no-std, deterministic) rather than JSON to minimise local-store storage overhead. -## Identity Client +## Signer -The `identity` WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed `IdentityClient`: +The `identity` WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed `Signer`: ```rust use nexum_sdk::prelude::*; // Get available signing accounts -let accounts = IdentityClient::accounts()?; +let accounts = Signer::accounts()?; for account in &accounts { info!("available signer: 0x{}", hex::encode(account)); } // Sign raw bytes with a specific account -let signature = IdentityClient::sign(&accounts[0], &data_to_sign)?; +let signature = Signer::sign(&accounts[0], &data_to_sign)?; // signature is 65 bytes: r (32) || s (32) || v (1) // Sign EIP-712 typed data let typed_data_json = r#"{"types":...,"primaryType":"Order","domain":...,"message":...}"#; -let signature = IdentityClient::sign_typed_data(&accounts[0], typed_data_json)?; +let signature = Signer::sign_typed_data(&accounts[0], typed_data_json)?; ``` Implementation: @@ -359,17 +364,14 @@ Implementation: ```rust /// Typed client for the identity WIT interface. /// -/// Provides cryptographic signing operations backed by the host runtime's +/// Provides cryptographic signing operations backed by the host engine's /// key management. The host manages private keys -- modules never see them. -pub struct IdentityClient; +pub struct Signer; -impl IdentityClient { +impl Signer { /// Get available signing accounts (20-byte Ethereum addresses). pub fn accounts() -> Result>> { - identity::accounts().map_err(|e| Error::Identity { - code: e.code, - message: e.message, - }) + identity::accounts().map_err(HostError::from) } /// Get available signing accounts as alloy `Address` types. @@ -379,10 +381,8 @@ impl IdentityClient { .into_iter() .map(|a| { Address::try_from(a.as_slice()) - .map_err(|_| Error::Identity { - code: 1, - message: "invalid address length".into(), - }) + .map_err(|_| HostError::module("identity", HostErrorKind::InvalidInput, + "invalid address length")) }) .collect() } @@ -390,29 +390,25 @@ impl IdentityClient { /// Sign raw bytes with the specified account. /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). pub fn sign(account: &[u8], data: &[u8]) -> Result> { - identity::sign(account, data).map_err(|e| Error::Identity { - code: e.code, - message: e.message, - }) + identity::sign(account, data).map_err(HostError::from) } /// Sign EIP-712 typed data with the specified account. /// `typed_data` is a JSON string conforming to the EIP-712 specification. /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). pub fn sign_typed_data(account: &[u8], typed_data: &str) -> Result> { - identity::sign_typed_data(account, typed_data).map_err(|e| Error::Identity { - code: e.code, - message: e.message, - }) + identity::sign_typed_data(account, typed_data).map_err(HostError::from) } } ``` -Note: modules can also use `identity` indirectly through `csn`. When a module calls `csn::request` with a signing method (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host's `csn` implementation delegates to the `identity` backend internally. The `IdentityClient` is for modules that need direct, raw signing operations. +Note: modules can also use `identity` indirectly through `chain`. When a module calls `chain::request` with a signing method (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host's `chain` implementation delegates to the `identity` backend internally. `Signer` is for modules that need direct, raw signing operations -- e.g. EIP-712 over an off-chain order payload. + +Modules can match on `HostErrorKind::Denied` to distinguish "user rejected" from a transport failure -- see the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping table. ## Ethereum ABI Helpers & alloy Provider -Modules frequently need to read chain state and encode/decode Ethereum calldata. The SDK provides a full alloy `Provider` (via `HostTransport` over `csn::request`) and integrates `alloy-sol-types` and `alloy-primitives` (compiled to WASM): +Modules frequently need to read chain state and encode/decode Ethereum calldata. The SDK provides a full alloy `Provider` (via `HostTransport` over `chain::request` / `chain::request-batch`) and integrates `alloy-sol-types` and `alloy-primitives` (compiled to WASM): ```rust use nexum_sdk::prelude::*; @@ -430,7 +426,7 @@ sol! { } // Named handler -- provider is injected by the macro -async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { +async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { // Full alloy Provider API -- natural .await let block_num = provider.get_block_number().await?; let balance = provider.get_balance(owner_addr).latest().await?; @@ -457,25 +453,25 @@ The SDK re-exports: - `alloy_provider::Provider` -- the full alloy Provider trait. - `alloy_rpc_types::*` -- `TransactionRequest`, `Filter`, `Block`, etc. -These are already WASM-compatible (no-std support, no system dependencies). The `HostTransport` routes all RPC calls through the single `csn::request` host function -- see doc 07 for the full design. +These are already WASM-compatible (no-std support, no system dependencies). The `HostTransport` routes all RPC calls through the `chain::request` (and batched `chain::request-batch`) host functions -- see doc 07 for the full design. -## CoW Protocol API: `CowClient` +## CoW Protocol API: `Cow` -The `cow` WIT interface (in `shepherd:cow`) exposes a generic REST passthrough to the CoW Protocol API. The `shepherd-sdk` wraps this with a typed `CowClient`: +The `cow-api` WIT interface (in `shepherd:cow`) exposes a REST passthrough to the CoW Protocol API plus a typed `submit-order` function (the two were separate interfaces, `cow` and `order`, in 0.1). The `shepherd-sdk` wraps this with a typed `Cow` client: ```rust use shepherd_sdk::prelude::*; -let cow = CowClient::new(42161); +let cow = Cow::new(42161); -// Submit an order +// Submit an order via the merged cow-api interface let uid = cow.submit_order(&OrderCreation { sell_token: sell_addr, buy_token: buy_addr, sell_amount: U256::from(1_000_000), buy_amount: U256::from(950_000), kind: OrderKind::Sell, - valid_to: block.timestamp + 300, + valid_to: (block.timestamp / 1000) + 300, // block.timestamp is ms in 0.2 ..Default::default() })?; @@ -489,11 +485,7 @@ let order = cow.get_order(&uid)?; let resp = cow.raw_request("GET", "/api/v1/auction", None)?; ``` -The `CowClient` handles JSON serialisation and routes requests through the host's `cow::request` function, which forwards to the correct CoW API base URL for the given chain. - -### Legacy: `order::submit` - -The WIT `order::submit` interface is retained for backwards compatibility. New modules should prefer `CowClient::submit_order` which provides richer types and access to the full CoW API surface (quotes, auctions, order queries). +The `Cow` client handles JSON serialisation and routes requests through the host's `cow-api::request` (REST passthrough) and `cow-api::submit-order` (order submission) functions. ## Logging Convenience @@ -524,61 +516,59 @@ nexum_sdk::info!("processing block {} on chain {}", block.number, block.chain_id ## Error Handling -The `nexum-sdk` defines a proper error type that converts to the WIT `string` error. The `shepherd-sdk` extends it with CoW-specific variants: - -### Universal (`nexum-sdk`) +In 0.2 every host function returns `result`. The SDK re-exports `HostError` and the `HostErrorKind` discriminant; module errors use the same shape so user code can `?`-propagate across host and module boundaries uniformly. ```rust -#[derive(Debug)] -pub enum Error { - Rpc(alloy_transport::TransportError), - LocalStore(String), - Identity { code: u16, message: String }, - Abi(alloy_sol_types::Error), - Serde(postcard::Error), - Custom(String), +pub struct HostError { + pub domain: String, // "chain" | "store" | "messaging" | "identity" | "cow" | + pub kind: HostErrorKind, // unsupported | unavailable | denied | rate-limited + // | timeout | invalid-input | internal + pub code: i32, // domain-specific + pub message: String, + pub data: Option, // JSON for richer context } -pub type Result = core::result::Result; - -// Auto-converts to the WIT result<_, string> via Display -impl fmt::Display for Error { ... } -``` - -### CoW Protocol (`shepherd-sdk`) +pub type Result = core::result::Result; -```rust -#[derive(Debug)] -pub enum Error { - Nexum(nexum_sdk::Error), - CowApi { status: u16, message: String }, - Order(String), +impl HostError { + /// Helper for module-defined errors. Sets `domain` to the module name, + /// `kind` to the closest match, and `code` to 0. + pub fn module(name: &str, kind: HostErrorKind, message: impl Into) -> Self { ... } } - -pub type Result = core::result::Result; ``` -Module authors use `?` naturally: +Module authors use `?` naturally, and match on `kind` for retry/backoff: ```rust // Universal module -async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // RPC error - let decoded = MyCall::abi_decode_returns(&data)?; // ABI error - TypedState::set("last", &decoded)?; // serde/local-store error - let sig = IdentityClient::sign(&account, &data)?; // identity error +async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { + let num = provider.get_block_number().await?; // chain error -> HostError + let decoded = MyCall::abi_decode_returns(&data) + .map_err(|e| HostError::module("twap", HostErrorKind::InvalidInput, e.to_string()))?; + TypedState::set("last", &decoded)?; // store error + let sig = Signer::sign(&account, &data)?; // identity error Ok(()) } +// Inspecting the kind for retry decisions +match provider.get_block_number().await { + Ok(n) => Ok(n), + Err(e) if matches!(e.kind, HostErrorKind::Unavailable | HostErrorKind::Timeout) => retry(), + Err(e) if matches!(e.kind, HostErrorKind::RateLimited) => backoff(), + Err(e) => Err(e), +} + // CoW module -async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // RPC error - TypedState::set("last", &num)?; // local-store error - CowClient::new(block.chain_id).submit_order(&order)?; // CoW API error +async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { + let num = provider.get_block_number().await?; // chain error + TypedState::set("last", &num)?; // store error + Cow::new(block.chain_id).submit_order(&order)?; // cow-api error Ok(()) } ``` +See the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the full taxonomy and the embedder-side mapping of backend signals (HTTP codes, transport errors, wallet rejections) to `host-error-kind`. + ## Testing Framework ### Universal: `nexum-sdk` Mock Host @@ -606,11 +596,11 @@ fn test_monitor_processes_block() { .set("last_block", &19_000_000u64.to_le_bytes()); // Dispatch a block event - let result = host.dispatch(Event::Block(BlockData { + let result = host.dispatch(Event::Block(Block { chain_id: 42161, number: 19_000_001, hash: vec![0; 32], - timestamp: 1700000000, + timestamp: 1_700_000_000_000, // ms since epoch })); assert!(result.is_ok()); @@ -644,7 +634,7 @@ fn test_module_signs_data() { Ok(vec![0u8; 65]) }); - let result = host.dispatch(Event::Block(BlockData { + let result = host.dispatch(Event::Block(Block { chain_id: 1, number: 19_000_001, hash: vec![0; 32], @@ -671,11 +661,11 @@ fn test_twap_monitor_submits_order() { host.chain(42161).block_number(19_000_001); - let result = host.dispatch(Event::Block(BlockData { + let result = host.dispatch(Event::Block(Block { chain_id: 42161, number: 19_000_001, hash: vec![0; 32], - timestamp: 1700000000, + timestamp: 1_700_000_000_000, })); assert!(result.is_ok()); @@ -737,11 +727,11 @@ fn test_module_as_component() { harness.mock_chain(42161).block_number(100); harness.call_init(vec![("api_url".into(), "mock".into())]).unwrap(); - let result = harness.call_on_event(Event::Block(BlockData { + let result = harness.call_on_event(Event::Block(Block { chain_id: 42161, number: 100, hash: vec![0; 32], - timestamp: 1700000000, + timestamp: 1_700_000_000_000, })); assert!(result.is_ok()); } @@ -769,7 +759,7 @@ my-module/ └── lib.rs # minimal module skeleton ``` -#### Universal module (targeting `web3:runtime/headless-module`) +#### Universal module (targeting `nexum:host/event-module`) `Cargo.toml`: ```toml @@ -782,7 +772,7 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -nexum-sdk = "0.1" +nexum-sdk = "0.2" [package.metadata.component] package = "my:module" @@ -801,25 +791,25 @@ impl MyModule { Ok(()) } - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { let block_num = provider.get_block_number().await?; info!("block {} on chain {}", block_num, block.chain_id); Ok(()) } - async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { + async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { info!("received {} logs", logs.len()); Ok(()) } - fn on_timer(timestamp: u64) -> Result<()> { - info!("timer fired at {timestamp}"); + fn on_tick(tick: Tick) -> Result<()> { + info!("tick fired at {} ms UTC", tick.fired_at); Ok(()) } } ``` -#### CoW Protocol module (targeting `shepherd:cow/shepherd-module`) +#### CoW Protocol module (targeting `shepherd:cow/shepherd`) `Cargo.toml`: ```toml @@ -832,7 +822,7 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -shepherd-sdk = "0.1" +shepherd-sdk = "0.2" [package.metadata.component] package = "my:module" @@ -851,19 +841,19 @@ impl MyCowModule { Ok(()) } - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { let block_num = provider.get_block_number().await?; info!("block {} on chain {}", block_num, block.chain_id); Ok(()) } - async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { + async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { info!("received {} logs", logs.len()); Ok(()) } - fn on_timer(timestamp: u64) -> Result<()> { - info!("timer fired at {timestamp}"); + fn on_tick(tick: Tick) -> Result<()> { + info!("tick fired at {} ms UTC", tick.fired_at); Ok(()) } } @@ -876,7 +866,7 @@ name = "my-module" version = "0.1.0" description = "" authors = [] -wasm = "sha256:TODO" +component = "sha256:TODO" [module.resources] max_memory_bytes = 10_485_760 @@ -890,6 +880,10 @@ max_consecutive_failures = 10 required = [] optional = [] +[capabilities] +required = ["chain", "local-store", "logging"] +optional = [] + [config] ``` @@ -916,31 +910,33 @@ cargo nexum publish --swarm http://localhost:1633 --batch-id ## SDK / Runtime Version Compatibility -The WIT definition is versioned (`web3:runtime@0.1.0`). The SDK pins this version. When the WIT evolves: +The WIT definition is versioned (`nexum:host@0.2.0`). The SDK pins this version. When the WIT evolves: -- **Patch** (0.1.x): backwards-compatible additions (new host functions). Old modules continue to work. +- **Patch** (0.2.x): backwards-compatible additions (new host functions, new manifest fields, new SDK helpers). Old modules continue to work. - **Minor** (0.x.0): may add new required exports. Old modules need recompilation. - **Major** (x.0.0): breaking changes. Runtime supports multiple world versions during transition. -The `bindgen!` macro on the host side uses wasmtime's **semver-aware resolution** -- a host implementing `@0.1.1` satisfies a guest compiled against `@0.1.0`. +The `bindgen!` macro on the host side uses wasmtime's **semver-aware resolution** -- a host implementing `@0.2.1` satisfies a guest compiled against `@0.2.0`. + +0.2 is the coordinated breaking-change window relative to 0.1. The 0.2.0 contracts (WIT package name, interface names, the `host-error` shape, the `nexum.toml` schema, the `#[nexum::module]` macro surface) are stable starting at 0.2.0 -- see the [migration guide §10](migration/0.1-to-0.2.md#10-deprecation-policy-going-forward-both) for the full deprecation policy. ## Summary | SDK Layer | Provides | |-----------|----------| -| `#[nexum::module]` | Eliminates WIT boilerplate; named event handlers (`on_block`, `on_logs`, `on_timer`); `async fn` + provider injection (universal) | -| `#[shepherd::module]` | Same as above, targeting CoW Protocol's `shepherd:cow/shepherd-module` world | -| `provider(chain_id)` | Full alloy `Provider` backed by host RPC via `HostTransport` | -| `IdentityClient` | Typed identity client for accounts, signing, and EIP-712 (nexum-sdk) | -| `CowClient` | Typed CoW Protocol API client backed by host `cow` interface (shepherd-sdk only) | +| `#[nexum::module]` | Eliminates WIT boilerplate; named event handlers (`on_block`, `on_logs`, `on_tick`, `on_message`); `async fn` + provider injection (universal) | +| `#[shepherd::module]` | Same as above, targeting CoW Protocol's `shepherd:cow/shepherd` world | +| `provider(chain_id)` | Full alloy `Provider` backed by host RPC via `HostTransport` (including 0.2's `chain::request-batch` for real wire-level batching) | +| `Signer` | Typed identity client for accounts, signing, and EIP-712 (nexum-sdk) | +| `Cow` | Typed CoW Protocol API client backed by host `cow-api` interface (shepherd-sdk only) | | `nexum_sdk::prelude::*` | Universal types, interfaces, alloy re-exports in one import | | `shepherd_sdk::prelude::*` | Universal prelude + CoW-specific types and interfaces | | `TypedState` | Serde-based typed local-store over raw bytes | | `sol!` | Compile-time Ethereum ABI codec (alloy-sol-types) | | `log::{info!, ...}` | Formatted logging macros | -| `Error` / `Result` | Proper error type with `?` support (extended by shepherd-sdk for CoW) | +| `HostError` / `HostErrorKind` / `Result` | Unified error type with `?` support and `kind`-based matching | | `nexum_sdk::testing::MockHost` | Native-Rust unit tests with universal mock host (includes identity mocking) | | `shepherd_sdk::testing::MockHost` | Extends universal mock with CoW-specific assertions | | `testing::MockProvider` | alloy `Provider` mock for RPC-level testing | | `testing::WasmTestHarness` | Integration tests against real wasmtime | -| `cargo nexum` | new / build / package / publish CLI | +| `cargo nexum` | new / build / package / publish / check / migrate CLI | diff --git a/docs/06-production-hardening.md b/docs/06-production-hardening.md index b4c5dd4..f3c25ef 100755 --- a/docs/06-production-hardening.md +++ b/docs/06-production-hardening.md @@ -80,7 +80,7 @@ Local-store quota (`max_state_bytes`) enforced in the `local-store::set` host fu | CPU (deterministic) | Fuel | Trap -> rollback -> restart | | CPU (wall-clock) | Epoch interruption | Yield -> resume or trap | | Memory | `ResourceLimiter` | `memory.grow` returns -1 | -| Storage | Host-side byte tracking | `local-store::set` returns `Err` | +| Storage | Host-side byte tracking | `local-store::set` returns `host-error { domain: "store", kind: invalid-input }` | ## Crash Handling & Restart Policy @@ -149,13 +149,13 @@ A restart creates a fresh `Store` (clean WASM memory) but reuses the `InstancePr ## RPC Resilience -All RPC I/O flows through alloy providers configured by the runtime operator. The `csn::request` host function (see doc 07) forwards to the provider, which is wrapped with resilience layers using alloy's tower-based middleware. +All RPC I/O flows through alloy providers configured by the runtime operator. The `chain::request` host function (see doc 07) forwards to the provider, which is wrapped with resilience layers using alloy's tower-based middleware. The additive `chain::request-batch` (0.2) routes alloy's `RequestPacket::Batch` to actually batch on the wire. ### Provider Stack ```mermaid flowchart TD - A["Module calls csn::request\n(via alloy Provider in SDK)"] --> B["Host csn::request impl\n-> alloy Provider"] + A["Module calls chain::request\n(via alloy Provider in SDK)"] --> B["Host chain::request impl\n-> alloy Provider"] B --> C["Timeout\n(10s default)"] C --> D["Retry\n(3 attempts, exponential\nbackoff + jitter)"] D --> E["Rate Limit\n(per-endpoint)"] @@ -177,11 +177,11 @@ flowchart TD chain_id = 42161 name = "arbitrum" -[[chains.csn]] +[[chains.endpoints]] url = "wss://arb-mainnet.g.alchemy.com/v2/KEY" priority = 1 -[[chains.csn]] +[[chains.endpoints]] url = "https://arb1.arbitrum.io/rpc" priority = 2 # fallback @@ -231,7 +231,7 @@ Every log line includes: |-------|--------| | `module` | Module name from manifest | | `chain_id` | Chain the event originated from | -| `event_type` | `block` / `logs` / `timer` | +| `event_type` | `block` / `logs` / `tick` / `message` | | `block_number` | For block/log events | | `level` | trace / debug / info / warn / error | | `timestamp` | ISO 8601 | diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index 7004c4d..8f443a3 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -1,8 +1,14 @@ # RPC Namespace Design: Generic JSON-RPC Passthrough +> **Naming note (0.2):** This document describes the `chain` interface in the +> `nexum:host` WIT package. In the 0.1 design history it was called `chain` +> (short for "consensus"); 0.2 renamed it to `chain` because `chain.request(...)` +> reads itself at the call site. The function signatures below are the 0.2 shape, +> returning `host-error` rather than the 0.1-era `json-rpc-error`. + ## Problem Statement -The current WIT `blockchain` interface defines individual functions for each Ethereum RPC method: +The 0.1 design started with a `blockchain` interface that defined individual functions for each Ethereum RPC method: ```wit interface blockchain { @@ -40,12 +46,12 @@ flowchart TD provider.get_logs(&filter)"] -->|full alloy Provider API| B B["HostTransport (SDK) - implements alloy Transport trait"] -->|"csn::request(chain_id, "eth_blockNumber", "[]")"| C + implements alloy Transport trait"] -->|"chain::request(chain_id, "eth_blockNumber", "[]")"| C C["WIT boundary single generic function"] --> D - D["Host csn::request impl + D["Host chain::request impl forwards to alloy provider"] -->|"provider.raw_request_dyn(method, params)"| E E["Alloy provider stack @@ -54,20 +60,13 @@ flowchart TD ## Updated WIT Interface -Replace the `blockchain` interface with `csn`: +Replace the `blockchain` interface with `chain`: ```wit -package web3:runtime@0.1.0; - -interface csn { - use types.{chain-id}; +package nexum:host@0.2.0; - /// JSON-RPC error returned by the provider or the host. - record json-rpc-error { - code: s64, - message: string, - data: option, - } +interface chain { + use types.{chain-id, host-error}; /// Execute a JSON-RPC request against the specified chain. /// @@ -80,56 +79,60 @@ interface csn { /// the JSON-RPC specification. The host handles id/jsonrpc framing; the /// guest only provides method + params and receives the `result` field. request: func(chain-id: chain-id, method: string, params: string) - -> result; + -> result; + + /// 0.2 additive: batched JSON-RPC. alloy's HostTransport routes + /// RequestPacket::Batch through this, so provider.multicall(...) actually + /// batches on the wire (it silently fanned-out single requests in 0.1). + request-batch: func(chain-id: chain-id, calls: list>) + -> result>, host-error>; } ``` -The `types` interface is unchanged. The `local-store`, `remote-store`, `msg`, `order`, and `logging` interfaces are unchanged. +Errors are reported via the unified `host-error` (see doc 00 and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) — the 0.1 `json-rpc-error` shape is gone. Modules match on `host-error-kind` (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) for retry/backoff decisions rather than parsing numeric JSON-RPC codes. + +The `types` interface is unchanged in shape (it now exposes `host-error` / `host-error-kind`). The `local-store`, `remote-store`, `messaging`, and `logging` interfaces are unchanged. The `identity` interface provides cryptographic identity — key management and signing: ```wit interface identity { - record identity-error { - code: u16, - message: string, - } + use types.{host-error}; /// Get available signing accounts (20-byte Ethereum addresses). - accounts: func() -> result>, identity-error>; + accounts: func() -> result>, host-error>; /// Sign raw bytes with the specified account. /// Returns a 65-byte ECDSA secp256k1 signature (r ‖ s ‖ v). - sign: func(account: list, data: list) -> result, identity-error>; + sign: func(account: list, data: list) -> result, host-error>; /// Sign EIP-712 typed data with the specified account. - sign-typed-data: func(account: list, typed-data: string) -> result, identity-error>; + sign-typed-data: func(account: list, typed-data: string) -> result, host-error>; } ``` -The universal `headless-module` world (in `web3:runtime`) contains the platform-agnostic interfaces: +The universal `event-module` world (in `nexum:host`) contains the platform-agnostic interfaces — six imports in 0.2: ```wit -world headless-module { - import csn; // replaces `import blockchain;` +world event-module { + import chain; // replaces `import blockchain;` from the early 0.1 sketch import identity; // cryptographic identity (key management, signing) import local-store; import remote-store; - import msg; + import messaging; import logging; - export init: func(config: types.config) -> result<_, string>; - export on-event: func(event: types.event) -> result<_, string>; + export init: func(config: types.config) -> result<_, host-error>; + export on-event: func(event: types.event) -> result<_, host-error>; } ``` -The CoW-specific `shepherd-module` world (in `shepherd:cow`) extends it with domain interfaces: +The CoW-specific `shepherd` world (in `shepherd:cow`) extends it with the merged `cow-api` interface: ```wit -world shepherd-module { - include web3:runtime/headless-module; - import cow; - import order; +world shepherd { + include nexum:host/event-module; + import cow-api; } ``` @@ -137,12 +140,12 @@ world shepherd-module { | Before (per-method) | After (generic) | |---|---| -| `blockchain::eth-call(chain-id, to, data)` | `csn::request(chain-id, "eth_call", params_json)` | -| `blockchain::eth-get-logs(filter)` | `csn::request(chain-id, "eth_getLogs", params_json)` | -| `blockchain::eth-block-number(chain-id)` | `csn::request(chain-id, "eth_blockNumber", "[]")` | -| *n/a — not exposed* | `csn::request(chain-id, "eth_getBalance", params_json)` | -| *n/a — not exposed* | `csn::request(chain-id, "eth_getCode", params_json)` | -| *n/a — not exposed* | `csn::request(chain-id, "eth_getStorageAt", params_json)` | +| `blockchain::eth-call(chain-id, to, data)` | `chain::request(chain-id, "eth_call", params_json)` | +| `blockchain::eth-get-logs(filter)` | `chain::request(chain-id, "eth_getLogs", params_json)` | +| `blockchain::eth-block-number(chain-id)` | `chain::request(chain-id, "eth_blockNumber", "[]")` | +| *n/a — not exposed* | `chain::request(chain-id, "eth_getBalance", params_json)` | +| *n/a — not exposed* | `chain::request(chain-id, "eth_getCode", params_json)` | +| *n/a — not exposed* | `chain::request(chain-id, "eth_getStorageAt", params_json)` | | *n/a — not exposed* | Any `eth_*` method — no WIT change needed | ### Why JSON Strings (Not `list`) @@ -159,13 +162,13 @@ The host implementation is minimal — one function handles the entire `eth_` na ```rust use serde_json::value::RawValue; -impl web3::runtime::csn::Host for NexumHostState { +impl nexum::host::chain::Host for NexumHostState { async fn request( &mut self, chain_id: u64, method: String, params: String, - ) -> wasmtime::Result> { + ) -> wasmtime::Result> { // 1. Check if this is a signing method that requires identity delegation if self.is_signing_method(&method) { return self.dispatch_signing(chain_id, &method, ¶ms).await; @@ -173,7 +176,9 @@ impl web3::runtime::csn::Host for NexumHostState { // 2. Method allowlisting for read-only methods if !self.is_read_method_allowed(&method) { - return Ok(Err(JsonRpcError { + return Ok(Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::Denied, code: -32601, message: format!("method not allowed: {method}"), data: None, @@ -182,7 +187,9 @@ impl web3::runtime::csn::Host for NexumHostState { // 3. Resolve the provider for this chain let provider = self.provider_for(chain_id).map_err(|e| { - JsonRpcError { + HostError { + domain: "chain".into(), + kind: HostErrorKind::Unsupported, code: -32002, message: format!("unknown chain: {chain_id}"), data: None, @@ -195,7 +202,7 @@ impl web3::runtime::csn::Host for NexumHostState { match provider.raw_request_dyn(method.into(), &raw_params).await { Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), // TransportError -> JsonRpcError + Err(e) => Ok(Err(HostError::from_transport("chain", e))), } } } @@ -242,17 +249,17 @@ impl NexumHostState { This could be made configurable per-module via `nexum.toml`: ```toml -[module.csn] +[module.chain] # Additional methods beyond the default read-only set. # Use with caution — write methods can have side-effects. extra_allowed_methods = ["eth_createAccessList"] ``` -The allowlist is runtime-enforced (string matching), not compile-time. This is an acceptable trade-off: the Component Model already provides structural sandboxing (modules can only call `csn::request`, not arbitrary network I/O), and the allowlist adds defence-in-depth for method-level granularity. +The allowlist is runtime-enforced (string matching), not compile-time. This is an acceptable trade-off: the Component Model already provides structural sandboxing (modules can only call `chain::request`, not arbitrary network I/O), and the allowlist adds defence-in-depth for method-level granularity. #### Signing Methods (Identity Delegation) -When a module calls `csn::request` with a signing method, the host does **not** forward the request to the RPC provider. Instead, it delegates to the `identity` backend for signing, then broadcasts the signed result via RPC. +When a module calls `chain::request` with a signing method, the host does **not** forward the request to the RPC provider. Instead, it delegates to the `identity` backend for signing, then broadcasts the signed result via RPC. ```rust impl NexumHostState { @@ -271,7 +278,7 @@ These methods are deliberately **not** in the read-only allowlist. They follow a ### Identity Delegation Flow -When a module calls a signing method through `csn::request`, the host intercepts it and delegates to the `Identity` trait: +When a module calls a signing method through `chain::request`, the host intercepts it and delegates to the `Identity` trait: ```mermaid sequenceDiagram @@ -280,7 +287,7 @@ sequenceDiagram participant I as Identity backend participant R as RPC provider - M->>C: csn::request(1, "eth_sendTransaction", params) + M->>C: chain::request(1, "eth_sendTransaction", params) C->>C: is_signing_method("eth_sendTransaction") → true C->>C: Parse transaction from params C->>I: sign(account, tx_hash) @@ -302,47 +309,49 @@ This pattern applies to all signing methods: | `eth_signTypedData_v4` | Signs EIP-712 typed data via `Identity::sign_typed_data()` | | `personal_sign` | Signs the message via `Identity::sign()` (with EIP-191 prefix) | -### Identity Trait and CsnHost +### Identity Trait and ChainHost -The host's `csn` implementation is generic over an `Identity` trait. This allows different identity backends (hardware wallet, KMS, in-memory test keys, etc.): +The host's `chain` implementation is generic over an `Identity` trait. This allows different identity backends (hardware wallet, KMS, in-memory test keys, etc.): ```rust /// Trait for identity backends that provide signing capabilities. /// -/// The host's csn implementation delegates signing methods to this trait. +/// The host's chain implementation delegates signing methods to this trait. /// Implementations can back onto hardware wallets, cloud KMS, in-memory /// test keys, or any other signing infrastructure. pub trait Identity: Send + Sync { /// Get available signing accounts (20-byte Ethereum addresses). - fn accounts(&self) -> Result>, IdentityError>; + fn accounts(&self) -> Result>, IdentityBackendError>; /// Sign raw bytes with the specified account. /// Returns a 65-byte ECDSA secp256k1 signature (r ‖ s ‖ v). - fn sign(&self, account: &[u8], data: &[u8]) -> Result, IdentityError>; + fn sign(&self, account: &[u8], data: &[u8]) -> Result, IdentityBackendError>; /// Sign EIP-712 typed data with the specified account. - fn sign_typed_data(&self, account: &[u8], typed_data: &str) -> Result, IdentityError>; + fn sign_typed_data(&self, account: &[u8], typed_data: &str) -> Result, IdentityBackendError>; } /// The host state is generic over the identity backend. -pub struct CsnHost { +pub struct ChainHost { providers: HashMap, identity: I, } -impl web3::runtime::csn::Host for CsnHost { +impl nexum::host::chain::Host for ChainHost { async fn request( &mut self, chain_id: u64, method: String, params: String, - ) -> wasmtime::Result> { + ) -> wasmtime::Result> { if self.is_signing_method(&method) { return self.dispatch_signing(chain_id, &method, ¶ms).await; } if !self.is_read_method_allowed(&method) { - return Ok(Err(JsonRpcError { + return Ok(Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::Denied, code: -32601, message: format!("method not allowed: {method}"), data: None, @@ -355,22 +364,24 @@ impl web3::runtime::csn::Host for CsnHost { match provider.raw_request_dyn(method.into(), &raw_params).await { Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), + Err(e) => Ok(Err(HostError::from_transport("chain", e))), } } } -impl CsnHost { +impl ChainHost { /// Dispatch signing methods to the identity backend. async fn dispatch_signing( &self, chain_id: u64, method: &str, params: &str, - ) -> wasmtime::Result> { + ) -> wasmtime::Result> { match method { "eth_accounts" => { - let accounts = self.identity.accounts().map_err(|e| JsonRpcError { + let accounts = self.identity.accounts().map_err(|e| HostError { + domain: "identity".into(), + kind: HostErrorKind::Internal, code: -32000, message: e.message, data: None, @@ -396,9 +407,11 @@ impl CsnHost { // Hash the transaction and sign it let tx_hash = filled_tx.signing_hash(); let signature = self.identity.sign(&from, tx_hash.as_ref()) - .map_err(|e| JsonRpcError { + .map_err(|e| HostError { + domain: "identity".into(), + kind: HostErrorKind::Internal, code: -32000, - message: e.message, + message: e.to_string(), data: None, })?; @@ -410,7 +423,7 @@ impl CsnHost { let raw_params_box: Box = RawValue::from_string(raw_params)?; match provider.raw_request_dyn("eth_sendRawTransaction".into(), &raw_params_box).await { Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), + Err(e) => Ok(Err(HostError::from_transport("chain", e))), } } @@ -420,9 +433,11 @@ impl CsnHost { let typed_data = params_arr[1].to_string(); let signature = self.identity.sign_typed_data(&account, &typed_data) - .map_err(|e| JsonRpcError { + .map_err(|e| HostError { + domain: "identity".into(), + kind: HostErrorKind::Internal, code: -32000, - message: e.message, + message: e.to_string(), data: None, })?; Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) @@ -440,15 +455,19 @@ impl CsnHost { let hash = keccak256(&msg); let signature = self.identity.sign(&account, &hash) - .map_err(|e| JsonRpcError { + .map_err(|e| HostError { + domain: "identity".into(), + kind: HostErrorKind::Internal, code: -32000, - message: e.message, + message: e.to_string(), data: None, })?; Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) } - _ => Ok(Err(JsonRpcError { + _ => Ok(Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::InvalidInput, code: -32601, message: format!("unknown signing method: {method}"), data: None, @@ -458,35 +477,42 @@ impl CsnHost { } ``` -The `CsnHost` also implements `web3::runtime::identity::Host` directly, delegating to the same `Identity` trait so modules can use the identity WIT interface for raw signing: +The `ChainHost` also implements `nexum::host::identity::Host` directly, delegating to the same `Identity` trait so modules can use the identity WIT interface for raw signing (errors map to `host-error` with `domain = "identity"`): ```rust -impl web3::runtime::identity::Host for CsnHost { - fn accounts(&mut self) -> wasmtime::Result>, IdentityError>> { - Ok(self.identity.accounts()) +impl nexum::host::identity::Host for ChainHost { + fn accounts(&mut self) -> wasmtime::Result>, HostError>> { + Ok(self.identity.accounts().map_err(|e| HostError { + domain: "identity".into(), + kind: e.kind(), // backend chooses unavailable/denied/internal + code: 0, + message: e.to_string(), + data: None, + })) } fn sign( &mut self, account: Vec, data: Vec, - ) -> wasmtime::Result, IdentityError>> { - Ok(self.identity.sign(&account, &data)) + ) -> wasmtime::Result, HostError>> { + Ok(self.identity.sign(&account, &data).map_err(|e| e.into_host_error("identity"))) } fn sign_typed_data( &mut self, account: Vec, typed_data: String, - ) -> wasmtime::Result, IdentityError>> { - Ok(self.identity.sign_typed_data(&account, &typed_data)) + ) -> wasmtime::Result, HostError>> { + Ok(self.identity.sign_typed_data(&account, &typed_data) + .map_err(|e| e.into_host_error("identity"))) } } ``` ## Guest SDK: `HostTransport` -The key SDK addition is a `HostTransport` struct that implements alloy's `Transport` trait by routing through the WIT `csn::request` host function. +The key SDK addition is a `HostTransport` struct that implements alloy's `Transport` trait by routing through the WIT `chain::request` host function. ### Transport Implementation @@ -500,7 +526,7 @@ use tower::Service; use std::task::{Context, Poll}; /// An alloy-compatible transport that routes JSON-RPC requests through the -/// Nexum host runtime. Synchronous from the guest's perspective — the host +/// Nexum host engine. Synchronous from the guest's perspective — the host /// function blocks until the RPC response is available. #[derive(Debug, Clone)] pub struct HostTransport { @@ -532,11 +558,18 @@ impl Service for HostTransport { Ok(ResponsePacket::Single(resp)) } RequestPacket::Batch(reqs) => { - let resps: Result, _> = reqs - .iter() - .map(|r| dispatch_single(chain_id, r)) + // 0.2: route batches through chain::request-batch so the + // host actually pipelines them on the wire. + let calls: Vec<(String, String)> = reqs.iter() + .map(|r| (r.method().to_string(), + r.params().map(|p| p.get()).unwrap_or("[]").to_string())) + .collect(); + let results = chain::request_batch(chain_id, &calls) + .map_err(|e| TransportError::from_host(e))?; + let resps: Vec<_> = reqs.iter().zip(results.into_iter()) + .map(|(req, result)| build_response(req, result)) .collect(); - Ok(ResponsePacket::Batch(resps?)) + Ok(ResponsePacket::Batch(resps)) } } }) @@ -563,7 +596,7 @@ fn dispatch_single( // This calls the WIT-imported host function. Synchronous from the guest's // perspective — the host executes the RPC call asynchronously and returns // the result when ready. - match csn::request(chain_id, method, params_json) { + match chain::request(chain_id, method, params_json) { Ok(result_json) => { let payload: Box = RawValue::from_string(result_json) .map_err(|e| TransportError::deser_err(e, "host response"))?; @@ -573,14 +606,17 @@ fn dispatch_single( }) } Err(e) => { - // Return a JSON-RPC error response rather than a transport error, - // so alloy can surface the RPC error code/message to the caller. + // Map the host-error onto an alloy error payload, encoding the + // kind/domain into `data` so the caller can recover the + // discriminant via HostError::from_response. Ok(Response { id: req.id().clone(), payload: ResponsePayload::Failure(ErrorPayload { - code: e.code, + code: e.code as i64, message: e.message, - data: e.data.and_then(|d| RawValue::from_string(d).ok()), + data: Some(RawValue::from_string( + serde_json::to_string(&HostErrorWire::from(e)).unwrap() + ).unwrap()), }), }) } @@ -590,7 +626,7 @@ fn dispatch_single( ### Why This Works Without Real Async -The `call()` method returns a `Box::pin(async move { ... })` — but the body is entirely synchronous. The `csn::request` host function blocks from the guest's perspective (the host runs the actual RPC call asynchronously via wasmtime's `func_wrap_async`, but the guest sees a normal function call that returns a value). The future resolves in a single poll. +The `call()` method returns a `Box::pin(async move { ... })` — but the body is entirely synchronous. The `chain::request` host function blocks from the guest's perspective (the host runs the actual RPC call asynchronously via wasmtime's `func_wrap_async`, but the guest sees a normal function call that returns a value). The future resolves in a single poll. This means alloy's `Provider` methods — which `await` the transport internally — complete immediately when driven by any executor. The SDK provides a minimal single-threaded executor: @@ -611,7 +647,7 @@ pub fn block_on(future: F) -> F::Output { use alloy_provider::RootProvider; use alloy_rpc_client::RpcClient; -/// Create an alloy `Provider` backed by the Nexum host runtime. +/// Create an alloy `Provider` backed by the Nexum host engine. /// /// The returned provider supports the full alloy `Provider` API — all `eth_*` /// methods, builder patterns, typed responses — routing every request through @@ -645,7 +681,7 @@ This is verbose and obscures the actual logic. But we can't reimplement every `P The proc macro (see doc 05) already generates the WIT export boilerplate. We extend it in two ways. For universal modules, the `#[nexum::module]` macro is used; for CoW modules, the `#[shepherd::module]` macro (which extends the universal one with CoW-specific imports): -1. **Named event handlers** — instead of writing the `match event { ... }` dispatch manually, module authors implement `on_block`, `on_logs`, and/or `on_timer`. The macro generates the `on_event` match. +1. **Named event handlers** — instead of writing the `match event { ... }` dispatch manually, module authors implement `on_block`, `on_logs`, `on_tick`, and/or `on_message`. The macro generates the `on_event` match. 2. **`async fn` support** — handlers can be async. The macro wraps the generated `on_event` in `block_on()`, so `.await` works naturally. 3. **Provider injection** — if a handler accepts `&RootProvider` as a second parameter, the macro creates the provider from the event's chain_id and passes it in. @@ -656,20 +692,20 @@ The proc macro (see doc 05) already generates the WIT export boilerplate. We ext struct MyModule; impl MyModule { - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { let block_num = provider.get_block_number().await?; // natural .await let balance = provider.get_balance(addr).latest().await?; // no block_on Ok(()) } - async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { + async fn on_logs(logs: Vec, provider: &RootProvider) -> Result<()> { for log in &logs { // ... } Ok(()) } - // on_timer not defined -> timer events silently ignored + // on_tick / on_message not defined -> those events are silently ignored } ``` @@ -680,8 +716,8 @@ impl MyModule { struct MyModule; impl MyModule { - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { - let cow = CowClient::new(block.chain_id); + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { + let cow = Cow::new(block.chain_id); let block_num = provider.get_block_number().await?; cow.submit_order(&order)?; Ok(()) @@ -693,7 +729,7 @@ impl MyModule { ```rust impl Guest for MyModule { - fn on_event(event: types::Event) -> Result<(), String> { + fn on_event(event: types::Event) -> Result<(), HostError> { nexum_sdk::block_on(async { match event { Event::Block(block) => { @@ -704,9 +740,10 @@ impl Guest for MyModule { let provider = nexum_sdk::provider(logs[0].chain_id); MyModule::on_logs(logs, &provider).await } - Event::Timer(_) => Ok(()), // no handler defined + Event::Tick(_) => Ok(()), // no handler defined + Event::Message(_) => Ok(()), // no handler defined } - }).map_err(|e| e.to_string()) + }) } } ``` @@ -717,9 +754,10 @@ The generated code calls `block_on` exactly once — at the top-level export bou | Handler | Payload | Optional injectable context | |---|---|---| -| `on_block(block)` | `BlockData` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_logs(logs)` | `Vec` | `provider: &RootProvider` (from `logs[0].chain_id`) | -| `on_timer(timestamp)` | `u64` | None (no chain context) | +| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | +| `on_logs(logs)` | `Vec` | `provider: &RootProvider` (from `logs[0].chain_id`) | +| `on_tick(tick)` | `Tick` (`tick.fired_at` is ms UTC) | None (no chain context) | +| `on_message(message)` | `Message` | None | The macro inspects each handler's signature: - **Second parameter is `&RootProvider`** -> inject `nexum_sdk::provider(chain_id)` @@ -740,7 +778,7 @@ The macro inspects each handler's signature: 4. **Composability.** Module authors can use alloy's builder patterns naturally inside any handler: ```rust - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { // EthCall builder — .latest() and .await both work let result = provider.call(tx).latest().await?; @@ -758,8 +796,8 @@ The macro inspects each handler's signature: 5. **Sync handlers still work.** Handlers that don't need RPC can be plain `fn`: ```rust - fn on_timer(timestamp: u64) -> Result<()> { - info!("timer fired at {timestamp}"); + fn on_tick(tick: Tick) -> Result<()> { + info!("tick fired at {} ms UTC", tick.fired_at); Ok(()) } ``` @@ -826,7 +864,7 @@ struct MyModule; impl MyModule { // Named handler — macro generates the match dispatch + provider injection - async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { + async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { // Full alloy Provider API — natural .await, provider injected let block_num = provider.get_block_number().await?; let eth_balance = provider.get_balance(addr).latest().await?; @@ -856,27 +894,23 @@ impl MyModule { } // Only implement handlers for event types you care about. - // No on_logs or on_timer -> those events are no-ops. + // No on_logs, on_tick, or on_message -> those events are no-ops. } ``` Every alloy `Provider` method works. No WIT changes. No host-side per-method code. No `block_on`. No `match event { ... }`. No manual provider construction. -## The `cow_` Namespace +## The `cow-api` Namespace CoW Protocol's API is REST-based, not JSON-RPC. Two options: -### Option A: Separate REST Interface (Recommended) +### Option A: Separate REST Interface (Recommended — chosen for 0.2) -```wit -interface cow { - use web3:runtime/types.{chain-id}; +In 0.1 this was two interfaces, `cow` (REST passthrough) and `order` (typed `submit`). 0.2 merges them into a single `cow-api` interface, dropping the `cow::cow::request` triple-stutter: - record api-error { - status: u16, - message: string, - body: option, - } +```wit +interface cow-api { + use nexum:host/types.{chain-id, host-error}; /// HTTP-style request to the CoW Protocol API. /// @@ -894,29 +928,32 @@ interface cow { method: string, path: string, body: option, - ) -> result; + ) -> result; + + /// Submit a serialised order. (Merged in from the 0.1 `order::submit`.) + submit-order: func(chain-id: chain-id, order-data: list) + -> result; } ``` ```wit -world shepherd-module { - include web3:runtime/headless-module; - import cow; // CoW Protocol API access - import order; // kept for backwards compat; could merge into cow +world shepherd { + include nexum:host/event-module; + import cow-api; } ``` The host implementation is similarly minimal: ```rust -impl shepherd::cow::cow::Host for NexumHostState { +impl shepherd::cow::cow_api::Host for NexumHostState { async fn request( &mut self, chain_id: u64, method: String, path: String, body: Option, - ) -> wasmtime::Result> { + ) -> wasmtime::Result> { let base_url = self.cow_api_url_for(chain_id)?; let url = format!("{base_url}{path}"); @@ -926,91 +963,110 @@ impl shepherd::cow::cow::Host for NexumHostState { None => req, }; - let resp = req.send().await?; + let resp = req.send().await + .map_err(|e| HostError::module("cow", HostErrorKind::Unavailable, e.to_string()))?; let status = resp.status().as_u16(); if status >= 400 { + let kind = match status { + 429 => HostErrorKind::RateLimited, + 401 | 403 => HostErrorKind::Denied, + 500..=599 => HostErrorKind::Unavailable, + _ => HostErrorKind::InvalidInput, + }; let body = resp.text().await.ok(); - return Ok(Err(ApiError { status, message: "request failed".into(), body })); + return Ok(Err(HostError { + domain: "cow".into(), + kind, + code: status as i32, + message: "request failed".into(), + data: body, + })); } - Ok(Ok(resp.text().await?)) + Ok(Ok(resp.text().await.unwrap_or_default())) } } ``` ### Option B: JSON-RPC Style (Unified) -Route `cow_*` methods through the same `csn::request` function: +Route `cow_*` methods through the same `chain::request` function: ```rust -// Guest usage: +// Guest usage (illustrative): let order_uid: String = block_on(provider.raw_request( "cow_submitOrder".into(), serde_json::json!({ "sellToken": "0x...", "buyToken": "0x...", ... }), ))?; ``` -The host dispatches by method prefix: +The host would dispatch by method prefix: ```rust async fn request(&mut self, chain_id: u64, method: String, params: String) - -> wasmtime::Result> + -> wasmtime::Result> { if method.starts_with("eth_") || method.starts_with("net_") { self.dispatch_rpc(chain_id, &method, ¶ms).await } else if method.starts_with("cow_") { self.dispatch_cow(chain_id, &method, ¶ms).await } else { - Ok(Err(JsonRpcError { code: -32601, message: "unknown namespace".into(), data: None })) + Ok(Err(HostError { + domain: "chain".into(), + kind: HostErrorKind::InvalidInput, + code: -32601, + message: "unknown namespace".into(), + data: None, + })) } } ``` -**Option A is recommended.** The CoW API is REST, not JSON-RPC — forcing it into JSON-RPC semantics adds a translation layer on both sides. A separate `cow` interface keeps the contract explicit and makes it clear in the WIT world what capabilities a module has. It also allows independent evolution — the `csn` interface doesn't need to know about CoW, and vice versa. +**Option A is recommended and is what 0.2 ships.** The CoW API is REST, not JSON-RPC — forcing it into JSON-RPC semantics adds a translation layer on both sides. A separate `cow-api` interface keeps the contract explicit and makes it clear in the WIT world what capabilities a module has. It also allows independent evolution — the `chain` interface doesn't need to know about CoW, and vice versa. -### SDK: `CowClient` +### SDK: `Cow` ```rust -/// Typed client for the CoW Protocol API, backed by the host runtime. -pub struct CowClient { +/// Typed client for the CoW Protocol API, backed by the host engine. +pub struct Cow { chain_id: u64, } -impl CowClient { +impl Cow { pub fn new(chain_id: u64) -> Self { Self { chain_id } } - /// Submit an order to the CoW Protocol API. + /// Submit an order via the typed cow-api::submit-order function. pub fn submit_order(&self, order: &OrderCreation) -> Result { - let body = serde_json::to_string(order)?; - let resp = cow::request(self.chain_id, "POST", "/api/v1/orders", Some(&body))?; - Ok(serde_json::from_str(&resp)?) + let bytes = postcard::to_allocvec(order)?; + let uid = cow_api::submit_order(self.chain_id, &bytes)?; + Ok(uid.parse()?) } /// Get an order by UID. pub fn get_order(&self, uid: &OrderUid) -> Result { - let resp = cow::request(self.chain_id, "GET", &format!("/api/v1/orders/{uid}"), None)?; + let resp = cow_api::request(self.chain_id, "GET", &format!("/api/v1/orders/{uid}"), None)?; Ok(serde_json::from_str(&resp)?) } /// Get the current auction. pub fn get_auction(&self) -> Result { - let resp = cow::request(self.chain_id, "GET", "/api/v1/auction", None)?; + let resp = cow_api::request(self.chain_id, "GET", "/api/v1/auction", None)?; Ok(serde_json::from_str(&resp)?) } /// Get a quote for a potential order. pub fn get_quote(&self, params: &OrderQuoteRequest) -> Result { let body = serde_json::to_string(params)?; - let resp = cow::request(self.chain_id, "POST", "/api/v1/quote", Some(&body))?; + let resp = cow_api::request(self.chain_id, "POST", "/api/v1/quote", Some(&body))?; Ok(serde_json::from_str(&resp)?) } /// Raw request for endpoints not yet wrapped. pub fn raw_request(&self, method: &str, path: &str, body: Option<&str>) -> Result { - Ok(cow::request(self.chain_id, method, path, body)?) + Ok(cow_api::request(self.chain_id, method, path, body)?) } } ``` @@ -1018,8 +1074,8 @@ impl CowClient { Usage in a module: ```rust -async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { - let cow = CowClient::new(block.chain_id); +async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { + let cow = Cow::new(block.chain_id); // Read chain state via alloy — provider injected by macro let block_num = provider.get_block_number().await?; @@ -1030,8 +1086,9 @@ async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { buy_token: weth, sell_amount: U256::from(1_000_000_000), kind: OrderKind::Sell, - valid_to: provider.get_block(block_num.into(), false).await? - .unwrap().header.timestamp + 300, + // block.timestamp is ms-since-epoch in 0.2 — divide for seconds + valid_to: (provider.get_block(block_num.into(), false).await? + .unwrap().header.timestamp / 1000) + 300, ..Default::default() })?; @@ -1045,14 +1102,14 @@ async fn on_block(block: BlockData, provider: &RootProvider) -> Result<()> { nexum-sdk/ ├── Cargo.toml ├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor +│ ├── lib.rs # re-exports, prelude, provider() constructor │ ├── bindings.rs # generated WIT bindings -│ ├── transport.rs # HostTransport (alloy Transport impl) +│ ├── transport.rs # HostTransport (alloy Transport impl, batches via chain::request-batch) │ ├── local_store.rs # TypedState helpers (serde over local-store) -│ ├── identity.rs # IdentityClient (typed identity helpers) +│ ├── signer.rs # Signer (typed identity helpers) │ ├── abi.rs # alloy-sol-types integration │ ├── log.rs # logging macros -│ ├── error.rs # error types +│ ├── error.rs # HostError / HostErrorKind │ └── testing.rs # mock host, test harness └── macros/ └── src/ @@ -1062,8 +1119,7 @@ shepherd-sdk/ ├── Cargo.toml # depends on nexum-sdk, re-exports it ├── src/ │ ├── lib.rs # re-exports nexum-sdk + CoW additions -│ ├── cow.rs # CowClient typed wrapper -│ └── order.rs # order submission helpers +│ └── cow.rs # Cow typed wrapper (submit + REST passthrough) └── macros/ └── src/ └── lib.rs # #[shepherd::module] proc macro (extends nexum::module) @@ -1092,19 +1148,19 @@ All alloy crates with `default-features = false` to avoid pulling in reqwest, to ```rust // nexum_sdk::prelude -pub use crate::bindings::web3::runtime::types::*; -pub use crate::bindings::web3::runtime::csn; -pub use crate::bindings::web3::runtime::identity; -pub use crate::bindings::web3::runtime::local_store; -pub use crate::bindings::web3::runtime::remote_store; -pub use crate::bindings::web3::runtime::msg; -pub use crate::bindings::web3::runtime::logging; +pub use crate::bindings::nexum::host::types::*; +pub use crate::bindings::nexum::host::chain; +pub use crate::bindings::nexum::host::identity; +pub use crate::bindings::nexum::host::local_store; +pub use crate::bindings::nexum::host::remote_store; +pub use crate::bindings::nexum::host::messaging; +pub use crate::bindings::nexum::host::logging; pub use crate::log::{trace, debug, info, warn, error}; pub use crate::local_store::TypedState; -pub use crate::identity::IdentityClient; +pub use crate::signer::Signer; pub use crate::transport::HostTransport; -pub use crate::{provider, block_on}; -pub use crate::error::{Result, Error}; +pub use crate::provider; +pub use crate::error::{Result, HostError, HostErrorKind}; // Re-export alloy essentials so modules don't need direct alloy dependencies pub use alloy_primitives::{Address, B256, U256, Bytes}; @@ -1116,9 +1172,8 @@ pub use alloy_provider::Provider; ```rust // shepherd_sdk::prelude (re-exports nexum_sdk::prelude + CoW additions) pub use nexum_sdk::prelude::*; -pub use crate::bindings::shepherd::cow::cow; -pub use crate::bindings::shepherd::cow::order; -pub use crate::cow::CowClient; +pub use crate::bindings::shepherd::cow::cow_api; +pub use crate::cow::Cow; ``` ## Testing @@ -1152,14 +1207,14 @@ fn test_reads_balance() { Note: `block_on` is still available and useful in test code where `#[test]` functions are synchronous. In module code, prefer `async fn on_event` with `.await` instead. -### MockCowClient for Unit Tests +### MockCow for Unit Tests ```rust -use shepherd_sdk::testing::MockCowClient; +use shepherd_sdk::testing::MockCow; #[test] fn test_submits_order() { - let mut mock_cow = MockCowClient::new(42161); + let mut mock_cow = MockCow::new(42161); mock_cow.on_submit(|order| { assert_eq!(order.sell_token, usdc); Ok(OrderUid::from([0x42; 56])) @@ -1189,26 +1244,19 @@ The primary trade-off is **type safety at the WIT boundary**: JSON strings vs. s 2. **Non-Rust guests** (JS, Python, Go) typically work with JSON natively, so JSON strings are actually *more* natural than WIT record types. 3. **Tracing**: the host can log method + params as structured JSON before forwarding, providing equal or better debuggability. -The compile-time guarantee that a module can only call methods in the WIT is traded for a runtime allowlist. Given that the Component Model already provides structural sandboxing (the module can only call `csn::request`, not arbitrary network I/O), and the allowlist is enforced at the host boundary before any RPC call is made, this is a sound trade-off. +The compile-time guarantee that a module can only call methods in the WIT is traded for a runtime allowlist. Given that the Component Model already provides structural sandboxing (the module can only call `chain::request`, not arbitrary network I/O), and the allowlist is enforced at the host boundary before any RPC call is made, this is a sound trade-off. ## Migration Path -If the current `blockchain` interface has already been implemented: - -1. Add `csn` interface alongside `blockchain` (both in WIT world). -2. SDK defaults to `csn`-backed `provider()`. Raw `blockchain::*` functions still work. -3. Deprecation cycle: mark `blockchain` functions as deprecated in SDK docs. -4. Remove `blockchain` interface in the next WIT minor version bump. - -If starting from scratch (recommended): implement `csn` only. Skip `blockchain` entirely. +For modules and embedders moving from 0.1 to 0.2, follow the [Migration Guide](migration/0.1-to-0.2.md). In summary: the early 0.1 `blockchain` sketch was replaced by `csn` later in 0.1 and is now `chain` in 0.2; the SDK's `block_on` is now hidden behind the `#[nexum::module]` macro; and every host function returns `host-error` rather than a per-protocol error type. ## Summary -| Component | What Changes | +| Component | What 0.2 ships | |---|---| -| **WIT** | Replace `blockchain` with `csn` (1 function). Add `identity` interface (accounts, sign, sign-typed-data). Add `cow` interface in `shepherd:cow`. `headless-module` imports 6 interfaces: csn, identity, local-store, remote-store, msg, logging. | -| **Host** | `CsnHost` — one `csn::request` impl that forwards read-only methods to `provider.raw_request_dyn` and delegates signing methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) to the `Identity` backend. One `identity::Host` impl delegating to the same backend. One `cow::request` impl forwarding to HTTP client. | -| **SDK** | `nexum-sdk`: `HostTransport` (alloy `Transport` impl), `provider()` constructor, `block_on()`, `IdentityClient` (typed identity wrapper). `shepherd-sdk`: `CowClient`, order helpers (extends `nexum-sdk`). | -| **`#[nexum::module]` / `#[shepherd::module]` macros** | Named event handlers (`on_block`, `on_logs`, `on_timer`) with generated match dispatch. `async fn` support. Optional `&RootProvider` injection. `#[nexum::module]` for universal modules; `#[shepherd::module]` for CoW modules. | -| **Module author experience** | Full alloy `Provider` API via injected provider. Signing via `IdentityClient` or transparently through `csn::request` signing methods. Full CoW API via `CowClient`. No match boilerplate. No `block_on`. No manual ABI wrangling for RPC calls. | +| **WIT** | `chain` interface with `request` + additive `request-batch`. `identity` (accounts, sign, sign-typed-data). Merged `cow-api` in `shepherd:cow`. `event-module` imports 6 interfaces: chain, identity, local-store, remote-store, messaging, logging. Plus additive `clock` / `random` / `http` capabilities and the experimental `query-module` world. | +| **Host** | `ChainHost` — one `chain::request` impl that forwards read-only methods to `provider.raw_request_dyn` and delegates signing methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) to the `Identity` backend. Plus `chain::request-batch` that actually pipelines. One `identity::Host` impl delegating to the same backend. One `cow-api::request` + `submit-order` impl forwarding to HTTP client. All host functions return `host-error`. | +| **SDK** | `nexum-sdk`: `HostTransport` (alloy `Transport` impl, batches via `chain::request-batch`), `provider()` constructor, `Signer` (typed identity wrapper), `HostError` / `HostErrorKind`. `shepherd-sdk`: `Cow` (extends `nexum-sdk`). `block_on` is internal. | +| **`#[nexum::module]` / `#[shepherd::module]` macros** | Named event handlers (`on_block`, `on_logs`, `on_tick`, `on_message`) with generated match dispatch. `async fn` support. Optional `&RootProvider` injection. `#[nexum::module]` for universal modules; `#[shepherd::module]` for CoW modules. | +| **Module author experience** | Full alloy `Provider` API via injected provider. Signing via `Signer` or transparently through `chain::request` signing methods. Full CoW API via `Cow`. No match boilerplate. No `block_on`. No manual ABI wrangling for RPC calls. Match on `HostErrorKind` for retry/backoff. | | **Existing ABI helpers** | Unchanged — `sol!` macro and `alloy-sol-types` still used for contract calldata encoding/decoding. | diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index e1d526e..afc50ab 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -1,17 +1,19 @@ # Platform Generalisation +> **Status (0.2):** Nexum is **designed** to be portable to mobile and browser hosts; the 0.2 **reference runtime is server-only**. The mobile, WebView, and super-app targets in this document describe architectural direction, not shipping artifacts. They remain in the docs because they're load-bearing design — the WIT contract is shaped by the requirement that all four can implement it — but they are **planned** work, conditional on a named design partner for 0.3. See the per-target rows below for current status. + ## Motivation -The Nexum runtime (docs 01-07) is designed as a server-side Rust binary embedding wasmtime. But the core abstractions — WIT-defined host interfaces, content-addressed module distribution, declarative manifests — are not inherently server-specific. The same module binary, the same packaging, and the same distribution mechanism can serve multiple platform targets: +The Nexum runtime (docs 01-07) is designed as a server-side Rust binary embedding wasmtime. But the core abstractions — WIT-defined host interfaces, content-addressed module distribution, declarative manifests — are not inherently server-specific. The same module binary, the same packaging, and the same distribution mechanism are intended to serve multiple platform targets: -1. **Server runtime** — the current design (Rust/Tokio/wasmtime). Headless automation: blockchain event monitoring, order submission, background computation. -2. **Mobile app (Flutter/Dart)** — a WASM runtime embedded in a native mobile application via FFI. Modules run on-device, backed by local state (SQLite) and RPC over HTTP. -3. **WebView** — a browser engine (V8/JSC/SpiderMonkey) executing WASM natively, with host functions injected from the native layer via a JavaScript bridge. Enables rich web-based UIs with blockchain-native capabilities. -4. **Decentralised super app** — a shell application (mobile or desktop) that dynamically loads modules discovered via ENS and fetched from Swarm. Some modules are headless (automation); others are interactive (UI). All are sandboxed, all are distributed without a central app store. +1. **Server runtime** *(shipping in 0.2)* — the current design (Rust/Tokio/wasmtime). Headless automation: blockchain event monitoring, order submission, background computation. +2. **Mobile app (Flutter/Dart)** *(planned — see roadmap)* — a WASM runtime embedded in a native mobile application via FFI. Modules run on-device, backed by local state (SQLite) and RPC over HTTP. +3. **WebView** *(planned — see roadmap)* — a browser engine (V8/JSC/SpiderMonkey) executing WASM natively, with host functions injected from the native layer via a JavaScript bridge. Enables rich web-based UIs with blockchain-native capabilities. +4. **Decentralised super app** *(planned — see roadmap)* — a shell application (mobile or desktop) that dynamically loads modules discovered via ENS and fetched from Swarm. Some modules are headless (automation); others are interactive (UI). All are sandboxed, all are distributed without a central app store. The key insight: **the WIT contract is the universal interface**. Any host that implements the required interfaces can run the same module binary. The differences between platforms are in *how* the host implements those interfaces — not in what the module sees. -This document defines the layered architecture that enables this generalisation and specifies the universal interface set. +This document defines the layered architecture that enables this generalisation and specifies the universal interface set. The 0.2 server runtime is the first host implementation; the experimental `nexum:host/query-module` WIT world (published but unhosted in 0.2) exists to give mobile/wallet embedders a stable target to implement against before 0.3. ## Primitive Taxonomy @@ -19,27 +21,29 @@ Before diving into WIT definitions, the universal runtime is built on six primit | Primitive | Interface | Backed by | Purpose | |-----------|-----------|-----------|---------| -| **Consensus** | `csn` | JSON-RPC (eth_*) | Read/write blockchain consensus state | +| **Chain** | `chain` | JSON-RPC (eth_*) | Read/write blockchain consensus state | | **Identity** | `identity` | Keystore / KMS / device keychain / wallet extension | Cryptographic identity — key management and signing | | **Local Store** | `local-store` | redb / SQLite / IndexedDB | Per-module private persistence on the device | | **Remote Store** | `remote-store` | Ethereum Swarm | Decentralised content-addressed storage | -| **Messaging** | `msg` | Waku | Decentralised pub/sub messaging | +| **Messaging** | `messaging` | Waku | Decentralised pub/sub messaging | | **Logging** | `logging` | tracing / console | Diagnostic output | These six primitives are orthogonal: -- **Consensus** is the source of truth — the blockchain. Modules read chain state and (indirectly) write to it via order submission or transactions. -- **Identity** is cryptographic agency — key management and signing. Modules can enumerate available accounts and request signatures (ECDSA secp256k1 by default, extensible). The `csn` host implementation depends on `identity` internally — signing RPC methods (e.g. `eth_sendTransaction`) delegate to `identity` for the actual signature. +- **Chain** is the source of truth — the blockchain consensus state. Modules read chain state and (indirectly) write to it via order submission or transactions. +- **Identity** is cryptographic agency — key management and signing. Modules can enumerate available accounts and request signatures (ECDSA secp256k1 by default, extensible). The `chain` host implementation depends on `identity` internally — signing RPC methods (e.g. `eth_sendTransaction`) delegate to `identity` for the actual signature. - **Local Store** is the module's private scratchpad — fast, local, scoped to one module on one device. Does not replicate. - **Remote Store** is shared persistent content — content-addressed, decentralised, survives independent of any device. Any module on any device can read what another module wrote. - **Messaging** is real-time communication — ephemeral pub/sub messages between modules, devices, or users. Unlike remote store (persistent, content-addressed), messaging is transient and topic-based. - **Logging** is diagnostics — one-way output for debugging and monitoring. Not a data channel. -Together they cover the full spectrum: persistent truth (consensus), cryptographic agency (identity), local scratch (local-store), shared content (remote-store), real-time coordination (msg), and diagnostics (logging). +Together they cover the full spectrum: persistent truth (chain), cryptographic agency (identity), local scratch (local-store), shared content (remote-store), real-time coordination (messaging), and diagnostics (logging). + +The 0.2 `event-module` world imports all six. (In 0.1 the WIT inadvertently omitted `identity` from the world definition despite the docs claiming six primitives; 0.2 makes the contract match the taxonomy.) Three additional **additive** capabilities — `clock`, `random`, and `http` (allowlisted) — are available via the manifest's `[capabilities]` section but are not part of the six-primitive core. ## Architectural Principle: Layered WIT Worlds -The current `shepherd-module` world conflates universal blockchain runtime capabilities with CoW Protocol domain-specific interfaces. To enable reuse across platforms and domains, the WIT is split into layers: +The current `shepherd` world conflates universal blockchain runtime capabilities with CoW Protocol domain-specific interfaces. To enable reuse across platforms and domains, the WIT is split into layers: ```mermaid graph TD @@ -54,11 +58,11 @@ graph TD end subgraph L1["Layer 1: Universal Runtime Interfaces"] - CSN["csn — consensus access (JSON-RPC passthrough)"] + CSN["chain — consensus access (JSON-RPC passthrough)"] ID["identity — cryptographic identity (key management, signing)"] LS["local-store — local key-value persistence"] RS["remote-store — decentralised content-addressed storage"] - MSG["msg — decentralised pub/sub messaging"] + MSG["messaging — decentralised pub/sub messaging"] LOG["logging — structured logging"] EXP["Exports: init(config) + on-event(event)"] end @@ -73,19 +77,13 @@ Each layer builds on the one below via WIT `include`. A module compiled against These six interfaces form the universal runtime contract. Any platform — server, mobile, WebView, desktop — can implement them. -### `csn` — Consensus Access +### `chain` — Consensus Access -The module's window into blockchain consensus. A single generic function that forwards JSON-RPC requests to the host's provider infrastructure. The host decides *how* to reach the chain — the module only specifies *what* to ask. +The module's window into blockchain consensus. A single generic function that forwards JSON-RPC requests to the host's provider infrastructure, plus an additive batched variant. The host decides *how* to reach the chain — the module only specifies *what* to ask. ```wit -interface csn { - type chain-id = u64; - - record json-rpc-error { - code: s64, - message: string, - data: option, - } +interface chain { + use types.{chain-id, host-error}; /// Execute a JSON-RPC request against the specified chain. /// @@ -97,46 +95,47 @@ interface csn { /// `method` includes the namespace prefix (e.g. "eth_call"). /// `params` and the success value are JSON-encoded strings. request: func(chain-id: chain-id, method: string, params: string) - -> result; + -> result; + + /// Additive 0.2 method: batched JSON-RPC. + request-batch: func(chain-id: chain-id, calls: list>) + -> result>, host-error>; } ``` **Platform implementations:** -| Platform | `csn::request` backed by | +| Platform | `chain::request` backed by | |----------|--------------------------| | Server (Nexum) | alloy provider with tower middleware (timeout, retry, rate-limit, fallback) | | Mobile (Flutter) | HTTP client (reqwest via FFI, or Dart `http` package) to configured RPC endpoint | | WebView | JavaScript bridge -> `window.ethereum` (injected wallet) or native HTTP via message channel | | Super app | Same as mobile, with per-module chain permissions | -The Rust SDK's `HostTransport` (doc 07) works identically on all platforms — it implements alloy's `Transport` trait over `csn::request`, so module authors get the full alloy `Provider` API regardless of where the module runs. +The Rust SDK's `HostTransport` (doc 07) works identically on all platforms — it implements alloy's `Transport` trait over `chain::request`, so module authors get the full alloy `Provider` API regardless of where the module runs. ### `identity` — Cryptographic Identity Provides key management and signing capabilities to modules. ECDSA secp256k1 by default (the Ethereum standard), extensible to other schemes. Modules can enumerate available accounts and request signatures over arbitrary data. -The `csn` host implementation depends on `identity` internally — signing RPC methods such as `eth_sendTransaction` or `eth_signTypedData_v4` delegate to `identity` for the actual cryptographic signature. Modules can also import `identity` directly for raw signing operations outside of JSON-RPC (e.g. signing EIP-712 typed data for off-chain order submission). +The `chain` host implementation depends on `identity` internally — signing RPC methods such as `eth_sendTransaction` or `eth_signTypedData_v4` delegate to `identity` for the actual cryptographic signature. Modules can also import `identity` directly for raw signing operations outside of JSON-RPC (e.g. signing EIP-712 typed data for off-chain order submission). ```wit interface identity { - record identity-error { - code: u16, - message: string, - } + use types.{host-error}; /// List available accounts (public keys or addresses). /// Returns a list of account identifiers (e.g. 20-byte Ethereum addresses). - accounts: func() -> result>, identity-error>; + accounts: func() -> result>, host-error>; /// Sign arbitrary data with the specified account's private key. /// Returns the signature bytes (e.g. 65-byte ECDSA signature with recovery id). - sign: func(account: list, data: list) -> result, identity-error>; + sign: func(account: list, data: list) -> result, host-error>; /// Sign EIP-712 typed structured data. /// `typed-data` is the JSON-encoded EIP-712 typed data structure. /// Returns the signature bytes. - sign-typed-data: func(account: list, typed-data: string) -> result, identity-error>; + sign-typed-data: func(account: list, typed-data: string) -> result, host-error>; } ``` @@ -149,15 +148,15 @@ interface identity { | WebView | window.ethereum (wallet extension) or native bridge to keychain | | Super app | Device keychain + per-module permission grants | -**Relationship with `csn`:** +**Relationship with `chain`:** -The `csn` host implementation uses `identity` internally when it encounters signing methods. For example, when a module calls `csn::request` with `eth_sendTransaction`, the host: +The `chain` host implementation uses `identity` internally when it encounters signing methods. For example, when a module calls `chain::request` with `eth_sendTransaction`, the host: 1. Constructs the transaction from the JSON-RPC params. 2. Calls `identity::sign` to produce the signature. 3. Sends the signed transaction via the provider. -This means modules that only need to sign transactions via standard JSON-RPC methods do not need to import `identity` directly — `csn` handles it transparently. Modules that need raw signing (e.g. off-chain message signing for order submission, attestations, or custom protocols) import `identity` explicitly. +This means modules that only need to sign transactions via standard JSON-RPC methods do not need to import `identity` directly — `chain` handles it transparently. Modules that need raw signing (e.g. off-chain message signing for order submission, attestations, or custom protocols) import `identity` explicitly. ### `local-store` — Local Key-Value Persistence @@ -165,18 +164,20 @@ The module's private scratchpad. **Local to the device/process** — does not re ```wit interface local-store { + use types.{host-error}; + /// Get a value by key. Returns None if the key does not exist. - get: func(key: string) -> result>, string>; + get: func(key: string) -> result>, host-error>; /// Set a key-value pair. Overwrites any existing value. - /// The host MAY enforce a size quota; if exceeded, returns Err. - set: func(key: string, value: list) -> result<_, string>; + /// The host MAY enforce a size quota; if exceeded, returns Err with kind=invalid-input. + set: func(key: string, value: list) -> result<_, host-error>; /// Delete a key. No-op if the key does not exist. - delete: func(key: string) -> result<_, string>; + delete: func(key: string) -> result<_, host-error>; /// List all keys matching a prefix. Empty prefix returns all keys. - list-keys: func(prefix: string) -> result, string>; + list-keys: func(prefix: string) -> result, host-error>; } ``` @@ -201,10 +202,7 @@ Swarm is both the distribution mechanism (modules are fetched from Swarm) and a ```wit interface remote-store { - record store-error { - code: u16, - message: string, - } + use types.{host-error}; /// Upload raw data to the decentralised store. /// Returns the 32-byte content reference (Swarm address). @@ -212,14 +210,14 @@ interface remote-store { /// The host routes to its configured Bee node. Postage batch /// management is the host's responsibility — the module only /// provides data and gets back a reference. - upload: func(data: list) -> result, store-error>; + upload: func(data: list) -> result, host-error>; /// Download raw data by 32-byte content reference. /// /// The host fetches from its Bee node or a public gateway. /// Returns the raw bytes. The caller is responsible for /// interpreting the content (JSON, protobuf, WASM, etc.). - download: func(reference: list) -> result, store-error>; + download: func(reference: list) -> result, host-error>; /// Read the latest value from a mutable feed. /// @@ -228,10 +226,10 @@ interface remote-store { /// `topic`: 32-byte topic hash. /// /// Returns None if the feed has no updates. - feed-get: func( + read-feed: func( owner: list, topic: list, - ) -> result>, store-error>; + ) -> result>, host-error>; /// Update a mutable feed with new data. /// @@ -243,10 +241,10 @@ interface remote-store { /// `data`: the payload to publish. /// /// Returns the 32-byte reference of the new chunk. - feed-set: func( + write-feed: func( topic: list, data: list, - ) -> result, store-error>; + ) -> result, host-error>; } ``` @@ -263,24 +261,21 @@ interface remote-store { - **Decentralised persistence.** `local-store` is device-local. `remote-store` gives modules access to content-addressed storage that persists independent of any single device. - **Content distribution.** Modules can publish data (feeds, references) that other modules or users can consume — without a central server. -- **Cross-device coordination.** Two instances of the same module on different devices can share data via feed topics — one writes via `feed-set`, the other reads via `feed-get`. +- **Cross-device coordination.** Two instances of the same module on different devices can share data via feed topics — one writes via `write-feed`, the other reads via `read-feed`. - **Consistency with distribution model.** Modules are already fetched from Swarm (doc 02, 03). Exposing `remote-store` at runtime means modules participate in the same content-addressed network they were distributed through. -### `msg` — Decentralised Messaging +### `messaging` — Decentralised Messaging -Backed by Waku. Provides real-time, privacy-preserving pub/sub messaging between modules, devices, and users. Unlike `remote-store` (persistent, content-addressed), `msg` is transient and topic-based — fire-and-forget messages on content topics. +Backed by Waku. Provides real-time, privacy-preserving pub/sub messaging between modules, devices, and users. Unlike `remote-store` (persistent, content-addressed), `messaging` is transient and topic-based — fire-and-forget messages on content topics. ```wit -interface msg { - record msg-error { - code: u16, - message: string, - } +interface messaging { + use types.{host-error}; record message { content-topic: string, payload: list, - timestamp: u64, + timestamp: u64, // milliseconds since Unix epoch, UTC /// Optional sender identity (protocol-dependent). sender: option>, } @@ -293,7 +288,7 @@ interface msg { /// /// Content topics follow the format: //// /// e.g. "/nexum/1/twap-updates/proto" - publish: func(content-topic: string, payload: list) -> result<_, msg-error>; + publish: func(content-topic: string, payload: list) -> result<_, host-error>; /// Query historical messages from the Waku store protocol. /// @@ -305,41 +300,41 @@ interface msg { start-time: option, end-time: option, limit: option, - ) -> result, msg-error>; + ) -> result, host-error>; } ``` -**Receiving messages** is handled through the event system, not the `msg` interface. Modules declare message subscriptions in their manifest, and the host delivers them as events: +**Receiving messages** is handled through the event system, not the `messaging` interface. Modules declare message subscriptions in their manifest, and the host delivers them as events: ```toml -[[subscribe]] -type = "message" +[[subscription]] +kind = "message" content_topic = "/nexum/1/twap-updates/proto" ``` -The event variant is extended to include message events: +The event variant in 0.2 carries `message` as a first-class variant: ```wit -record message-data { +record message { content-topic: string, payload: list, - timestamp: u64, + timestamp: u64, // milliseconds since Unix epoch, UTC sender: option>, } variant event { - block(block-data), - logs(list), - timer(u64), - message(message-data), + block(block), + logs(list), + tick(tick), + message(message), } ``` -This follows the same pattern as all other event sources: sending uses the import interface (`msg::publish`), receiving uses the declarative subscription + `on-event` dispatch. +This follows the same pattern as all other event sources: sending uses the import interface (`messaging::publish`), receiving uses the declarative subscription + `on-event` dispatch. **Platform implementations:** -| Platform | `msg` backed by | +| Platform | `messaging` backed by | |----------|-----------------| | Server (Nexum) | Waku node (nwaku or go-waku) via JSON-RPC or REST API | | Mobile (Flutter) | Waku light client via FFI (libwaku) or HTTP to remote Waku node | @@ -352,7 +347,7 @@ This follows the same pattern as all other event sources: sending uses the impor - **User notifications.** A headless server module can publish an alert to a content topic; the user's mobile app module subscribes and displays a notification. - **Decentralised coordination.** Multiple instances of the same module (e.g. running on different operator nodes) can coordinate via messaging — leader election, work distribution, heartbeats. - **Privacy.** Waku supports encrypted messaging and ephemeral relay. Modules can communicate without exposing data to the public chain. -- **Complementary to remote-store.** `remote-store` is for persistent content (data that should survive). `msg` is for ephemeral signals (notifications, coordination, real-time feeds). Together they cover the full persistence spectrum. +- **Complementary to remote-store.** `remote-store` is for persistent content (data that should survive). `messaging` is for ephemeral signals (notifications, coordination, real-time feeds). Together they cover the full persistence spectrum. ### `logging` — Structured Logging @@ -373,63 +368,81 @@ Every platform implements this trivially. On server: `tracing` crate. On mobile: ### Universal World Definition ```wit -package web3:runtime@0.1.0; +package nexum:host@0.2.0; interface types { type chain-id = u64; - record block-data { + record block { chain-id: chain-id, number: u64, hash: list, - timestamp: u64, + timestamp: u64, // ms since Unix epoch, UTC } - record log-entry { + record log { chain-id: chain-id, address: list, topics: list>, data: list, block-number: u64, - tx-hash: list, + transaction-hash: list, log-index: u32, } - record message-data { + record tick { + fired-at: u64, // ms since Unix epoch, UTC + } + + record message { content-topic: string, payload: list, - timestamp: u64, + timestamp: u64, // ms since Unix epoch, UTC sender: option>, } variant event { - block(block-data), - logs(list), - timer(u64), - message(message-data), + block(block), + logs(list), + tick(tick), + message(message), } + /// Opaque config (typed variant deferred to 0.3). type config = list>; + + record host-error { + domain: string, + kind: host-error-kind, + code: s32, + message: string, + data: option, + } + + variant host-error-kind { + unsupported, unavailable, denied, rate-limited, + timeout, invalid-input, internal, + } } -// ... csn, identity, local-store, remote-store, msg, logging interfaces as above ... +// ... chain, identity, local-store, remote-store, messaging, logging interfaces as above ... -/// Headless module — automation, background processing. -/// No UI capabilities. Runs on any conforming host. -world headless-module { - import csn; +/// Event-driven module — automation, background processing. +/// No UI capabilities. Runs on any conforming host. Six imports in 0.2. +world event-module { + import chain; import identity; import local-store; import remote-store; - import msg; + import messaging; import logging; - export init: func(config: types.config) -> result<_, string>; - export on-event: func(event: types.event) -> result<_, string>; + export init: func(config: types.config) -> result<_, host-error>; + export on-event: func(event: types.event) -> result<_, host-error>; } ``` -A module compiled against `web3:runtime/headless-module` is the **maximally portable** artifact. It runs on server, mobile, and WebView hosts without modification. +A module compiled against `nexum:host/event-module` is the **maximally portable** artifact. In 0.2 it runs on the server reference runtime; mobile and WebView hosts are planned (see the status banner at the top of this doc). ## Layer 2: UI Interface @@ -496,7 +509,7 @@ Interactive modules export additional lifecycle hooks beyond `init` and `on-even ```wit /// Interactive module — has a UI presence. world app-module { - include headless-module; + include event-module; import ui; /// Called when the module's UI surface is first displayed. @@ -526,7 +539,7 @@ flowchart TD D --> E["Host calls on-interact(element, action, data)"] E --> F["Module processes interaction"] F --> G["module calls ui::render(target, new-content) to update UI"] - F --> H["module calls csn::request to read chain state"] + F --> H["module calls chain::request to read chain state"] F --> I["module calls local-store::set to persist"] G --> C ``` @@ -554,36 +567,25 @@ The host loads `index.html` into a WebView and injects the bridge JavaScript tha Domain-specific interfaces extend the universal layer for particular use cases. The pattern: ```wit -package shepherd:cow@0.1.0; - -interface cow { - use web3:runtime/types.{chain-id}; +package shepherd:cow@0.2.0; - record api-error { - status: u16, - message: string, - body: option, - } +interface cow-api { + use nexum:host/types.{chain-id, host-error}; request: func( chain-id: chain-id, method: string, path: string, body: option, - ) -> result; -} - -interface order { - use web3:runtime/types.{chain-id}; + ) -> result; - submit: func(chain-id: chain-id, order-data: list) - -> result; + submit-order: func(chain-id: chain-id, order-data: list) + -> result; } -world shepherd-module { - include web3:runtime/headless-module; - import cow; - import order; +world shepherd { + include nexum:host/event-module; + import cow-api; } ``` @@ -597,37 +599,40 @@ interface vault { /* ... */ } interface strategy { /* ... */ } world yield-module { - include web3:runtime/headless-module; + include nexum:host/event-module; import vault; import strategy; } ``` -The `include` mechanism ensures that any domain-specific module inherits the full universal interface set. A `shepherd-module` can call `csn::request`, `identity::sign`, `local-store::get`, `remote-store::upload`, `msg::publish`, and `logging::log` — plus the CoW-specific `cow::request` and `order::submit`. +The `include` mechanism ensures that any domain-specific module inherits the full universal interface set. A `shepherd` module can call `chain::request`, `identity::sign`, `local-store::get`, `remote-store::upload`, `messaging::publish`, and `logging::log` — plus the CoW-specific `cow-api::request` and `cow-api::submit-order`. ## Complete WIT Package Layout ``` wit/ -├── web3-runtime/ -│ ├── types.wit # chain-id, block-data, log-entry, message-data, event, config -│ ├── csn.wit # csn interface (consensus access) +├── nexum-host/ +│ ├── types.wit # chain-id, block, log, tick, message, event, config, host-error +│ ├── chain.wit # chain interface (consensus access + request-batch) │ ├── identity.wit # identity interface (key management, signing) │ ├── local-store.wit # local-store interface │ ├── remote-store.wit # remote-store interface (Swarm) -│ ├── msg.wit # msg interface (Waku) +│ ├── messaging.wit # messaging interface (Waku) │ ├── logging.wit # logging interface -│ ├── ui.wit # ui interface + host-capabilities -│ ├── headless-module.wit # headless-module world -│ └── app-module.wit # app-module world (includes ui) +│ ├── clock.wit # additive: clock (now-ms, monotonic-ns) +│ ├── random.wit # additive: random (CSPRNG fill) +│ ├── http.wit # additive: http (allowlisted fetch) +│ ├── ui.wit # ui interface + host-capabilities (planned hosts only) +│ ├── event-module.wit # event-module world (6 imports) +│ ├── query-module.wit # experimental: query-module world (no host impl in 0.2) +│ └── app-module.wit # app-module world (includes ui) — design only │ └── shepherd-cow/ - ├── cow.wit # cow interface - ├── order.wit # order interface - └── shepherd-module.wit # shepherd-module world (includes headless-module + cow + order) + ├── cow-api.wit # merged cow-api interface (request + submit-order) + └── shepherd.wit # shepherd world (includes event-module + cow-api) ``` -The `web3-runtime` package is domain-agnostic and reusable. The `shepherd-cow` package is the CoW Protocol extension. New domains add new packages without touching the universal layer. +The `nexum-host` package is domain-agnostic and reusable. The `shepherd-cow` package is the CoW Protocol extension. New domains add new packages without touching the universal layer. ## Platform Targets @@ -637,22 +642,21 @@ This is the current design (docs 01-07), adapted for the layered WIT. Shepherd i | Interface | Implementation | |-----------|---------------| -| `csn` | alloy provider with tower middleware (timeout, retry, rate-limit, fallback) | +| `chain` | alloy provider with tower middleware (timeout, retry, rate-limit, fallback) | | `identity` | Keystore file, AWS KMS, or HSM — operator-configured signing backend | | `local-store` | redb (per-module database file, ACID, MVCC, crash-safe) | | `remote-store` | Bee API (`http://localhost:1633`) — operator runs a Bee node | -| `msg` | Waku node (nwaku) via JSON-RPC or REST API | +| `messaging` | Waku node (nwaku) via JSON-RPC or REST API | | `logging` | `tracing` crate -> JSON structured logs | -| `cow` | reqwest HTTP client -> CoW Protocol API | -| `order` | CoW API order submission (permissionless) | +| `cow-api` | reqwest HTTP client -> CoW Protocol API (REST passthrough + typed `submit-order`) | | Event sources | `eth_subscribe` (blocks, logs), cron (Tokio interval), Waku relay (messages) | -| WASM engine | wasmtime 41.x (Component Model, fuel, epoch metering) | +| WASM engine | wasmtime 45.x (Component Model, fuel, epoch metering) | -The `local-store` interface is renamed from `state`. The `remote-store`, `identity`, and `msg` interfaces are new. Event sources gain a fourth type: `message` (Waku content topic subscriptions). Everything else is as designed. +### Mobile App (Flutter/Dart) — Planned -### Mobile App (Flutter/Dart) +> **Status:** No mobile host ships in 0.2. The design below is the target architecture for a future release (0.3+, conditional on a named design partner). It's retained because the WIT contract was shaped to make this implementation possible, and the `query-module` world in 0.2 is the experimental contract a mobile/wallet embedder would target. -A Flutter application embeds a WASM runtime and provides the universal interfaces via Dart implementations: +A Flutter application would embed a WASM runtime and provide the universal interfaces via Dart implementations: ```mermaid flowchart TD @@ -664,11 +668,11 @@ flowchart TD end subgraph HostAdapter["Host Adapter (Dart)"] - HA_CSN["csn -> HTTP client to RPC endpoint"] + HA_CHAIN["chain -> HTTP client to RPC endpoint"] HA_ID["identity -> device keychain (Keystore/Keychain) or wallet SDK"] HA_LS["local-store -> SQLite (sqflite)"] HA_RS["remote-store -> HTTP to Bee gateway"] - HA_MSG["msg -> libwaku via FFI"] + HA_MSG["messaging -> libwaku via FFI"] HA_LOG["logging -> platform logger"] end @@ -699,29 +703,31 @@ For full Component Model support (identical module binaries across server and mo - **Connectivity.** Mobile networks are intermittent. Host functions should handle offline gracefully (queue requests, retry on reconnect). - **Waku light client.** Mobile devices should use Waku's light push and filter protocols rather than full relay to minimise bandwidth and battery consumption. -### WebView (Browser Engine + Injected Host Functions) +### WebView (Browser Engine + Injected Host Functions) — Planned -A WebView host runs inside a native app (or standalone browser). The WASM module executes in the browser's native WASM engine. Host functions are injected via a JavaScript bridge. +> **Status:** No WebView host ships in 0.2. The architecture below describes a future target. The `jco`-based transpilation path is the strongest candidate, but it depends on Component Model browser support stabilising and on a concrete embedder design partner. + +A WebView host would run inside a native app (or standalone browser). The WASM module executes in the browser's native WASM engine. Host functions are injected via a JavaScript bridge. ```mermaid flowchart TD subgraph NativeApp["Native App Shell"] subgraph WebView["WebView"] - WASMModule["WASM Module (browser's WASM engine)\nCalls imported functions:\ncsn.request(...)\nidentity.sign(...)\nlocalStore.get(...)\nremoteStore.download(...)\nmsg.publish(...)\nlogging.log(...)"] + WASMModule["WASM Module (browser's WASM engine)\nCalls imported functions:\nchain.request(...)\nidentity.sign(...)\nlocalStore.get(...)\nremoteStore.download(...)\nmessaging.publish(...)\nlogging.log(...)"] subgraph JSBridge["JavaScript Bridge (injected)"] - JS["window.web3runtime = {\n csn: { request: (c, m, p) =>\n nativeBridge.call('csn', ...) },\n identity: { accounts: () =>\n nativeBridge.call('identity', ...) },\n localStore: { get: (k) =>\n nativeBridge.call('store', ..) },\n remoteStore: { download: (ref) =>\n nativeBridge.call('store', ..) },\n msg: { publish: (t, p) =>\n nativeBridge.call('msg', ...) },\n logging: { log: (l, m) =>\n console.log(${`[l] m`}) }\n}"] + JS["window.nexumRuntime = {\n chain: { request: (c, m, p) =>\n nativeBridge.call('chain', ...) },\n identity: { accounts: () =>\n nativeBridge.call('identity', ...) },\n localStore: { get: (k) =>\n nativeBridge.call('store', ..) },\n remoteStore: { download: (ref) =>\n nativeBridge.call('store', ..) },\n messaging: { publish: (t, p) =>\n nativeBridge.call('messaging', ...) },\n logging: { log: (l, m) =>\n console.log(${`[l] m`}) }\n}"] end WASMModule --> JSBridge end subgraph NativeHost["Native Host Adapter"] - NH_CSN["csn -> HTTP to RPC / wallet bridge"] + NH_CHAIN["chain -> HTTP to RPC / wallet bridge"] NH_ID["identity -> window.ethereum / native keychain"] NH_LS["local-store -> SQLite / IndexedDB"] NH_RS["remote-store -> HTTP to Bee gateway"] - NH_MSG["msg -> Waku node / js-waku"] + NH_MSG["messaging -> Waku node / js-waku"] NH_LOG["logging -> native logger"] end @@ -741,18 +747,18 @@ Approach 1 is preferred — it preserves the single-artifact property (one `.was **WebView-specific capability: `window.ethereum`** -In a browser context, the user may have a wallet extension (MetaMask, Rabby, etc.) that injects `window.ethereum`. The `csn::request` host function can optionally route through this: +In a browser context, the user may have a wallet extension (MetaMask, Rabby, etc.) that injects `window.ethereum`. The `chain::request` host function can optionally route through this: ```javascript // In the JS bridge -csn: { +chain: { request: async (chainId, method, params) => { if (window.ethereum && useWalletProvider) { // Route through user's wallet (gets signing capabilities too) return await window.ethereum.request({ method, params: JSON.parse(params) }); } else { // Route through native bridge to configured RPC endpoint - return await nativeBridge.call('csn', { chainId, method, params }); + return await nativeBridge.call('chain', { chainId, method, params }); } } } @@ -764,18 +770,20 @@ Similarly, the `identity` interface in a WebView context can delegate to `window **WebView-specific capability: `js-waku`** -For messaging in the browser, `js-waku` provides a pure JavaScript Waku client. The `msg` host function can route through `js-waku` directly in the WebView without needing the native bridge — peer-to-peer messaging from the browser. +For messaging in the browser, `js-waku` provides a pure JavaScript Waku client. The `messaging` host function can route through `js-waku` directly in the WebView without needing the native bridge — peer-to-peer messaging from the browser. + +### Decentralised Super App — Planned -### Decentralised Super App +> **Status:** The super app is the convergence of the mobile and WebView targets. No super-app host ships in 0.2. The content below describes the target architecture for a future release once mobile and WebView are live. -The super app is the convergence of all targets. A native shell (Flutter) that: +The super app is the convergence of all targets. A native shell (Flutter) that would: -1. **Discovers modules** via ENS (doc 03) — the same discovery mechanism as the server runtime. -2. **Fetches modules** from Swarm/IPFS — the same content-addressed distribution. -3. **Runs headless modules** in an embedded WASM runtime (automation, background tasks). -4. **Runs interactive modules** in WebViews (UI, dashboards, transaction builders). -5. **Provides the universal interfaces** to all modules (csn, identity, local-store, remote-store, msg, logging). -6. **Provides the UI interface** to interactive modules. +1. **Discover modules** via ENS (doc 03) — the same discovery mechanism as the server runtime. +2. **Fetch modules** from Swarm/IPFS — the same content-addressed distribution. +3. **Run event-driven modules** in an embedded WASM runtime (automation, background tasks). +4. **Run interactive modules** in WebViews (UI, dashboards, transaction builders). +5. **Provide the universal interfaces** to all modules (chain, identity, local-store, remote-store, messaging, logging). +6. **Provide the UI interface** to interactive modules. ```mermaid flowchart TD @@ -797,11 +805,11 @@ flowchart TD end subgraph HostLayer["Host Adapter Layer"] - HL_CSN["csn -> HTTP to RPC endpoints"] + HL_CHAIN["chain -> HTTP to RPC endpoints"] HL_ID["identity -> device keychain + per-module grants"] HL_LS["local-store -> SQLite"] HL_RS["remote-store -> Bee light node / gateway"] - HL_MSG["msg -> Waku light client"] + HL_MSG["messaging -> Waku light client"] HL_LOG["logging -> app logger + optional cloud"] HL_UI["ui -> WebView bridge (interactive modules)"] end @@ -838,19 +846,18 @@ The super app adds a capability-grant layer on top of the WIT world. When a modu ``` "TWAP Monitor" requests: - ✓ csn — read blockchain state (chains: 42161) + ✓ chain — read blockchain state (chains: 42161) ✓ identity — sign with your accounts ✓ local-store — store data on your device ✓ remote-store — read/write to Swarm network - ✓ msg — send/receive messages (topics: /nexum/1/twap-*) - ✗ ui — (not requested — headless module) - ✓ cow — interact with CoW Protocol API - ✓ order — submit orders to CoW Protocol + ✓ messaging — send/receive messages (topics: /nexum/1/twap-*) + ✗ ui — (not requested — event-driven module) + ✓ cow-api — interact with CoW Protocol API and submit orders [Allow] [Deny] ``` -The host only links interfaces the user has approved. A module that doesn't import `msg` structurally cannot publish messages — the same structural sandboxing property that the server runtime uses (doc 01). +The host only links interfaces the user has approved. A module that doesn't import `messaging` structurally cannot publish messages — the same structural sandboxing property that the server runtime uses (doc 01). ## Host Adapter Specification @@ -858,10 +865,12 @@ Any platform that wants to run modules must implement the **Host Adapter** — t ### Required Behaviours -**`csn::request`** (Consensus) +In 0.2 every host function returns `result`. The `host-error.kind` discriminant (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`) is normative — embedders MUST pick the most specific kind for each backend failure. See the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping table. + +**`chain::request` / `chain::request-batch`** (Chain) - MUST forward the JSON-RPC request to a provider for the given chain. - MUST return the JSON-encoded result (the `result` field from the JSON-RPC response). -- MUST return `json-rpc-error` for provider errors, method-not-found, and transport failures. +- MUST return `host-error` with `domain = "chain"` for provider errors, method-not-found, and transport failures. Use `kind: invalid-input` for method-not-found, `unavailable`/`timeout` for transport, `rate-limited` for 429s, `denied` for 401/403. - SHOULD enforce a method allowlist (configurable by the operator/user). - MAY apply middleware (timeout, retry, rate-limit, fallback) — this is platform-specific. @@ -869,7 +878,7 @@ Any platform that wants to run modules must implement the **Host Adapter** — t - `accounts` MUST return the list of available account identifiers (addresses) for the current host configuration. - `sign` MUST produce a valid cryptographic signature over the provided data using the specified account's private key. - `sign-typed-data` MUST produce a valid EIP-712 signature over the provided typed data structure. -- MUST return `identity-error` if the account is unknown, the user rejects the signing request, or the backend is unavailable. +- MUST return `host-error` with `domain = "identity"`. User rejection is `kind: denied`; unknown account is `kind: invalid-input`; backend offline is `kind: unavailable`. - MAY prompt the user for approval before signing (platform-dependent — e.g. wallet extension popup in WebView, biometric prompt on mobile). - SHOULD NOT expose private key material to the module. The module sends data in, gets a signature out. @@ -877,22 +886,22 @@ Any platform that wants to run modules must implement the **Host Adapter** — t - MUST provide per-module isolation (module A cannot read module B's state). - MUST persist across module restarts within the same host process/session. - SHOULD persist across host process restarts (platform-dependent). -- MAY enforce size quotas. If exceeded, `set` returns `Err` (not a trap). +- MAY enforce size quotas. If exceeded, `set` returns `host-error { domain: "store", kind: invalid-input }` (not a trap). - MAY provide transactional semantics. Modules SHOULD NOT rely on this across platforms. -**`remote-store::upload/download/feed-get/feed-set`** +**`remote-store::upload/download/read-feed/write-feed`** - MUST route to a Swarm-compatible node or gateway. - `upload` MUST return the 32-byte content reference of the stored data. -- `download` MUST return the raw bytes for a valid reference, or error for missing/unreachable content. -- `feed-set` signs with the host's identity. The owner is implicit. -- MAY return errors for unavailable connectivity (offline, no node configured). +- `download` MUST return the raw bytes for a valid reference, or `host-error` (`kind: unavailable`) for missing/unreachable content. +- `write-feed` signs with the host's identity. The owner is implicit. +- MAY return `host-error { kind: unavailable }` for offline / no-node-configured. -**`msg::publish/query`** +**`messaging::publish/query`** - MUST route `publish` to a Waku-compatible node. - `publish` MUST deliver the message to the content topic's relay network on a best-effort basis. - `query` SHOULD return historical messages if the host's Waku node supports the store protocol. -- `query` MAY return an empty list or error if store is unavailable. -- MAY apply rate limits to prevent message spam. +- `query` MAY return an empty list or `host-error { kind: unsupported }` if store is unavailable. +- MAY apply rate limits (returning `kind: rate-limited`) to prevent message spam. **`logging::log`** - MUST accept log calls without blocking or erroring. @@ -902,7 +911,7 @@ Any platform that wants to run modules must implement the **Host Adapter** — t **Event dispatch (`on-event`)** - MUST call `init(config)` exactly once before any `on-event` calls. - MUST call `on-event` for each subscribed event (per manifest). -- MUST support all four event types: `block`, `logs`, `timer`, `message`. +- MUST support all four event variants: `block`, `logs`, `tick`, `message`. - SHOULD guarantee in-order delivery within a single module. - MAY dispatch events concurrently across modules. - SHOULD handle panics/traps gracefully (restart module, not crash host). @@ -951,40 +960,34 @@ The SDK mirrors the WIT layering: ```mermaid graph TD subgraph ShepherdSDK["shepherd-sdk (Domain-specific: CoW Protocol)"] - COW_ITEMS["CowClient, order helpers,\n#[shepherd::module] macro\n(imports cow + order)"] + COW_ITEMS["Cow client,\n#[shepherd::module] macro\n(imports cow-api)"] end subgraph NexumSDK["nexum-sdk (Universal: any blockchain app)"] - NEXUM_ITEMS["HostTransport, provider(),\nTypedState, RemoteStore,\nMsgClient, Identity,\nlogging macros,\nerror types,\n#[nexum::module] macro\n(imports csn + identity\n+ local-store\n+ remote-store + msg\n+ logging)"] + NEXUM_ITEMS["HostTransport, provider(),\nTypedState, RemoteStore,\nMessaging, Signer,\nlogging macros,\nHostError / HostErrorKind,\n#[nexum::module] macro\n(imports chain + identity\n+ local-store\n+ remote-store + messaging\n+ logging)"] end ShepherdSDK -->|"extends"| NexumSDK ``` -- **`nexum-sdk`** — the universal Rust SDK for any module targeting `web3:runtime/headless-module`. Provides `HostTransport` (alloy `Transport` trait over `csn::request`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `MsgClient` (typed wrapper over `msg`), `Identity` (typed wrapper over `identity`), logging macros, error types. Any module author — CoW, DeFi, gaming, whatever — uses this. +- **`nexum-sdk`** — the universal Rust SDK for any module targeting `nexum:host/event-module`. Provides `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`), logging macros, `HostError`/`HostErrorKind`. Any module author — CoW, DeFi, gaming, whatever — uses this. -- **`shepherd-sdk`** — extends `nexum-sdk` with CoW-specific wrappers: `CowClient`, order submission helpers, the `#[shepherd::module]` proc macro (which generates `cow` and `order` imports in addition to the universals). +- **`shepherd-sdk`** — extends `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro (which generates the `cow-api` import in addition to the universals). A module author building a generic blockchain automation module depends only on `nexum-sdk`. A module author building a CoW Protocol module depends on `shepherd-sdk` (which re-exports `nexum-sdk`). For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnecessary — they use `wit-bindgen` directly against the WIT package for their target world. The WIT is the universal contract; the SDK is a Rust ergonomics layer on top. -## Migration from Current Design - -The changes from the current docs (01-07) are additive, not breaking: +## Migration from 0.1 -| Change | Impact | -|--------|--------| -| Rename `state` -> `local-store` | WIT interface rename. SDK wrapper updated. Module source uses `local_store::get()` instead of `state::get()`. | -| Rename `swarm` -> `remote-store` | Abstracts the storage backend. WIT interface and SDK wrapper renamed. Swarm is the initial implementation; the interface name is backend-agnostic. | -| Add `identity` interface | New WIT interface for key management and signing. Host gains new implementation requirement. `csn` uses `identity` internally for signing RPC methods. Modules that don't import it directly are unaffected. | -| Add `msg` interface | New WIT interface backed by Waku. Host gains new implementation requirement. Modules that don't import it are unaffected. | -| Add `message` event variant | Extends the `event` type with `message(message-data)`. Existing handlers for `block`, `logs`, `timer` are unaffected. | -| Add `ui` interface + `app-module` world | New WIT interface and world. Headless modules are unaffected. Only interactive modules import this. | -| Split WIT package: `web3:runtime` + `shepherd:cow` | Namespace change. The `shepherd-module` world now `include`s `headless-module` from `web3:runtime`. Module source is unchanged (bindgen generates the same Rust types). | -| Split SDK: `nexum-sdk` + `shepherd-sdk` | Crate restructure. `shepherd-sdk` depends on and re-exports `nexum-sdk`. Module authors using `nexum_sdk::prelude::*` see no change. | +For the full 0.1 → 0.2 rename and behaviour change list, see the [Migration Guide](migration/0.1-to-0.2.md). The main themes: -The Nexum server runtime requires three new host implementations: `identity` (keystore/KMS), `remote-store` (Bee API — already needed for content distribution), and `msg` (Waku node). Event sources gain a fourth type: `message` (Waku content topic subscriptions). +- WIT package `web3:runtime` → `nexum:host`; interfaces `csn` → `chain` and `msg` → `messaging`; worlds `headless-module` → `event-module` and `shepherd-module` → `shepherd`. +- CoW `cow` + `order` interfaces merged into `cow-api`. +- All host functions return the unified `host-error` (with `host-error-kind` discriminant) instead of five per-protocol error types. +- The `event-module` world imports the six primitives the docs always claimed (0.1's WIT was missing `identity` from the world definition). +- Manifest: `wasm = ...` → `component = ...`; `[[subscribe]]` → `[[subscription]]` with `kind` instead of `type`; new `[capabilities]` section drives optional/required imports; `[config]` values are now typed. +- Additive WIT: `clock`, `random`, `http`, `chain::request-batch`, and the experimental `query-module` world. ## Summary @@ -992,24 +995,25 @@ The Nexum server runtime requires three new host implementations: `identity` (ke | Primitive | Interface | Implementation | Persistence | Scope | |-----------|-----------|---------------|-------------|-------| -| Consensus | `csn` | JSON-RPC (eth_*) | Blockchain | Global (chain) | +| Chain | `chain` | JSON-RPC (eth_*) | Blockchain | Global (chain) | | Identity | `identity` | Keystore / KMS / HSM | Key material | Per-account | | Local Store | `local-store` | redb / SQLite / IndexedDB | Device-local | Per-module | | Remote Store | `remote-store` | Ethereum Swarm | Decentralised | Global (content-addressed) | -| Messaging | `msg` | Waku | Ephemeral | Topic-based pub/sub | +| Messaging | `messaging` | Waku | Ephemeral | Topic-based pub/sub | | Logging | `logging` | tracing / console | None | Diagnostic | ### Architecture | Concept | Scope | |---------|-------| -| `web3:runtime` WIT package | Universal — any blockchain app, any platform | -| `headless-module` world | Automation modules — server, mobile, background | -| `app-module` world | Interactive modules — WebView, super app | +| `nexum:host` WIT package | Universal — any blockchain app, any platform | +| `event-module` world (0.2, shipping) | Event-driven modules — server today, mobile/background planned | +| `query-module` world (0.2 experimental) | Request/response modules — WIT published, no host impl in 0.2 | +| `app-module` world | Interactive modules — design only; planned hosts | | `shepherd:cow` WIT package | CoW Protocol domain extension | -| `shepherd-module` world | CoW automation modules (includes headless-module + cow + order) | -| `nexum-sdk` crate | Universal Rust SDK (HostTransport, TypedState, RemoteStore, MsgClient, Identity) | -| `shepherd-sdk` crate | CoW Rust SDK (CowClient, order helpers, extends nexum-sdk) | +| `shepherd` world | CoW automation modules (includes event-module + cow-api) | +| `nexum-sdk` crate | Universal Rust SDK (HostTransport, TypedState, RemoteStore, Messaging, Signer, HostError) | +| `shepherd-sdk` crate | CoW Rust SDK (Cow, extends nexum-sdk) | | Content-addressed distribution | Platform-agnostic (Swarm/IPFS, ENS discovery, hash verification) | | Host Adapter | Platform-specific implementation of universal interfaces | diff --git a/docs/migration/0.1-to-0.2.md b/docs/migration/0.1-to-0.2.md new file mode 100644 index 0000000..8a036ab --- /dev/null +++ b/docs/migration/0.1-to-0.2.md @@ -0,0 +1,541 @@ +# Migrating from Nexum 0.1 to 0.2 + +Nexum 0.2 is a single coordinated breaking-change release. It does the renames, the error-model unification, the missing primitives, and the capability-negotiation work in one window so module authors only pay the migration tax once. There will not be another breaking release of comparable scope before 1.0. + +This guide is written for two audiences: + +- **Module authors** — you write WASM components that import the Nexum WIT. +- **Host embedders** — you build the runtime that loads modules (the server daemon, a mobile wallet, a browser host). + +Each section is tagged `[author]`, `[embedder]`, or `[both]`. + +--- + +## TL;DR — what changed [both] + +| Area | 0.1 | 0.2 | +|---|---|---| +| WIT package | `web3:runtime` | `nexum:host` | +| Consensus interface | `csn` | `chain` | +| Messaging interface | `msg` | `messaging` | +| Default world | `headless-module` | `event-module` | +| CoW world | `shepherd:cow/shepherd-module` | `shepherd:cow/shepherd` | +| CoW interfaces | `cow` + `order` | `cow-api` (merged) | +| Feed methods | `feed-get` / `feed-set` | `read-feed` / `write-feed` | +| Event variants | `block-data` / `log-entry` / `message-data` / `timer(u64)` | `block` / `log` / `message` / `tick { fired-at }` | +| Errors | 5 different shapes + bare `string` | single `host-error` with `host-error-kind` discriminant | +| Capabilities | All six imports mandatory | Manifest-negotiated, optional imports trap on call | +| Engine crate | `nxm-engine` | `nexum-engine` | +| Manifest file | `nexum.toml` (some docs said `shepherd.toml`) | `nexum.toml` (canonical) | +| Manifest field | `wasm = "sha256:..."` | `component = "sha256:..."` | +| Manifest section | `[[subscribe]]` | `[[subscription]]` | +| Config type | `list>` (stringified) | unchanged in 0.2; typed variant on the 0.3 roadmap | +| New capabilities | — | `clock`, `random`, `http` (allowlisted) | +| New RPC method | — | `chain::request-batch` (additive) | +| New world | — | `query-module` (experimental, no host impl shipped) | + +If you only do four things: update your `nexum.toml`, run the sed cheat-sheet at the bottom, replace your error handling with the new `host-error` taxonomy, and declare your capabilities explicitly. Everything else is mechanical. + +--- + +## 1. WIT renames [author] + +### Package rename + +```diff +- use web3:runtime/types.{config, event}; +- use web3:runtime/chain.{chain-id}; ++ use nexum:host/types.{config, event}; ++ use nexum:host/chain.{chain-id}; +``` + +Why: `web3:` precommitted the engine to crypto-only branding. The package is now named after the engine; web3-specific capabilities live inside it as interfaces. + +### Interface renames + +| 0.1 | 0.2 | Rationale | +|---|---|---| +| `csn` | `chain` | `csn` was unreadable; `chain.request(chainId, method, params)` reads itself. | +| `msg` | `messaging` | `msg` collided with its own `message` record; ambiguous in non-Rust bindings. | +| `cow` + `order` | `cow-api` (one interface) | `cow::cow::request` triple-stutter eliminated; `order::submit` merged as `cow-api::submit-order`. | + +### World renames + +```diff +- world headless-module { ++ world event-module { + import chain; + import identity; // NOTE: was missing from 0.1 WIT; now present + import local-store; + import remote-store; + import messaging; + import logging; + export init: func(config: config) -> result<_, string>; + export on-event: func(event: event) -> result<_, string>; + } +``` + +```diff +- world shepherd-module { +- include headless-module; +- import cow; +- import order; ++ world shepherd { ++ include event-module; ++ import cow-api; + } +``` + +### Function renames (verb-first, fully spelled) + +```diff + interface remote-store { +- feed-get: func(owner: list, topic: list) -> result>, store-error>; +- feed-set: func(topic: list, data: list) -> result, store-error>; ++ read-feed: func(owner: list, topic: list) -> result>, host-error>; ++ write-feed: func(topic: list, data: list) -> result, host-error>; + } +``` + +### Type and field renames + +```diff + interface types { +- record block-data { ... } +- record log-entry { ..., tx-hash: list, ... } +- record message-data { ... } +- variant event { +- block(block-data), +- logs(list), +- timer(u64), +- message(message-data), +- } ++ record block { ... } ++ record log { ..., transaction-hash: list, ... } ++ record message { ... } ++ record tick { fired-at: u64 } // milliseconds since Unix epoch, UTC ++ variant event { ++ block(block), ++ logs(list), ++ tick(tick), ++ message(message), ++ } + } +``` + +Two semantic notes: + +- All `u64` timestamps in 0.2 are **milliseconds since Unix epoch, UTC**. The 0.1 WIT did not specify a unit and several sources used seconds. Audit any timestamp arithmetic you do. +- `tick` (formerly `timer`) is now a record, not a bare `u64`. In bindings it reads `event.tick.firedAt` instead of `event.timer === 1700000000`. + +--- + +## 2. Error model unification [both] + +The five 0.1 error shapes (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) plus bare `string` errors collapse to one record: + +```wit +interface types { + record host-error { + domain: string, // "chain" | "store" | "messaging" | "identity" | "cow" | ... + kind: host-error-kind, // normative discriminant — see below + code: s32, // domain-specific + message: string, + data: option, // JSON for richer context + } + + variant host-error-kind { + unsupported, // host does not implement this capability + unavailable, // capability exists, backend is down/offline + denied, // user or policy rejected + rate-limited, + timeout, + invalid-input, + internal, // host bug + } +} +``` + +### Author migration + +```diff +- match chain::request(1, "eth_call", params) { +- Ok(s) => parse(s), +- Err(JsonRpcError { code, message, .. }) if code == -32000 => retry(), +- Err(e) => bail!("rpc failed: {}", e.message), +- } ++ use nexum_sdk::error::{HostError, HostErrorKind}; ++ match chain::request(1, "eth_call", params) { ++ Ok(s) => parse(s), ++ Err(HostError { kind: HostErrorKind::Unavailable, .. }) => retry(), ++ Err(HostError { kind: HostErrorKind::RateLimited, .. }) => backoff(), ++ Err(HostError { kind: HostErrorKind::Denied, .. }) => abort("user rejected"), ++ Err(e) => bail!("{}::{} ({}): {}", e.domain, e.code, e.kind, e.message), ++ } +``` + +`local-store` errors are no longer bare `string`s. The same `host-error` shape applies — `domain: "store"`, `kind` indicates whether you hit a quota, the key doesn't exist (for write-conditional ops), etc. + +Module export signatures also change: + +```diff +- export init: func(config: config) -> result<_, string>; +- export on-event: func(event: event) -> result<_, string>; ++ export init: func(config: config) -> result<_, host-error>; ++ export on-event: func(event: event) -> result<_, host-error>; +``` + +For module errors, set `domain` to your module name and pick the closest `kind`. The SDK provides `HostError::module(name, kind, message)` to make this ergonomic. + +### Embedder migration + +Hosts implementing capability traits must now return `HostError`, not protocol-specific error types. Map each backend failure to the right `kind`: + +| Backend signal | `host-error-kind` | +|---|---| +| Connection refused / DNS fail / offline | `unavailable` | +| Provider HTTP 4xx (other than 401/403/429) | `invalid-input` | +| Provider HTTP 401/403 | `denied` | +| Provider HTTP 429 | `rate-limited` | +| Provider HTTP 5xx / timeout | `unavailable` or `timeout` (prefer the more specific) | +| User rejected signing in wallet UI | `denied` | +| Module asked for a capability the host doesn't provide | `unsupported` | +| Bug / panic / internal invariant violated | `internal` | + +--- + +## 3. Manifest changes [both] + +### File rename + +If any code, docs, or scripts reference `shepherd.toml`, change to `nexum.toml`. This was a doc/code inconsistency in 0.1; canonical is `nexum.toml`. + +### Field and section renames + +```diff + [module] + name = "twap-monitor" + version = "0.3.0" +- wasm = "sha256:9f86d081..." ++ component = "sha256:9f86d081..." + + [module.resources] + max_memory_bytes = 10_485_760 + max_fuel_per_event = 100_000 + max_state_bytes = 52_428_800 + + [chains] + required = [42161] + +- [[subscribe]] +- type = "block" +- chain_id = 42161 ++ [[subscription]] ++ kind = "block" ++ chain_id = 42161 +``` + +`type` → `kind` because `type` is reserved in several binding languages. + +### Capability declaration (new, required) + +In 0.1 the world declared which interfaces a module imported, and instantiation failed if any were unsatisfied. In 0.2, imports declared `optional` in the manifest install a trap stub on the host side — calling them returns `host-error { kind: unsupported }` rather than failing instantiation. + +```toml +[capabilities] +required = ["chain", "local-store", "logging"] +optional = ["messaging", "remote-store"] # module continues if host doesn't provide +denied = [] # explicit "do not grant even if available" + +[capabilities.http] +allow = ["api.coingecko.com", "discord.com"] + +[capabilities.identity] +methods = ["sign-typed-data"] # subset of identity surface used +``` + +If you omit `[capabilities]` entirely, 0.2 falls back to "all imports required" — same as 0.1 behaviour — and prints a deprecation warning at load. Add the section in your next module update; the implicit-all fallback will be removed in 0.3. + +### Config: unchanged in 0.2 + +`[config]` values continue to flow through to the guest as `list>` — the host flattens TOML scalars (numbers, booleans) to their string form on the way through, same as 0.1. If you currently parse `"50"` into `u64`, that code continues to work unchanged: + +```rust +let bps: u64 = config.iter() + .find(|(k, _)| k == "slippage_bps") + .map(|(_, v)| v.parse()) + .transpose()? + .unwrap_or(50); +``` + +**Deferred to 0.3.** A typed `config-value` variant (string / integer / boolean / list) and a `#[derive(NexumConfig)]` helper are on the 0.3 roadmap, bundled with the manifest-parser work (see §3) so the typing story lands as one coherent feature. + +--- + +## 4. New capabilities (additive) [author] + +These didn't exist in 0.1 and don't break anything. Adopt them to remove workarounds. + +### `clock` + +```wit +interface clock { + now-ms: func() -> u64; // wall-clock ms since Unix epoch, UTC + monotonic-ns: func() -> u64; // for measuring elapsed +} +``` + +Replaces the 0.1 workaround of "only know the time inside `on_block` via `block.timestamp`." + +### `random` + +```wit +interface random { + fill: func(len: u32) -> list; +} +``` + +CSPRNG. Replaces the 0.1 workaround of "you can't, period." + +### `http` (allowlisted) + +```wit +interface http { + use types.{host-error}; + + /// A single HTTP header. Header names are case-insensitive on the wire; + /// the host normalises them. + record header { + name: string, + value: string, + } + + record request { + method: string, + url: string, + headers: list
, + body: option>, + /// Optional per-request timeout in milliseconds. The host MAY clamp + /// this to its own configured maximum. + timeout-ms: option, + } + + record response { + status: u16, + headers: list
, + body: list, + } + + /// Perform a single HTTP request. Transport-level failures (DNS, TLS, + /// timeout, host policy rejection) surface as `host-error`; HTTP-level + /// non-2xx responses are returned as `ok(response)` so the caller can + /// inspect headers and body. + fetch: func(req: request) -> result; +} +``` + +Requires a domain allowlist in `nexum.toml`: + +```toml +[capabilities.http] +allow = ["api.coingecko.com", "*.discord.com"] +``` + +Hosts MUST enforce the allowlist (exact host match or `*.domain` suffix). The operator sees the union of granted domains at module load. This replaces the 0.1 anti-pattern of tunnelling alerts through Waku. + +### `chain::request-batch` + +```wit +interface chain { + use types.{chain-id, host-error}; + + /// A single JSON-RPC request to be executed as part of a batch. + record rpc-request { + method: string, + params: string, + } + + /// Result of a single request inside a batch. Each entry is independent; + /// one failing call does not abort the others. + variant rpc-result { + ok(string), + err(host-error), + } + + request: func(chain-id: chain-id, method: string, params: string) + -> result; + + /// Hosts that cannot batch natively MUST fall back to sequential + /// `request` calls; the returned list is the same length as `requests` + /// and in the same order. + request-batch: func(chain-id: chain-id, requests: list) + -> result, host-error>; +} +``` + +Additive. The alloy-backed `HostTransport` now routes `RequestPacket::Batch` through `request-batch` — your existing `provider.multicall(...).await` actually batches on the wire in 0.2 (it didn't in 0.1, despite the docs). + +--- + +## 5. New world: `query-module` (experimental) [author] + +A request/response world for modules that aren't event-driven (wallet rule evaluators, signature validators, pricing oracles). + +```wit +world query-module { + import local-store; + import logging; + // chain, identity, http, etc. are optional via manifest + + export init: func(config: config) -> result<_, host-error>; + export evaluate: func(input: list) -> result, host-error>; +} +``` + +**Status: WIT is published, no host implementation ships in 0.2.** The 0.2 server runtime only supports `event-module` and `shepherd`. The world is published so module authors can target it experimentally and so embedders building mobile/wallet hosts have a stable contract to implement against. Production support lands in 0.3. + +If you're writing a module that fits this shape, target it now and stub the host with `MockHost` for testing. + +--- + +## 6. Engine crate rename [embedder] + +```diff + [dependencies] +- nxm-engine = "0.1" ++ nexum-engine = "0.2" +``` + +The 0.1 release renamed `nexum-host` → `nxm-engine`. 0.2 reverses that to `nexum-engine` for consistency with `nexum-sdk`, `shepherd-sdk`, `cargo-nexum`. + +```diff +- use nxm_engine::{Engine, Module}; ++ use nexum_engine::{Engine, Module}; +``` + +The Rust API surface is otherwise unchanged in 0.2. The C ABI and `nexum-host` embedder facade (for non-Rust hosts) are explicitly **deferred to a later release** pending mobile validation; do not assume they exist in 0.2. + +--- + +## 7. SDK changes [author] + +### Rust SDK + +```diff +- use nexum_sdk::{provider, Identity, MsgClient, RemoteStore}; ++ use nexum_sdk::{provider, Signer, Messaging, RemoteStore}; +``` + +| 0.1 type | 0.2 type | Notes | +|---|---|---| +| `IdentityClient` | `Signer` | Trait renamed to reflect what it does | +| `MsgClient` | `Messaging` | Drops the meaningless `Client` suffix | +| `CowClient` | `Cow` | Same | +| `HostTransport` | (internal) | Now `pub(crate)`; you access it through `provider()` | +| `block_on` (re-export) | (removed from public API) | Hidden behind the `#[nexum::module]` macro | +| `Error` (multiple variants per domain) | `HostError` + `HostErrorKind` | Single shape; see §2 | + +### Proc macro + +`#[nexum::module]` and `#[shepherd::module]` are unchanged in shape. They now generate against `event-module` / `shepherd` worlds. If you targeted `headless-module` explicitly anywhere, rename to `event-module`. + +### Non-Rust SDKs + +The WIT renames propagate mechanically through `wit-bindgen`. Regenerate your bindings against the 0.2 WIT and your existing call sites — adjusted for the renames in §1 — will type-check. + +--- + +## 8. Mechanical rename cheat sheet [both] + +For mechanical search/replace in your codebase. Apply in order; some replacements depend on earlier ones. + +```bash +# WIT package +rg -l 'web3:runtime' | xargs sed -i 's/web3:runtime/nexum:host/g' + +# Interface names (do these before function names — some functions reference the old interface in paths) +rg -l '\bcsn\b' | xargs sed -i 's/\bcsn\b/chain/g' +rg -l '\bmsg\b' | xargs sed -i 's/\bmsg\b/messaging/g' + +# Worlds +rg -l 'headless-module' | xargs sed -i 's/headless-module/event-module/g' +rg -l 'headless_module' | xargs sed -i 's/headless_module/event_module/g' + +# CoW interface stutter +rg -l '\bcow::cow::' | xargs sed -i 's/\bcow::cow::/cow_api::/g' +# (manual: merge `order` imports into `cow-api`; rename `order::submit` to `cow-api::submit-order`) + +# Feed methods +rg -l '\bfeed-get\b' | xargs sed -i 's/\bfeed-get\b/read-feed/g' +rg -l '\bfeed-set\b' | xargs sed -i 's/\bfeed-set\b/write-feed/g' +rg -l '\bfeed_get\b' | xargs sed -i 's/\bfeed_get\b/read_feed/g' +rg -l '\bfeed_set\b' | xargs sed -i 's/\bfeed_set\b/write_feed/g' + +# Type renames +rg -l '\bblock-data\b' | xargs sed -i 's/\bblock-data\b/block/g' +rg -l '\blog-entry\b' | xargs sed -i 's/\blog-entry\b/log/g' +rg -l '\bmessage-data\b' | xargs sed -i 's/\bmessage-data\b/message/g' +rg -l '\btx-hash\b' | xargs sed -i 's/\btx-hash\b/transaction-hash/g' +rg -l '\btx_hash\b' | xargs sed -i 's/\btx_hash\b/transaction_hash/g' + +# Crate rename (Cargo.toml + use statements) +rg -l '\bnxm-engine\b' | xargs sed -i 's/\bnxm-engine\b/nexum-engine/g' +rg -l '\bnxm_engine\b' | xargs sed -i 's/\bnxm_engine\b/nexum_engine/g' + +# Manifest section +rg -l '\[\[subscribe\]\]' | xargs sed -i 's/\[\[subscribe\]\]/[[subscription]]/g' + +# Manifest field +rg -l '^wasm = ' | xargs sed -i 's/^wasm = /component = /' +``` + +Things that **cannot** be sedded — do these by hand: + +- `timer(u64)` → `tick(tick)` with the new `tick { fired-at: u64 }` record. Call sites that pattern-match `Event::Timer(ts)` become `Event::Tick(tick) => tick.fired_at`. +- Error handling. The five old error types are gone; you can't mechanically rewrite a `match` against `JsonRpcError { code, .. }` into the new `HostError { kind, .. }` discriminant. Do these per-call-site. +- Splitting `cow` + `order` into a single `cow-api`. Rewrite the imports and adjust function paths. +- Adding `[capabilities]` to `nexum.toml`. Declare what your module actually uses; this is a meaningful audit. + +--- + +## 9. Verification checklist [both] + +After running the renames: + +- [ ] `cargo check --workspace --all-targets` is clean (Rust + bindings). +- [ ] `cargo check --target wasm32-wasip2 -p ` is clean. +- [ ] `cargo test --workspace --no-fail-fast` passes. +- [ ] Your bindgen invocations point at the package's own WIT dir (`wit/nexum-host/`) — or, when consuming both `nexum:host` and a domain-extension package, list both paths explicitly. The 0.1 vendored `deps/` pattern is no longer used in the reference repo. +- [ ] `nexum.toml` has a `[capabilities]` section listing what the module uses. +- [ ] `nexum.toml` references `component = "sha256:..."` not `wasm = ...`. +- [ ] All `[[subscribe]]` sections renamed to `[[subscription]]` with `kind` (not `type`). +- [ ] No remaining references to `web3:runtime`, `csn`, `msg`, `headless-module`, `nxm-engine`, `shepherd.toml`, `feed-get`/`feed-set`, `block-data`/`log-entry`/`message-data`, `tx-hash`. +- [ ] All `Result<_, String>` from module exports replaced with `Result<_, HostError>`. +- [ ] Error matching code uses `HostErrorKind` discriminant, not protocol-specific error codes. +- [ ] If you used `chrono`/timestamp arithmetic, audited for the seconds-vs-ms change (0.2 is always ms UTC). +- [ ] If you used `provider.multicall(...).await`, confirmed it now actually batches on the wire (`chain::request-batch` shows in tracing). + +> **No `cargo nexum` toolchain in 0.2.** A `cargo-nexum` cargo subcommand (with `new`, `check`, `package`, `run --mock`, `migrate`) is on the 0.3 roadmap. Until then, use `cargo` directly and the `just` recipes in the reference repo. + +--- + +## 10. Deprecation policy going forward [both] + +0.2 is the breaking-change window. The contracts below are stable starting at 0.2.0: + +- WIT package name `nexum:host` and interface names within it. +- The `host-error` / `host-error-kind` shape. +- The `nexum.toml` manifest schema. +- The `#[nexum::module]` macro surface. + +Additive changes (new interfaces, new manifest fields, new SDK helpers) may land in any 0.2.x release. Existing identifiers will not be removed or repurposed before 1.0 without a deprecation cycle of at least one minor release. + +The mobile/wallet host story (`query-module` production support, C ABI, `nexum-host` embedder crate) is on the 0.3 roadmap, conditional on a named design partner. The 0.2 `query-module` WIT is an experimental option, not a stable contract; expect changes to its error variants and request/response payload conventions before the 0.3 host ships. + +--- + +## 11. Getting help + +- Open an issue at the repo with the `migration-0.2` label. +- The full 0.2 WIT lives in `wit/nexum-host/` (formerly `wit/web3-runtime/`). +- The §8 cheat sheet has the mechanical sed commands; a `cargo nexum migrate --from 0.1` codemod that wraps them safely is planned for 0.3 alongside the rest of the `cargo-nexum` toolchain. diff --git a/justfile b/justfile index 2f7f569..3e01212 100644 --- a/justfile +++ b/justfile @@ -1,24 +1,21 @@ -# Sync WIT deps (copies web3-runtime into shepherd-cow/deps) -sync-wit: - rm -rf wit/shepherd-cow/deps/web3-runtime - cp -r wit/web3-runtime wit/shepherd-cow/deps/web3-runtime - -# Build the host runtime -build-runtime: sync-wit - cargo build -p nxm-engine +# Build the host engine +build-engine: + cargo build -p nexum-engine # Build the example WASM module build-module: cargo build --target wasm32-wasip2 --release -p example # Build everything -build: build-runtime build-module +build: build-engine build-module -# Build the module then run the runtime with it -run: build-module build-runtime - cargo run -p nxm-engine -- target/wasm32-wasip2/release/example.wasm +# 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 +# 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 # Check the entire workspace -check: sync-wit +check: cargo check --target wasm32-wasip2 -p example - cargo check -p nxm-engine + cargo check -p nexum-engine diff --git a/modules/example/nexum.toml b/modules/example/nexum.toml new file mode 100644 index 0000000..e17a547 --- /dev/null +++ b/modules/example/nexum.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" diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 80b18a1..a008a3d 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -3,17 +3,17 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../wit/web3-runtime", - world: "headless-module", + path: "../../wit/nexum-host", + world: "nexum:host/event-module", }); -use web3::runtime::logging; -use web3::runtime::types; +use nexum::host::logging; +use nexum::host::types; struct ExampleModule; impl Guest for ExampleModule { - fn init(config: Vec<(String, String)>) -> Result<(), String> { + fn init(config: Vec<(String, String)>) -> Result<(), HostError> { let name = config .iter() .find(|(k, _)| k == "name") @@ -26,13 +26,13 @@ impl Guest for ExampleModule { Ok(()) } - fn on_event(event: types::Event) -> Result<(), String> { + fn on_event(event: types::Event) -> Result<(), HostError> { match &event { types::Event::Block(block) => { logging::log( logging::Level::Info, &format!( - "block {} on chain {} (ts={})", + "block {} on chain {} (ts={}ms)", block.number, block.chain_id, block.timestamp ), ); @@ -43,8 +43,11 @@ impl Guest for ExampleModule { &format!("received {} log entries", logs.len()), ); } - types::Event::Timer(ts) => { - logging::log(logging::Level::Info, &format!("timer fired at {ts}")); + types::Event::Tick(tick) => { + logging::log( + logging::Level::Info, + &format!("tick fired at {}ms", tick.fired_at), + ); } types::Event::Message(msg) => { logging::log( diff --git a/wit/nexum-host/chain.wit b/wit/nexum-host/chain.wit new file mode 100644 index 0000000..574368b --- /dev/null +++ b/wit/nexum-host/chain.wit @@ -0,0 +1,37 @@ +package nexum:host@0.2.0; + +interface chain { + use types.{chain-id, host-error}; + + /// A single JSON-RPC request to be executed as part of a batch. + record rpc-request { + method: string, + params: string, + } + + /// Result of a single request inside a batch. Each entry is independent; + /// one failing call does not abort the others. + variant rpc-result { + ok(string), + err(host-error), + } + + /// Execute a JSON-RPC request against the specified chain. + /// + /// The host routes to its configured provider for the given chain, + /// applying whatever middleware is appropriate for the platform + /// (timeout, retry, rate-limit, fallback on server; simple HTTP + /// on mobile; window.ethereum or injected provider in WebView). + /// + /// `method` includes the namespace prefix (e.g. "eth_call"). + /// `params` and the success value are JSON-encoded strings. + request: func(chain-id: chain-id, method: string, params: string) + -> result; + + /// Execute several JSON-RPC requests against the same chain in a single + /// round trip where the host transport supports it. Hosts that cannot + /// batch natively MUST fall back to sequential `request` calls. The + /// returned list is the same length as `requests` and in the same order. + request-batch: func(chain-id: chain-id, requests: list) + -> result, host-error>; +} diff --git a/wit/nexum-host/clock.wit b/wit/nexum-host/clock.wit new file mode 100644 index 0000000..b57408a --- /dev/null +++ b/wit/nexum-host/clock.wit @@ -0,0 +1,13 @@ +package nexum:host@0.2.0; + +/// Host-provided clock. Guest modules MUST use this rather than relying on +/// WASI clocks so the host can virtualise time during replay/testing. +interface clock { + /// Wall-clock time in milliseconds since the Unix epoch, UTC. + now-ms: func() -> u64; + + /// Monotonic timer in nanoseconds. The origin is unspecified; only + /// differences between successive calls are meaningful. Suitable for + /// measuring elapsed time without exposure to wall-clock jumps. + monotonic-ns: func() -> u64; +} diff --git a/wit/nexum-host/event-module.wit b/wit/nexum-host/event-module.wit new file mode 100644 index 0000000..30a1e99 --- /dev/null +++ b/wit/nexum-host/event-module.wit @@ -0,0 +1,24 @@ +package nexum:host@0.2.0; + +/// Event-driven module — automation, background processing. +/// No UI capabilities. Runs on any conforming host. +world event-module { + use types.{config, event, host-error}; + + // Six core primitives (always provided by a conforming host). + import chain; + import identity; + import local-store; + import remote-store; + import messaging; + import logging; + + // Ambient host services (additive in 0.2). `http` is gated per-module by + // the `[capabilities.http].allow` allowlist in nexum.toml. + import clock; + import random; + import http; + + export init: func(config: config) -> result<_, host-error>; + export on-event: func(event: event) -> result<_, host-error>; +} diff --git a/wit/nexum-host/http.wit b/wit/nexum-host/http.wit new file mode 100644 index 0000000..0e12fd4 --- /dev/null +++ b/wit/nexum-host/http.wit @@ -0,0 +1,39 @@ +package nexum:host@0.2.0; + +/// Generic HTTP client capability. Modules that need this MUST opt in via +/// their manifest; the host enforces an allow-list of destinations. +interface http { + use types.{host-error}; + + /// A single HTTP header. Header names are case-insensitive on the wire; + /// the host normalises them. + record header { + name: string, + value: string, + } + + record request { + /// HTTP method, e.g. "GET", "POST". + method: string, + /// Absolute URL (scheme + host + path + query). + url: string, + headers: list
, + /// Optional request body. Empty for methods like GET. + body: option>, + /// Optional per-request timeout in milliseconds. The host MAY clamp + /// this to its own configured maximum. + timeout-ms: option, + } + + record response { + status: u16, + headers: list
, + body: list, + } + + /// Perform a single HTTP request. Transport-level failures (DNS, TLS, + /// timeout, host policy rejection) surface as `host-error`; HTTP-level + /// non-2xx responses are returned as an `ok(response)` with the status + /// set accordingly so the caller can inspect headers/body. + fetch: func(req: request) -> result; +} diff --git a/wit/nexum-host/identity.wit b/wit/nexum-host/identity.wit new file mode 100644 index 0000000..6d62f7f --- /dev/null +++ b/wit/nexum-host/identity.wit @@ -0,0 +1,24 @@ +package nexum:host@0.2.0; + +/// Identity / signing capability. +/// +/// 0.2 ships a single, minimal interface. A future release (0.4+) is +/// expected to split this into `identity-read` and `identity-sign` and to +/// introduce a richer `signing-result` variant; for 0.2 the simple shape is +/// sufficient because user rejection can already be expressed via +/// `host-error { kind: denied, .. }`. +interface identity { + use types.{host-error}; + + /// Return the list of account addresses (20-byte EVM addresses) the host + /// is willing to sign for. Empty list means no signing capability. + accounts: func() -> result>, host-error>; + + /// Sign an arbitrary message with personal_sign semantics (prepends the + /// "\x19Ethereum Signed Message:\n" prefix). Returns a 65-byte signature. + sign: func(account: list, message: list) -> result, host-error>; + + /// Sign EIP-712 typed data. `typed-data` is a JSON-encoded EIP-712 payload. + /// Returns a 65-byte signature. + sign-typed-data: func(account: list, typed-data: string) -> result, host-error>; +} diff --git a/wit/web3-runtime/local-store.wit b/wit/nexum-host/local-store.wit similarity index 52% rename from wit/web3-runtime/local-store.wit rename to wit/nexum-host/local-store.wit index 29fda92..546dc0b 100644 --- a/wit/web3-runtime/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -1,16 +1,18 @@ -package web3:runtime@0.1.0; +package nexum:host@0.2.0; interface local-store { + use types.{host-error}; + /// Get a value by key. Returns none if the key does not exist. - get: func(key: string) -> result>, string>; + get: func(key: string) -> result>, host-error>; /// Set a key-value pair. Overwrites any existing value. /// The host may enforce a size quota; if exceeded, returns err. - set: func(key: string, value: list) -> result<_, string>; + set: func(key: string, value: list) -> result<_, host-error>; /// Delete a key. No-op if the key does not exist. - delete: func(key: string) -> result<_, string>; + delete: func(key: string) -> result<_, host-error>; /// List all keys matching a prefix. Empty prefix returns all keys. - list-keys: func(prefix: string) -> result, string>; + list-keys: func(prefix: string) -> result, host-error>; } diff --git a/wit/web3-runtime/logging.wit b/wit/nexum-host/logging.wit similarity index 90% rename from wit/web3-runtime/logging.wit rename to wit/nexum-host/logging.wit index a3b6d1a..37e9193 100644 --- a/wit/web3-runtime/logging.wit +++ b/wit/nexum-host/logging.wit @@ -1,4 +1,4 @@ -package web3:runtime@0.1.0; +package nexum:host@0.2.0; interface logging { enum level { diff --git a/wit/nexum-host/messaging.wit b/wit/nexum-host/messaging.wit new file mode 100644 index 0000000..0334834 --- /dev/null +++ b/wit/nexum-host/messaging.wit @@ -0,0 +1,19 @@ +package nexum:host@0.2.0; + +interface messaging { + use types.{host-error, message}; + + /// Publish a message to a content topic. + /// + /// Content topics follow the format: //// + /// e.g. "/nexum/1/twap-updates/proto" + publish: func(content-topic: string, payload: list) -> result<_, host-error>; + + /// Query historical messages from the Waku store protocol. + query: func( + content-topic: string, + start-time: option, + end-time: option, + limit: option, + ) -> result, host-error>; +} diff --git a/wit/nexum-host/query-module.wit b/wit/nexum-host/query-module.wit new file mode 100644 index 0000000..f261a8b --- /dev/null +++ b/wit/nexum-host/query-module.wit @@ -0,0 +1,25 @@ +package nexum:host@0.2.0; + +/// Query module — synchronous, side-effect-free evaluation. +/// +/// EXPERIMENTAL (0.2): the shape of this world is provisional and may +/// change in a future minor release without a major bump. Hosts and SDKs +/// should expect breakage here until the world is stabilised. +/// +/// A query module exposes a single pure `evaluate` entry point. It is given +/// read-only access to the local store (for cached/derived state) and to +/// logging; everything else (chain access, network, messaging, signing) is +/// deliberately excluded so the host can run queries inside a tight +/// deterministic sandbox. +world query-module { + use types.{config, host-error}; + + import local-store; + import logging; + + export init: func(config: config) -> result<_, host-error>; + + /// Evaluate the query. `input` and the returned bytes are opaque to the + /// host; the module and its caller agree on the encoding. + export evaluate: func(input: list) -> result, host-error>; +} diff --git a/wit/nexum-host/random.wit b/wit/nexum-host/random.wit new file mode 100644 index 0000000..e37a67a --- /dev/null +++ b/wit/nexum-host/random.wit @@ -0,0 +1,7 @@ +package nexum:host@0.2.0; + +/// Cryptographically secure randomness from the host. +interface random { + /// Return `len` bytes of cryptographically secure random data. + fill: func(len: u32) -> list; +} diff --git a/wit/shepherd-cow/deps/web3-runtime/remote-store.wit b/wit/nexum-host/remote-store.wit similarity index 67% rename from wit/shepherd-cow/deps/web3-runtime/remote-store.wit rename to wit/nexum-host/remote-store.wit index ab788cf..f68e32f 100644 --- a/wit/shepherd-cow/deps/web3-runtime/remote-store.wit +++ b/wit/nexum-host/remote-store.wit @@ -1,27 +1,24 @@ -package web3:runtime@0.1.0; +package nexum:host@0.2.0; interface remote-store { - record store-error { - code: u16, - message: string, - } + use types.{host-error}; /// Upload raw data to the decentralised store. /// Returns the 32-byte content reference (Swarm address). - upload: func(data: list) -> result, store-error>; + upload: func(data: list) -> result, host-error>; /// Download raw data by 32-byte content reference. - download: func(reference: list) -> result, store-error>; + download: func(reference: list) -> result, host-error>; /// Read the latest value from a mutable feed. /// /// Feeds are mutable pointers: (owner, topic) -> latest chunk. /// `owner`: 20-byte Ethereum address of the feed owner. /// `topic`: 32-byte topic hash. - feed-get: func( + read-feed: func( owner: list, topic: list, - ) -> result>, store-error>; + ) -> result>, host-error>; /// Update a mutable feed with new data. /// @@ -29,8 +26,8 @@ interface remote-store { /// `topic`: 32-byte topic hash. /// `data`: the payload to publish. /// Returns the 32-byte reference of the new chunk. - feed-set: func( + write-feed: func( topic: list, data: list, - ) -> result, store-error>; + ) -> result, host-error>; } diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit new file mode 100644 index 0000000..6a6def1 --- /dev/null +++ b/wit/nexum-host/types.wit @@ -0,0 +1,83 @@ +package nexum:host@0.2.0; + +/// Common types shared across all runtime interfaces. +/// +/// All `u64` timestamps in this package are milliseconds since the Unix +/// epoch, UTC, unless otherwise noted (e.g. `clock::monotonic-ns` is +/// nanoseconds from an arbitrary monotonic origin). +interface types { + type chain-id = u64; + + record block { + chain-id: chain-id, + number: u64, + hash: list, + timestamp: u64, + } + + record log { + chain-id: chain-id, + address: list, + topics: list>, + data: list, + block-number: u64, + transaction-hash: list, + log-index: u32, + } + + /// A message delivered over the messaging interface. Defined here (rather + /// than only in `messaging.wit`) so the `event` variant can reference it + /// without a cross-interface use clause. + record message { + content-topic: string, + payload: list, + timestamp: u64, + /// Optional sender identity (protocol-dependent). + sender: option>, + } + + /// Fired by the host on a configured cadence. `fired-at` is the host's + /// wall-clock time (ms since Unix epoch, UTC) at which the tick was + /// generated. + record tick { + fired-at: u64, + } + + variant event { + block(block), + logs(list), + tick(tick), + message(message), + } + + /// Opaque config from nexum.toml [config] section. + type config = list>; + + /// Coarse categorisation of host-side failures. The kind is suitable for + /// programmatic dispatch by guests; `message` carries a human-readable + /// detail and `code` carries a domain-specific numeric (e.g. a JSON-RPC + /// error code, HTTP status, etc.). + variant host-error-kind { + unsupported, + unavailable, + denied, + rate-limited, + timeout, + invalid-input, + internal, + } + + /// Unified error returned by every host-imported function. + /// + /// `domain` is a short identifier for the originating subsystem + /// (e.g. "chain", "local-store", "remote-store", "messaging", + /// "identity", "http"). `data` is an optional opaque payload (often a + /// JSON-encoded blob). + record host-error { + domain: string, + kind: host-error-kind, + code: s32, + message: string, + data: option, + } +} diff --git a/wit/shepherd-cow/cow.wit b/wit/shepherd-cow/cow-api.wit similarity index 57% rename from wit/shepherd-cow/cow.wit rename to wit/shepherd-cow/cow-api.wit index 7b9fdd6..0787d01 100644 --- a/wit/shepherd-cow/cow.wit +++ b/wit/shepherd-cow/cow-api.wit @@ -1,13 +1,7 @@ -package shepherd:cow@0.1.0; +package shepherd:cow@0.2.0; -interface cow { - use web3:runtime/types@0.1.0.{chain-id}; - - record api-error { - status: u16, - message: string, - body: option, - } +interface cow-api { + use nexum:host/types@0.2.0.{chain-id, host-error}; /// HTTP-style request to the CoW Protocol API. /// @@ -24,5 +18,12 @@ interface cow { method: string, path: string, body: option, - ) -> result; + ) -> result; + + /// Submit an order to the CoW Protocol. + /// + /// `order-data`: the serialised order payload. + /// Returns the order UID on success. + submit-order: func(chain-id: chain-id, order-data: list) + -> result; } diff --git a/wit/shepherd-cow/deps/web3-runtime/csn.wit b/wit/shepherd-cow/deps/web3-runtime/csn.wit deleted file mode 100644 index f6b7b2f..0000000 --- a/wit/shepherd-cow/deps/web3-runtime/csn.wit +++ /dev/null @@ -1,23 +0,0 @@ -package web3:runtime@0.1.0; - -interface csn { - use types.{chain-id}; - - record json-rpc-error { - code: s64, - message: string, - data: option, - } - - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host routes to its configured provider for the given chain, - /// applying whatever middleware is appropriate for the platform - /// (timeout, retry, rate-limit, fallback on server; simple HTTP - /// on mobile; window.ethereum or injected provider in WebView). - /// - /// `method` includes the namespace prefix (e.g. "eth_call"). - /// `params` and the success value are JSON-encoded strings. - request: func(chain-id: chain-id, method: string, params: string) - -> result; -} diff --git a/wit/shepherd-cow/deps/web3-runtime/headless-module.wit b/wit/shepherd-cow/deps/web3-runtime/headless-module.wit deleted file mode 100644 index 196c410..0000000 --- a/wit/shepherd-cow/deps/web3-runtime/headless-module.wit +++ /dev/null @@ -1,16 +0,0 @@ -package web3:runtime@0.1.0; - -/// Headless module — automation, background processing. -/// No UI capabilities. Runs on any conforming host. -world headless-module { - use types.{config, event}; - - import csn; - import local-store; - import remote-store; - import msg; - import logging; - - export init: func(config: config) -> result<_, string>; - export on-event: func(event: event) -> result<_, string>; -} diff --git a/wit/shepherd-cow/deps/web3-runtime/local-store.wit b/wit/shepherd-cow/deps/web3-runtime/local-store.wit deleted file mode 100644 index 29fda92..0000000 --- a/wit/shepherd-cow/deps/web3-runtime/local-store.wit +++ /dev/null @@ -1,16 +0,0 @@ -package web3:runtime@0.1.0; - -interface local-store { - /// Get a value by key. Returns none if the key does not exist. - get: func(key: string) -> result>, string>; - - /// Set a key-value pair. Overwrites any existing value. - /// The host may enforce a size quota; if exceeded, returns err. - set: func(key: string, value: list) -> result<_, string>; - - /// Delete a key. No-op if the key does not exist. - delete: func(key: string) -> result<_, string>; - - /// List all keys matching a prefix. Empty prefix returns all keys. - list-keys: func(prefix: string) -> result, string>; -} diff --git a/wit/shepherd-cow/deps/web3-runtime/logging.wit b/wit/shepherd-cow/deps/web3-runtime/logging.wit deleted file mode 100644 index a3b6d1a..0000000 --- a/wit/shepherd-cow/deps/web3-runtime/logging.wit +++ /dev/null @@ -1,15 +0,0 @@ -package web3:runtime@0.1.0; - -interface logging { - enum level { - trace, - debug, - info, - warn, - error, - } - - /// Emit a structured log message. - /// The host decides how to handle it (stdout, file, discard). - log: func(level: level, message: string); -} diff --git a/wit/shepherd-cow/deps/web3-runtime/msg.wit b/wit/shepherd-cow/deps/web3-runtime/msg.wit deleted file mode 100644 index a222da2..0000000 --- a/wit/shepherd-cow/deps/web3-runtime/msg.wit +++ /dev/null @@ -1,30 +0,0 @@ -package web3:runtime@0.1.0; - -interface msg { - record msg-error { - code: u16, - message: string, - } - - record message { - content-topic: string, - payload: list, - timestamp: u64, - /// Optional sender identity (protocol-dependent). - sender: option>, - } - - /// Publish a message to a content topic. - /// - /// Content topics follow the format: //// - /// e.g. "/shepherd/1/twap-updates/proto" - publish: func(content-topic: string, payload: list) -> result<_, msg-error>; - - /// Query historical messages from the Waku store protocol. - query: func( - content-topic: string, - start-time: option, - end-time: option, - limit: option, - ) -> result, msg-error>; -} diff --git a/wit/shepherd-cow/deps/web3-runtime/types.wit b/wit/shepherd-cow/deps/web3-runtime/types.wit deleted file mode 100644 index 64c0608..0000000 --- a/wit/shepherd-cow/deps/web3-runtime/types.wit +++ /dev/null @@ -1,39 +0,0 @@ -package web3:runtime@0.1.0; - -interface types { - type chain-id = u64; - - record block-data { - chain-id: chain-id, - number: u64, - hash: list, - timestamp: u64, - } - - record log-entry { - chain-id: chain-id, - address: list, - topics: list>, - data: list, - block-number: u64, - tx-hash: list, - log-index: u32, - } - - record message-data { - content-topic: string, - payload: list, - timestamp: u64, - sender: option>, - } - - variant event { - block(block-data), - logs(list), - timer(u64), - message(message-data), - } - - /// Opaque config from shepherd.toml [config] section. - type config = list>; -} diff --git a/wit/shepherd-cow/order.wit b/wit/shepherd-cow/order.wit deleted file mode 100644 index 52e4429..0000000 --- a/wit/shepherd-cow/order.wit +++ /dev/null @@ -1,12 +0,0 @@ -package shepherd:cow@0.1.0; - -interface order { - use web3:runtime/types@0.1.0.{chain-id}; - - /// Submit an order to the CoW Protocol. - /// - /// `order-data`: the serialised order payload. - /// Returns the order UID on success. - submit: func(chain-id: chain-id, order-data: list) - -> result; -} diff --git a/wit/shepherd-cow/shepherd-module.wit b/wit/shepherd-cow/shepherd-module.wit deleted file mode 100644 index dbc2ca0..0000000 --- a/wit/shepherd-cow/shepherd-module.wit +++ /dev/null @@ -1,8 +0,0 @@ -package shepherd:cow@0.1.0; - -/// Shepherd module — headless module with CoW Protocol extensions. -world shepherd-module { - include web3:runtime/headless-module@0.1.0; - import cow; - import order; -} diff --git a/wit/shepherd-cow/shepherd.wit b/wit/shepherd-cow/shepherd.wit new file mode 100644 index 0000000..88aff14 --- /dev/null +++ b/wit/shepherd-cow/shepherd.wit @@ -0,0 +1,7 @@ +package shepherd:cow@0.2.0; + +/// Shepherd module — event-driven Nexum module with CoW Protocol extensions. +world shepherd { + include nexum:host/event-module@0.2.0; + import cow-api; +} diff --git a/wit/web3-runtime/csn.wit b/wit/web3-runtime/csn.wit deleted file mode 100644 index f6b7b2f..0000000 --- a/wit/web3-runtime/csn.wit +++ /dev/null @@ -1,23 +0,0 @@ -package web3:runtime@0.1.0; - -interface csn { - use types.{chain-id}; - - record json-rpc-error { - code: s64, - message: string, - data: option, - } - - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host routes to its configured provider for the given chain, - /// applying whatever middleware is appropriate for the platform - /// (timeout, retry, rate-limit, fallback on server; simple HTTP - /// on mobile; window.ethereum or injected provider in WebView). - /// - /// `method` includes the namespace prefix (e.g. "eth_call"). - /// `params` and the success value are JSON-encoded strings. - request: func(chain-id: chain-id, method: string, params: string) - -> result; -} diff --git a/wit/web3-runtime/headless-module.wit b/wit/web3-runtime/headless-module.wit deleted file mode 100644 index 196c410..0000000 --- a/wit/web3-runtime/headless-module.wit +++ /dev/null @@ -1,16 +0,0 @@ -package web3:runtime@0.1.0; - -/// Headless module — automation, background processing. -/// No UI capabilities. Runs on any conforming host. -world headless-module { - use types.{config, event}; - - import csn; - import local-store; - import remote-store; - import msg; - import logging; - - export init: func(config: config) -> result<_, string>; - export on-event: func(event: event) -> result<_, string>; -} diff --git a/wit/web3-runtime/msg.wit b/wit/web3-runtime/msg.wit deleted file mode 100644 index a222da2..0000000 --- a/wit/web3-runtime/msg.wit +++ /dev/null @@ -1,30 +0,0 @@ -package web3:runtime@0.1.0; - -interface msg { - record msg-error { - code: u16, - message: string, - } - - record message { - content-topic: string, - payload: list, - timestamp: u64, - /// Optional sender identity (protocol-dependent). - sender: option>, - } - - /// Publish a message to a content topic. - /// - /// Content topics follow the format: //// - /// e.g. "/shepherd/1/twap-updates/proto" - publish: func(content-topic: string, payload: list) -> result<_, msg-error>; - - /// Query historical messages from the Waku store protocol. - query: func( - content-topic: string, - start-time: option, - end-time: option, - limit: option, - ) -> result, msg-error>; -} diff --git a/wit/web3-runtime/remote-store.wit b/wit/web3-runtime/remote-store.wit deleted file mode 100644 index ab788cf..0000000 --- a/wit/web3-runtime/remote-store.wit +++ /dev/null @@ -1,36 +0,0 @@ -package web3:runtime@0.1.0; - -interface remote-store { - record store-error { - code: u16, - message: string, - } - - /// Upload raw data to the decentralised store. - /// Returns the 32-byte content reference (Swarm address). - upload: func(data: list) -> result, store-error>; - - /// Download raw data by 32-byte content reference. - download: func(reference: list) -> result, store-error>; - - /// Read the latest value from a mutable feed. - /// - /// Feeds are mutable pointers: (owner, topic) -> latest chunk. - /// `owner`: 20-byte Ethereum address of the feed owner. - /// `topic`: 32-byte topic hash. - feed-get: func( - owner: list, - topic: list, - ) -> result>, store-error>; - - /// Update a mutable feed with new data. - /// - /// The host signs the feed update with its configured identity. - /// `topic`: 32-byte topic hash. - /// `data`: the payload to publish. - /// Returns the 32-byte reference of the new chunk. - feed-set: func( - topic: list, - data: list, - ) -> result, store-error>; -} diff --git a/wit/web3-runtime/types.wit b/wit/web3-runtime/types.wit deleted file mode 100644 index 64c0608..0000000 --- a/wit/web3-runtime/types.wit +++ /dev/null @@ -1,39 +0,0 @@ -package web3:runtime@0.1.0; - -interface types { - type chain-id = u64; - - record block-data { - chain-id: chain-id, - number: u64, - hash: list, - timestamp: u64, - } - - record log-entry { - chain-id: chain-id, - address: list, - topics: list>, - data: list, - block-number: u64, - tx-hash: list, - log-index: u32, - } - - record message-data { - content-topic: string, - payload: list, - timestamp: u64, - sender: option>, - } - - variant event { - block(block-data), - logs(list), - timer(u64), - message(message-data), - } - - /// Opaque config from shepherd.toml [config] section. - type config = list>; -}