Skip to content

Latest commit

 

History

History
executable file
·
650 lines (518 loc) · 27.1 KB

File metadata and controls

executable file
·
650 lines (518 loc) · 27.1 KB

Runtime Environment: wasmtime + Component Model

Version Target

wasmtime 45.x (latest stable as of Feb 2026).

Why wasmtime

Criterion wasmtime wasmer wasm3
Rust-native embedding First-class Yes C FFI
Async host functions Yes No No
Component Model / WASI Full Partial No
Fuel / epoch metering Both Fuel only Injection
Production users Fastly, Fermyon, Cloudflare, Zed General Embedded
Sandboxing Proven Similar Similar

Decision: Component Model from Day 1

Rationale

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.

  2. Type-safe API contract. The WIT definition is the API spec. Both host and guest get generated bindings (wasmtime::component::bindgen! on the host, wit_bindgen::generate! on the guest). No manual ABI wrangling, no serialisation disagreements.

  3. Resource types. Opaque handles with lifecycle management (constructors, methods, destructors via ResourceTable). Ideal for subscription handles, RPC connections, etc.

  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 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.57) and cargo-component (v0.21) are functional but APIs are not yet stable. Pin versions in the SDK.
  • Native async Component Model (stream<T>, future<T>) is still evolving. We use basic async host functions (func_wrap_async) which are stable.

Risk assessment

Aspect Risk
bindgen! macro, custom worlds, resource types Low — stable, well-documented
wit-bindgen guest bindings Medium — API churn between versions
Component Model native async (streams/futures) High — not needed yet, avoid for now

Core Concepts

Engine

Global, thread-safe compilation environment. One per process.

let mut config = Config::new();
config.async_support(true);
config.consume_fuel(true);
config.epoch_interruption(true);
let engine = Engine::new(&config)?;

Store

Per-module execution context. Holds component instances, host state (NexumHostState), fuel counters, resource limits, and the ResourceTable for handle management.

let mut store = Store::new(&engine, NexumHostState {
    table: ResourceTable::new(),
    rpc: alloy_provider,
    db: redb_handle,
    // ...
});
store.set_fuel(10_000)?;
store.epoch_deadline_async_yield_and_update(10); // yield after 10 epochs (~1s at 100ms tick)

Component -> InstancePre -> Instance

  1. Component: compiled from .wasm component binary (expensive, cacheable, thread-safe).
  2. Linker: binds host implementations of our WIT interfaces.
  3. InstancePre: pre-validated component + linker (reusable across stores).
  4. Instance: a live component in a specific store, from which we call exports.
let component = Component::from_file(&engine, "twap_monitor.wasm")?;
let mut linker = Linker::new(&engine);
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 = EventModule::instantiate_pre(&mut store, &pre)?;

WIT Worlds: Universal and CoW-Specific

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: nexum:host@0.2.0

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:

package nexum:host@0.2.0;

interface types {
    type chain-id = u64;

    record block {
        chain-id: chain-id,
        number: u64,
        hash: list<u8>,
        timestamp: u64,           // milliseconds since Unix epoch, UTC
    }

    record log {
        chain-id: chain-id,
        address: list<u8>,
        topics: list<list<u8>>,
        data: list<u8>,
        block-number: u64,
        transaction-hash: list<u8>,
        log-index: u32,
    }

    record tick {
        fired-at: u64,            // milliseconds since Unix epoch, UTC
    }

    record message {
        content-topic: string,
        payload: list<u8>,
        timestamp: u64,           // milliseconds since Unix epoch, UTC
        sender: option<list<u8>>,
    }

    variant event {
        block(block),
        logs(list<log>),
        tick(tick),
        message(message),
    }

    /// 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<tuple<string, string>>;

    /// 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<string>,      // 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
    /// the given chain, applying timeout/retry/rate-limit/fallback middleware
    /// transparently. Method includes the namespace prefix (e.g. "eth_call").
    ///
    /// `params` and the success return value are JSON-encoded strings matching
    /// the JSON-RPC spec. The host handles id/jsonrpc framing; the guest only
    /// provides method + params and receives the `result` field.
    ///
    /// See doc 07 (RPC Namespace Design) for the full design rationale: a
    /// single generic function replaces per-method WIT functions, enabling
    /// the SDK to implement alloy's Transport trait and expose the full
    /// alloy Provider API (80+ methods) to guest modules with zero WIT churn.
    ///
    /// 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 chain for transactions.
    request: func(chain-id: chain-id, method: string, params: string)
        -> result<string, 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),
    }

    /// 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<rpc-request>)
        -> result<list<rpc-result>, host-error>;
}

interface identity {
    use types.{host-error};

    /// Get available signing accounts (20-byte Ethereum addresses).
    accounts: func() -> result<list<list<u8>>, host-error>;

    /// Sign a message with `personal_sign` semantics. The host MUST prepend
    /// the EIP-191 prefix (`\x19Ethereum Signed Message:\n<len>`) 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).
    ///
    /// A separate raw-bytes signing primitive, gated by an explicit
    /// capability, is on the 0.3 roadmap.
    sign: func(account: list<u8>, message: list<u8>) -> result<list<u8>, 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<u8>, typed-data: string) -> result<list<u8>, host-error>;
}

interface local-store {
    use types.{host-error};
    get: func(key: string) -> result<option<list<u8>>, host-error>;
    set: func(key: string, value: list<u8>) -> result<_, host-error>;
    delete: func(key: string) -> result<_, host-error>;
    list-keys: func(prefix: string) -> result<list<string>, host-error>;
}

interface logging {
    enum level { trace, debug, info, warn, error }
    log: func(level: level, message: string);
}

/// The universal event-driven module world. Platform-agnostic: no CoW,
/// no domain-specific imports. Suitable for any web3 automation.
///
/// 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 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<_, host-error>;
}

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.

CoW-Specific Package: shepherd:cow@0.2.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:

package shepherd:cow@0.2.0;

interface cow-api {
    use nexum:host/types.{chain-id, host-error};

    /// HTTP-style request to the CoW Protocol API.
    ///
    /// The host routes to the correct CoW API base URL for the given chain.
    /// `method`: "GET" | "POST" | "PUT" | "DELETE"
    /// `path`: relative API path, e.g. "/api/v1/orders"
    /// `body`: optional JSON request body
    request: func(
        chain-id: chain-id,
        method: string,
        path: string,
        body: option<string>,
    ) -> result<string, host-error>;

    /// 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<u8>)
        -> result<string, host-error>;
}

/// CoW Protocol module world. Extends the universal event-module
/// with CoW-specific imports.
world shepherd {
    include nexum:host/event-module;

    import cow-api;
}

Key properties

  • 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 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<T, host-error>. 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<u8> 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 in 0.2's reference runtimenexum: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 nexum::host::. For CoW-specific interfaces, they live under shepherd::cow::.

// Universal event-module world
wasmtime::component::bindgen!({
    path: "wit/nexum-host",
    world: "event-module",
    async: true,
});

// CoW-specific shepherd world (extends event-module)
wasmtime::component::bindgen!({
    path: "wit/shepherd-cow",
    world: "shepherd",
    async: true,
});

Identity Host Trait

The Identity trait abstracts key management and signing. Platform implementations vary (server uses keystore/KMS/HSM, mobile uses device keychain, WebView uses wallet extensions), but the trait is uniform:

trait Identity {
    fn accounts(&self) -> Result<Vec<Address>>;
    fn sign(&self, account: Address, data: &[u8]) -> Result<Signature>;
    fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result<Signature>;
}

Chain depends on Identity

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:

impl nexum::host::chain::Host for NexumHostState {
    async fn request(
        &mut self,
        chain_id: u64,
        method: String,
        params: String,
    ) -> Result<Result<String, HostError>> {
        // Signing methods are intercepted and delegated to identity.
        match method.as_str() {
            "eth_accounts" => {
                let accounts = self.identity.accounts()?;
                let json = serde_json::to_string(&accounts)?;
                return Ok(Ok(json));
            }
            "eth_sendTransaction" => {
                // Parse tx, sign via identity, then submit signed tx to provider
                let tx = serde_json::from_str(&params)?;
                let signature = self.identity.sign(tx.from, &tx.signing_hash())?;
                let signed = tx.with_signature(signature);
                let provider = self.provider_for(chain_id)?;
                let hash = provider.send_raw_transaction(&signed.encoded()).await?;
                return Ok(Ok(serde_json::to_string(&hash)?));
            }
            "eth_signTypedData_v4" | "personal_sign" => {
                // Delegate to identity for signing
                let (account, data) = parse_sign_params(&method, &params)?;
                let sig = self.identity.sign(account, &data)?;
                return Ok(Ok(serde_json::to_string(&sig)?));
            }
            _ => {}
        }

        if !self.is_method_allowed(&method) {
            return Ok(Err(HostError {
                domain: "chain".into(),
                kind: HostErrorKind::Denied,
                code: -32601,
                message: format!("method not allowed: {method}"),
                data: None,
            }));
        }

        let provider = self.provider_for(chain_id)?;
        let raw_params: Box<RawValue> = RawValue::from_string(params)?;

        // One function handles the entire eth_ namespace — alloy's provider
        // 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(HostError::from_transport("chain", e))),
        }
    }
}

Identity Host Implementation

The identity::Host implementation delegates to the platform-specific Identity trait. Errors map to the unified HostError:

impl nexum::host::identity::Host for NexumHostState {
    async fn accounts(&mut self) -> Result<Result<Vec<Vec<u8>>, HostError>> {
        match self.identity.accounts() {
            Ok(addrs) => Ok(Ok(addrs.into_iter().map(|a| a.to_vec()).collect())),
            Err(e) => Ok(Err(HostError {
                domain: "identity".into(),
                kind: HostErrorKind::Internal,
                code: 1,
                message: e.to_string(),
                data: None,
            })),
        }
    }

    async fn sign(
        &mut self,
        account: Vec<u8>,
        data: Vec<u8>,
    ) -> Result<Result<Vec<u8>, HostError>> {
        let address = Address::from_slice(&account);
        match self.identity.sign(address, &data) {
            Ok(sig) => Ok(Ok(sig.to_vec())),
            Err(IdentityBackendError::UserRejected) => Ok(Err(HostError {
                domain: "identity".into(),
                kind: HostErrorKind::Denied,
                code: 2,
                message: "user rejected".into(),
                data: None,
            })),
            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

impl nexum::host::local_store::Host for NexumHostState {
    async fn get(&mut self, key: String) -> Result<Result<Option<Vec<u8>>, 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())?;
        Ok(Ok(table.get(key.as_str())?.map(|v| v.value().to_vec())))
    }
    // ...
}

impl shepherd::cow::cow_api::Host for NexumHostState {
    // CoW-specific host implementation
    // ...
}

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 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:

use nexum_sdk::prelude::*;

#[nexum::module]
struct BlockLogger;

impl BlockLogger {
    fn init(config: Config) -> Result<()> {
        info!("Block logger starting");
        Ok(())
    }

    async fn on_block(block: Block, provider: &RootProvider) -> Result<()> {
        let block_num = provider.get_block_number().await?;
        info!("New block: {block_num}");

        TypedState::set("last_block", &block_num)?;
        Ok(())
    }
}

CoW Protocol modules (shepherd-sdk)

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:

use shepherd_sdk::prelude::*;

sol! {
    function getTradeableOrderWithSignature(
        address owner, bytes32 ctx, bytes32 orderHash
    ) external view returns (bytes memory order, bytes memory signature);
}

#[shepherd::module]
struct TwapMonitor;

impl TwapMonitor {
    fn init(config: Config) -> Result<()> {
        info!("TWAP monitor starting");
        Ok(())
    }

    // 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: 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?;

        // Typed contract calls with sol! + EthCall builder
        let tx = TransactionRequest::default()
            .to(contract)
            .input(getTradeableOrderWithSignatureCall {
                owner, ctx, orderHash: order_hash,
            }.abi_encode().into());
        let result = provider.call(tx).latest().await?;
        let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?;

        // CoW API via typed client
        let cow = Cow::new(block.chain_id);
        cow.submit_order(&order)?;

        // State persistence
        TypedState::set("last_block", &block_num)?;
        Ok(())
    }

    // Only define handlers for events you subscribe to.
    // No on_logs, on_tick, or on_message → those events are silently ignored.
}

Build with cargo component build --release (or cargo build --target wasm32-wasip2 + wasm-tools component new).

See doc 05 for the full macro design (named handlers, provider injection, escape hatch) and doc 07 for the HostTransport implementation and provider() constructor.

Multi-Language Guest Support

Language Tooling Maturity
Rust wit-bindgen + cargo-component Mature
C/C++ wit-bindgen c + WASI SDK Mature
Go wit-bindgen Go generator Maturing
JavaScript ComponentizeJS (SpiderMonkey) Maturing
Python componentize-py (CPython) Maturing
C# wit-bindgen-csharp Emerging

All produce valid components against the same WIT worlds (nexum:host/event-module for universal, shepherd:cow/shepherd for CoW).

Execution Metering

Fuel (deterministic cost accounting)

  • Config::consume_fuel(true) — each WASM op consumes fuel; exhaustion traps.
  • Use for per-invocation budgets: cap a single on_event callback.

Epoch Interruption (cooperative time-slicing)

  • Config::epoch_interruption(true) — background Tokio task calls engine.increment_epoch() on a fixed interval.
  • Stores yield at epoch boundaries via epoch_deadline_async_yield_and_update.
  • Use for wall-clock fairness: prevent one module from starving others.

Both are needed: fuel for correctness, epochs for liveness.

Resource Limits

Implement ResourceLimiter to cap per-module:

  • Memory growth — target <10 MB default.
  • Table growth — max entries.
  • Instance count — max concurrent.

Enforced synchronously on every memory.grow / table.grow.

Async Integration

All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges this:

  • Config::async_support(true).
  • Host functions registered with func_wrap_async (or via async: true in bindgen!).
  • Guest exports called with call_async.
  • wasmtime runs WASM on a separate native stack; Future::poll drives execution.
  • Epoch yielding ensures cooperation with the Tokio scheduler.

Note: We use wasmtime's basic async support (stable), not the Component Model native async (stream<T>, future<T>) which is still evolving.

WASI: Intentionally Excluded

  • WASI 0.2.1 is stable in wasmtime. WASI 0.3 (native async) is in preview.
  • 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 (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<NexumHostState> + Instance
Host API impl Traits generated by bindgen!
Host identity Identity trait (keystore/KMS/HSM on server)
Opaque handles Resource<T> + ResourceTable
Per-call budget Fuel
Wall-clock fairness Epoch interruption
Memory/table caps ResourceLimiter
Async RPC / CoW I/O func_wrap_async + Tokio
Persistent state redb (per-module database file, via local-store interface host fns)