Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ Thumbs.db
# Environment
.env
.env.*
data/
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,71 @@ just build

# Run the runtime against the example module
just run

# Run unit tests
just test
```

Without Nix, you need: Rust (edition 2024, see `rust-toolchain.toml` if present), the `wasm32-wasip2` target, and `wasm-tools`.

## Running

### Single-module (development)

```sh
nexum-engine <path-to-component.wasm> [<module.toml>]
```

The `module.toml` is optional; without it the engine prints a deprecation warning and loads the module with empty capabilities and config (0.1 fallback).

### Multi-module (production)

```sh
nexum-engine --engine-config engine.toml
```

`engine.toml` declares RPC endpoints, the state directory, and a `[[modules]]` list:

```toml
[engine]
state_dir = "/var/lib/shepherd"
log_level = "info"

[chains.1]
rpc_url = "wss://mainnet.infura.io/ws/v3/..."

[[modules]]
path = "modules/twap-monitor/twap-monitor.wasm"
manifest = "modules/twap-monitor/module.toml"

[[modules]]
path = "modules/ethflow-watcher/ethflow-watcher.wasm"
```

### Module manifest (`module.toml`)

```toml
[module]
name = "twap-monitor"
version = "0.1.0"

[capabilities]
required = ["chain", "local-store", "cow-api"]
optional = ["http"]

[capabilities.http]
allow = ["api.cow.fi"]

[[subscription]]
kind = "log"
chain_id = 1
address = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" # ComposableCoW

[[subscription]]
kind = "block"
chain_id = 1
```

## Documentation

The `docs/` directory contains the design corpus:
Expand Down
51 changes: 50 additions & 1 deletion crates/nexum-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,59 @@ license.workspace = true
repository.workspace = true

[dependencies]
# WASM Component Model runtime.
wasmtime = { version = "45", features = ["component-model"] }
wasmtime-wasi = "45"

# Async + error plumbing.
anyhow = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
getrandom = "0.4"

# CLI.
clap = { version = "4", features = ["derive"] }

# Manifest parsing.
serde = { version = "1", features = ["derive"] }
toml = "1"
serde_json = "1"

# Observability. `tracing` replaces the prior `eprintln!` debug log
# so the engine can drop into a structured log pipeline in production.
tracing = "0.1"
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi"] }

# `cow-api` backend. cowprotocol pulls `OrderBookApi`, `OrderCreation`,
# `OrderUid`, the orderbook base URL table per `Chain`, and the typed
# error surface the host re-projects into `HostError`. Pinned to the
# crates.io release Shepherd is shipping against.
cowprotocol = "1.0.0-alpha"
# REST passthrough for `cow_api::request`. cowprotocol pulls reqwest
# transitively for its own client; we depend on it directly so the
# import is explicit and survives any future cowprotocol feature
# rearrangement.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

# `chain` backend. Each configured chain owns a `DynProvider` built
# from a `WsConnect`/`Http` transport so the host's `request` /
# `request-batch` impls can hand a raw `(method, params)` pair to
# alloy's JSON-RPC layer without reimplementing the codec.
alloy-provider = { version = "1.5", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] }
alloy-rpc-client = { version = "1.5", default-features = false }
alloy-rpc-types-eth = { version = "1.5", default-features = false, features = ["std"] }
alloy-transport = { version = "1.5", default-features = false }
alloy-transport-ws = { version = "1.5", default-features = false }
alloy-primitives = { version = "1.5", default-features = false, features = ["std", "serde"] }
futures = "0.3"

# `local-store` backend. Per-module namespacing is enforced
# host-side via a `[len:u8][module_name][raw_key]` prefix.
redb = "2"

# Misc.
getrandom = "0.4"
url = "2"

[dev-dependencies]
tempfile = "3"
wiremock = "0.6"
16 changes: 16 additions & 0 deletions crates/nexum-engine/src/bindings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//! WIT bindings generated by `wasmtime::component::bindgen!`.
//!
//! Both `wit/nexum-host` and `wit/shepherd-cow` packages are listed
//! explicitly so wit-parser can resolve the cross-package reference
//! natively - no vendored `deps/` tree needed. The world name is fully
//! qualified.
//!
//! Every `Host` trait impl in [`crate::host::impls`] consumes types
//! generated here.

wasmtime::component::bindgen!({
path: ["../../wit/nexum-host", "../../wit/shepherd-cow"],
world: "shepherd:cow/shepherd",
imports: { default: async },
exports: { default: async },
});
32 changes: 32 additions & 0 deletions crates/nexum-engine/src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! CLI surface, parsed via `clap`'s derive API.

use std::path::PathBuf;

use clap::Parser;

/// `nexum-engine` argument parser.
///
/// Production deployments pass `--engine-config <path>` and declare
/// modules in TOML. The positional `<wasm-path>` (with optional
/// `<manifest-path>`) is a backwards-compat shortcut that synthesises
/// a one-module engine config so the historical
/// `cargo run -- ./modules/example/example.wasm` flow keeps working.
#[derive(Debug, Parser)]
#[command(
name = "nexum-engine",
about = "Multi-module supervisor for shepherd WASM components."
)]
pub struct Cli {
/// Positional WASM component to boot when `--engine-config` is
/// not supplied (or when its `[[modules]]` list is empty).
pub wasm: Option<PathBuf>,

/// Optional manifest (`module.toml`) sibling for the positional
/// `<wasm-path>`. Ignored when `--engine-config` is supplied.
pub manifest: Option<PathBuf>,

/// Path to the `engine.toml` describing chains + modules. When
/// omitted, falls back to the in-repo default (single-module mode).
#[arg(long, value_name = "PATH")]
pub engine_config: Option<PathBuf>,
}
119 changes: 119 additions & 0 deletions crates/nexum-engine/src/engine_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! Engine-side runtime configuration.
//!
//! Distinct from `module.toml` (module manifest): this file describes
//! the *engine*'s I/O wiring - chain RPC endpoints and the on-disk
//! location of the `local-store` database. Both are required for the
//! 0.2 reference engine to do anything other than print stubs.
//!
//! Lookup order:
//!
//! 1. `--engine-config <path>` CLI flag (future), or third positional
//! argument today;
//! 2. `engine.toml` in the current working directory;
//! 3. defaults - no chains configured, `state_dir = ./data`.
//!
//! A missing config is OK for the example module (it only logs); for
//! the cow-api / chain backends it surfaces as `HostError {
//! kind: unsupported }` so guests learn early.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use tracing::{info, warn};

/// Engine-side configuration loaded from `engine.toml`.
#[derive(Debug, Default, Deserialize)]
pub struct EngineConfig {
#[serde(default)]
pub engine: EngineSection,
/// Per-chain RPC URLs keyed by EVM chain id (decimal in TOML).
/// Used by the `chain::request` host call and as the alloy provider
/// pool seed.
#[serde(default)]
pub chains: BTreeMap<u64, ChainConfig>,
/// Modules the supervisor should boot. Each entry resolves a
/// `(component.wasm, module.toml)` pair on the local filesystem
/// for 0.2 - content-addressed resolution (Swarm / OCI /
/// `[[content.sources]]`) lands in 0.3 per
/// `docs/03-module-discovery.md`.
#[serde(default)]
pub modules: Vec<ModuleEntry>,
}

/// One `[[modules]]` table from `engine.toml`.
///
/// Both fields are filesystem paths in 0.2. `manifest` defaults to
/// `module.toml` next to `path` if omitted, matching the bundle layout
/// in `docs/02-modules-events-packaging.md`.
#[derive(Debug, Deserialize)]
pub struct ModuleEntry {
/// Path to the compiled `.wasm` component.
pub path: std::path::PathBuf,
/// Path to the module's `module.toml`. Defaults to `<path-parent>/module.toml`.
#[serde(default)]
pub manifest: Option<std::path::PathBuf>,
}

#[derive(Debug, Deserialize)]
pub struct EngineSection {
#[serde(default = "default_state_dir")]
pub state_dir: PathBuf,
/// `tracing_subscriber::EnvFilter`-compatible directive. Defaults to
/// `info` when absent; `RUST_LOG` overrides at process start.
#[serde(default = "default_log_level")]
pub log_level: String,
}

impl Default for EngineSection {
fn default() -> Self {
Self {
state_dir: default_state_dir(),
log_level: default_log_level(),
}
}
}

#[derive(Debug, Deserialize)]
pub struct ChainConfig {
/// JSON-RPC endpoint. `ws://` and `wss://` engage alloy's pubsub
/// transport (required for `eth_subscribe`); `http://` and `https://`
/// engage the HTTP transport (request/response only).
pub rpc_url: String,
}

fn default_state_dir() -> PathBuf {
PathBuf::from("./data")
}

fn default_log_level() -> String {
"info".to_owned()
}

/// Read an engine config from disk, returning defaults if the file is
/// missing. Parse errors propagate.
pub fn load_or_default(path: Option<&Path>) -> anyhow::Result<EngineConfig> {
let path = match path {
Some(p) => p.to_path_buf(),
None => PathBuf::from("engine.toml"),
};

if !path.exists() {
warn!(
path = %path.display(),
"engine.toml not found - running with defaults (no chain RPC endpoints; \
chain::request and cow_api::submit_order will return Unsupported)"
);
return Ok(EngineConfig::default());
}

let raw = std::fs::read_to_string(&path)?;
let cfg: EngineConfig = toml::from_str(&raw)?;
info!(
path = %path.display(),
chains = cfg.chains.len(),
state_dir = %cfg.engine.state_dir.display(),
"engine config loaded",
);
Ok(cfg)
}
Loading