Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/
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"

# 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`.
#
# This is the canonical `cowprotocol` crate from `cowdao-grants/cow-rs`
# (homepage / repository fields on crates.io point there). The workspace
# `[patch.crates-io]` redirects it to the head of cow-rs PR #5 per
# ADR-0004; see the comment there for the bump policy. Pinned to the
# 1.0.0-alpha line — the published release Shepherd ships against.
cowprotocol = "1.0.0-alpha"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be very careful about this as I'm not sure what is being pulled in just to make sure it pulls in the correct one as I know that another grantee published to crates as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified: cowprotocol@1.0.0-alpha.3 on crates.io points at cowdao-grants/cow-rs (homepage + repository fields). Comment now cross-references ADR-0004. If you remember the conflicting package name, happy to add a deny entry. Changed on c83a042

# 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.8", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] }
alloy-rpc-client = { version = "1.8", default-features = false }
alloy-transport = { version = "1.8", default-features = false }
alloy-transport-ws = { version = "1.8", default-features = false }
alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] }

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

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

[dev-dependencies]
tempfile = "3"
wiremock = "0.6"
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 },
});
155 changes: 155 additions & 0 deletions crates/nexum-engine/src/engine_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! 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
Comment on lines +1 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There needs to be a gas / fuel parameter so wasmtime can limit greedy wasm modules

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replied here: #8 (comment)

/// pool seed.
#[serde(default)]
pub chains: BTreeMap<u64, ChainConfig>,
}

#[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,
/// Resource caps applied to every module store at instantiation.
/// `wasmtime` traps a module that overruns either; the operator
/// tunes the budget based on their hardware and the modules they
/// load.
#[serde(default)]
pub limits: ModuleLimits,
}

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

/// Per-module resource caps the supervisor applies to each store.
///
/// `wasmtime` exposes two complementary knobs:
///
/// - **Fuel** is decremented per executed instruction (`Config::consume_fuel
/// (true)`). When the store runs out, the module traps with `OutOfFuel`.
/// The budget is reset before every `on_event` invocation so a single
/// greedy event cannot starve the next one.
/// - **Memory size** caps the module's linear memory growth, applied via
/// `StoreLimitsBuilder::memory_size`. A module that requests more linear
/// memory than this gets a `MemoryOutOfBounds`-class trap from
/// `memory.grow`.
///
/// Both are `Option<u64>` so an unset value falls through to the engine's
/// built-in defaults; operators only need to write the keys they actually
/// want to override. `main.rs` reads these at instantiation; the multi-
/// module supervisor (BLEU-818) consumes the same accessors per module
/// store.
#[derive(Clone, Copy, Debug, Default, Deserialize)]
pub struct ModuleLimits {
/// Fuel granted before every `on_event` call. `None` -> engine default.
#[serde(default)]
pub fuel_per_event: Option<u64>,
/// Linear-memory ceiling per module (bytes). `None` -> engine default.
#[serde(default)]
pub memory_bytes: Option<u64>,
}

impl ModuleLimits {
/// Default fuel granted per `on_event` invocation (≈ 1 billion WASM
/// instructions). Modules that exceed this budget trap with
/// `OutOfFuel`.
pub const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000;

/// Default linear-memory cap per module (64 MiB). Prevents a single
/// runaway module from exhausting process memory.
pub const DEFAULT_MEMORY_BYTES: u64 = 64 * 1024 * 1024;

/// Resolved fuel budget (config override, falling back to the default).
pub fn fuel(&self) -> u64 {
self.fuel_per_event.unwrap_or(Self::DEFAULT_FUEL_PER_EVENT)
}

/// Resolved memory cap in bytes (config override, falling back to the
/// default).
pub fn memory(&self) -> u64 {
self.memory_bytes.unwrap_or(Self::DEFAULT_MEMORY_BYTES)
}
}

#[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)
}
141 changes: 141 additions & 0 deletions crates/nexum-engine/src/host/cow_orderbook.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//! `shepherd:cow/cow-api` backend.
//!
//! Two responsibilities:
//!
//! 1. `request` - generic REST passthrough. Module gives the HTTP
//! method, path (relative to the chain's orderbook base URL), and
//! optional JSON body. We dispatch via `reqwest`, return the
//! response body verbatim.
//! 2. `submit_order` - typed submission. Module gives a JSON-encoded
//! `cowprotocol::OrderCreation`; we parse, dispatch via
//! `cowprotocol::OrderBookApi::post_order`, return the assigned
//! `OrderUid` as a `0x`-prefixed hex string.
//!
//! Per-chain `OrderBookApi` instances are constructed once at engine
//! boot from the discriminated chain set in `cowprotocol::Chain`.
//! Chains the SDK does not know about return `Unsupported` at the
//! host call boundary.

use std::collections::BTreeMap;

use cowprotocol::{Chain, OrderBookApi, OrderCreation, OrderUid};
use thiserror::Error;

/// Process-wide pool of `OrderBookApi` clients keyed by EVM chain id.
#[derive(Debug, Clone)]
pub struct OrderBookPool {
clients: BTreeMap<u64, OrderBookApi>,
http: reqwest::Client,
}

impl Default for OrderBookPool {
/// Build a pool covering every `cowprotocol::Chain` variant. Each entry
/// uses the canonical `api.cow.fi/{slug}/api/v1` base URL from the SDK.
/// Override individual entries via `OrderBookApi::new_with_base_url` for
/// barn or staging targets.
fn default() -> Self {
let http = reqwest::Client::new();
let chains = [
Chain::Mainnet,
Chain::Gnosis,
Chain::Sepolia,
Chain::ArbitrumOne,
Chain::Base,
];
let clients = chains
.iter()
.map(|c| (c.id(), OrderBookApi::new(*c)))
.collect();
Self { clients, http }
}
}

impl OrderBookPool {
/// Look up the client for a chain.
pub fn get(&self, chain_id: u64) -> Result<&OrderBookApi, CowApiError> {
self.clients
.get(&chain_id)
.ok_or(CowApiError::UnknownChain(chain_id))
}

/// REST passthrough. The base URL is whichever URL the pool's
/// `OrderBookApi` client carries - overrides set via
/// `OrderBookApi::new_with_base_url` (staging, wiremock) flow
/// through here too, which keeps the passthrough and the typed
/// `submit_order_json` path aimed at the same orderbook.
pub async fn request(
&self,
chain_id: u64,
method: &str,
path: &str,
body: Option<&str>,
) -> Result<String, CowApiError> {
let api = self.get(chain_id)?;
let base = api.base_url().clone();
// `path` may or may not lead with a slash; `Url::join` handles
// both, but we strip a single leading `/` so consumers can
// write either `/orders/...` or `orders/...` interchangeably.
let trimmed = path.strip_prefix('/').unwrap_or(path);
let url = base
.join(trimmed)
.map_err(|e| CowApiError::BadPath(format!("{path:?}: {e}")))?;

let request = match method.to_ascii_uppercase().as_str() {
"GET" => self.http.get(url),
"POST" => self.http.post(url),
"PUT" => self.http.put(url),
"DELETE" => self.http.delete(url),
other => return Err(CowApiError::BadMethod(other.to_owned())),
};
let request = if let Some(body) = body {
request
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.to_owned())
} else {
request
};

let response = request.send().await.map_err(CowApiError::Network)?;
// Surface the orderbook's structured 4xx / 5xx bodies verbatim
// so the guest can decode `{"errorType": "...", "description":
// "..."}` - projecting them into HostError here loses the
// detail the guest needs to recover.
let text = response.text().await.map_err(CowApiError::Network)?;
Ok(text)
}

/// Typed submission. `body` is the JSON encoding of
/// `cowprotocol::OrderCreation`. The chain's orderbook validates
/// `from`, the EIP-712 hash, and (if `Eip1271`) the contract
/// signature; we return whatever UID it assigns.
pub async fn submit_order_json(
&self,
chain_id: u64,
body: &[u8],
) -> Result<OrderUid, CowApiError> {
let creation: OrderCreation = serde_json::from_slice(body).map_err(CowApiError::Decode)?;
let api = self.get(chain_id)?;
let uid = api.post_order(&creation).await?;
Ok(uid)
}
}

#[derive(Debug, Error)]
pub enum CowApiError {
#[error("unknown chain {0} (no cowprotocol::Chain variant)")]
UnknownChain(u64),
#[error("bad HTTP method `{0}` (expected GET/POST/PUT/DELETE)")]
BadMethod(String),
#[error("invalid path: {0}")]
BadPath(String),
#[error("network: {0}")]
Network(#[from] reqwest::Error),
#[error("decode OrderCreation JSON: {0}")]
Decode(#[from] serde_json::Error),
#[error("orderbook: {0}")]
Orderbook(#[from] cowprotocol::Error),
}


#[cfg(test)]
mod tests;
Loading
Loading