Status: future direction, not in 0.2 scope. This document is the 0.3+ north-star vision for the layered SDK. The 0.2 SDK shipped a focused subset, and the macro-driven authoring model below was superseded by the host-trait seam in ADR-0009 - that is the design that ships. Treat the macros, two-crate split,
TypedState,Signer,HostTransport/Provider, andcargo-nexumCLI sections below as design intent, not API documentation. For the shipped surface, seesdk.mdand the rustdoc oncrates/shepherd-sdk/.The split, for quick reference:
Feature M3 status Where shepherd-sdkcrate✅ shipped crates/shepherd-sdk/shepherd-sdk-testcrate (mock host)✅ shipped crates/shepherd-sdk-test/Host traits ( ChainHost,LocalStoreHost,LoggingHost) + supertraitHost✅ shipped crates/nexum-sdk/src/host.rs(see ADR-0009); the CoWCowApiHostlives incrates/shepherd-sdk/src/cow/strategy.rs(pure logic) +lib.rs(wit-bindgen adapter) recipe✅ shipped every M2/M3 module Fault+HostFaulttrait,ChainError(SDK-side mirror of wit)✅ shipped crates/nexum-sdk/src/host.rschainhelpers (eth_call_params,parse_eth_call_result)✅ shipped crates/nexum-sdk/src/chain/; the CoWdecode_revert_hexlives incrates/shepherd-sdk/src/cow/cowhelpers (PollOutcome,RetryAction,classify_api_error,gpv2_to_order_data,decode_revert,IConditionalOrder)✅ shipped crates/shepherd-sdk/src/cow/http::fetchover wasi:http (+Fetchseam,FetchError)✅ shipped crates/nexum-sdk/src/http.rsMockHostwith per-trait mocks (MockChain,MockLocalStore,MockLogging; CoWMockCowApi)✅ shipped crates/nexum-sdk-test/src/lib.rs+crates/shepherd-sdk-test/src/lib.rsSeparate nexum-sdkcrate✅ shipped crates/nexum-sdk/carries the generic surface (host seam, bind macro, chain/config/address, http, tracing);shepherd-sdklayers the CoW domain on top with no re-export#[nexum::module]/#[shepherd::module]proc macros❌ deferred (M5) modules write wit_bindgen::generate!+WitBindgenHostadapter by handNamed event handlers ( on_block/on_chain_logs/on_tick/on_messageinjection)❌ deferred (M5) modules pattern-match on types::EventinGuest::on_eventasync fnhandler support viablock_on❌ deferred (M5) strategy functions are synchronous Full alloy ProviderviaHostTransport❌ deferred (M5) modules call host.request(chain_id, method, params)with JSON stringsTypedState(postcard-backed typed local-store)❌ deferred (M5) modules call host.set(&key, &raw_bytes)directlySigner(ECDSA + EIP-712 viaidentityhost interface)❌ deferred (M5) modules use Signature::PreSign/Signature::Eip1271; no key custody on the module sideCowtyped CoW Protocol API client (quote / get_order / raw_request)❌ deferred (M5) cow-apiexposes onlysubmit-ordertodayMockIdentity,MockProvider,WasmTestHarness❌ deferred (M5) tests against &impl Host+ per-trait mockscargo nexumCLI (new / build / package / publish)❌ deferred (M5) modules use cargo build --target wasm32-wasip2directlyblock.timestampin ms✅ shipped confirmed in nexum:host/typesReader's guide: treat the sections below as design intent the next two milestones move toward, not API documentation for the code that exists today. For M3 API reference, see sdk.md and the rustdoc on
crates/shepherd-sdk/. The M3 architectural decision is captured in ADR-0009.
The SDK is split into two layers:
-
nexum-sdk-- the universal SDK for anynexum:host/event-module. It provides:- WIT bindings (re-exported, version-pinned)
- A proc macro (
#[nexum::module]) that eliminates boilerplate (supportsasync fnfor natural.await) - A full alloy
Providerbacked by the host's RPC stack (HostTransport) - Typed local-store helpers (serde over raw bytes)
- A typed
Signerfor key management and signing - Ethereum ABI helpers (alloy-sol-types integration)
- A test harness with a mock host (
MockHost) - A logging convenience layer
- The per-interface typed error model over the shared
Faultvocabulary
-
shepherd-sdk-- the CoW Protocol extension. It depends onnexum-sdk(modules import both directly; nothing is re-exported) and adds:- CoW-specific WIT bindings (
shepherd:cow) - A typed CoW Protocol API client (
Cow) - A proc macro (
#[shepherd::module]) that targets theshepherd:cow/shepherdworld - CoW-specific mock testing utilities
- CoW-specific WIT bindings (
Module authors should never interact with wit-bindgen or the canonical ABI directly.
nexum-sdk/
├── Cargo.toml
├── src/
│ ├── 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 chain::request / chain::request-batch
│ ├── local_store.rs # typed local-store helpers
│ ├── signer.rs # Signer -- typed identity helpers (accounts, signing)
│ ├── abi.rs # Ethereum ABI encoding/decoding
│ ├── log.rs # logging convenience
│ ├── error.rs # Fault, HostFault, ChainError
│ └── testing.rs # mock host, test harness
└── macros/
└── src/
└── lib.rs # #[nexum::module] proc macro (async fn support)
shepherd-sdk/
├── Cargo.toml
├── src/
│ ├── lib.rs # CoW-specific prelude and API; modules import nexum-sdk directly
│ ├── bindings.rs # generated CoW WIT bindings (shepherd:cow)
│ ├── 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/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:
# nexum-sdk/Cargo.toml
[package.metadata.component.target]
path = "../wit/nexum-host"# shepherd-sdk/Cargo.toml
[package.metadata.component.target]
path = "../wit/shepherd-cow"Both SDKs pin a specific wit-bindgen version so module authors are insulated from upstream churn.
The crates above are the guest-side SDK. The host side ships separately as the nexum-runtime library plus the nexum binary (crates/nexum-cli). A Rust host embedding the runtime directly should start from crates/nexum-runtime/examples/embed.rs rather than the SDK.
Without the macro, a module author writes (against the typed 0.2 config):
wit_bindgen::generate!({ world: "event-module", path: "..." });
struct MyModule;
impl Guest for MyModule {
fn init(config: Config) -> Result<(), Fault> { ... }
fn on_event(event: Event) -> Result<(), Fault> {
match event {
Event::Block(block) => { ... }
Event::ChainLogs(logs) => { ... }
Event::Tick(tick) => { ... }
Event::Message(msg) => { ... }
}
}
}
export!(MyModule);With the macro, module authors implement named event handlers instead. The macro generates the on_event match dispatch, the Guest trait impl, WIT bindings, and export!:
use nexum_sdk::prelude::*;
#[nexum::module]
struct TwapMonitor;
impl TwapMonitor {
fn init(config: Config) -> Result<()> {
Ok(())
}
async fn on_block(block: Block, provider: &RootProvider) -> Result<()> {
let num = provider.get_block_number().await?;
// ...
Ok(())
}
async fn on_chain_logs(logs: Vec<Log>, provider: &RootProvider) -> Result<()> {
for log in &logs {
// ...
}
Ok(())
}
// on_tick / on_message not defined -> those events are silently ignored
}The #[nexum::module] macro generates code against the nexum:host/event-module world.
For CoW Protocol modules, the #[shepherd::module] macro targets the shepherd:cow/shepherd world, which extends event-module with the merged cow-api import:
use shepherd_sdk::prelude::*;
#[shepherd::module]
struct CowTwapMonitor;
impl CowTwapMonitor {
fn init(config: Config) -> Result<()> {
Ok(())
}
async fn on_block(block: Block, provider: &RootProvider) -> Result<()> {
let cow = Cow::new(block.chain_id);
let quote = cow.get_quote(&OrderQuoteRequest { /* ... */ })?;
// ...
Ok(())
}
}For the universal #[nexum::module]:
wit_bindgen::generate!({ world: "event-module", path: "..." });
impl Guest for TwapMonitor {
fn init(config: Config) -> Result<(), Fault> {
TwapMonitor::init(config.into()).map_err(Fault::from)
}
fn on_event(event: types::Event) -> Result<(), Fault> {
nexum_sdk::block_on(async {
match event {
Event::Block(block) => {
let provider = nexum_sdk::provider(block.chain_id);
TwapMonitor::on_block(block, &provider).await
}
Event::ChainLogs(logs) => {
let provider = nexum_sdk::provider(logs[0].chain_id);
TwapMonitor::on_chain_logs(logs, &provider).await
}
Event::Tick(_) => Ok(()), // no handler defined
Event::Message(_) => Ok(()), // no handler defined
}
}).map_err(Fault::from)
}
}
export!(TwapMonitor);For the CoW #[shepherd::module], the generated code additionally imports shepherd:cow/cow-api alongside the nexum:host base.
| Handler | Payload | Optional injectable context |
|---|---|---|
on_block(block) |
Block |
provider: &RootProvider (from block.chain_id) |
on_chain_logs(logs) |
Vec<Log> |
provider: &RootProvider (from the chain-logs batch 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:
- If the second parameter is
&RootProvider: the macro createsnexum_sdk::provider(chain_id)(also for CoW modules) and passes it in. The chain_id is derived from the event payload (block.chain_id,logs[0].chain_id). - If no second parameter: the macro passes only the payload.
- Both sync and async handlers work. Async handlers are wrapped in
block_on; sync handlers are called directly. - Unimplemented handlers become
Ok(())-- the module only handles event types it cares about.
For modules that need custom dispatch logic, defining on_event directly takes precedence over named handlers:
#[nexum::module]
struct CustomModule;
impl CustomModule {
fn init(config: Config) -> Result<()> { Ok(()) }
// Full control -- named handlers are ignored if on_event exists
async fn on_event(event: Event) -> Result<()> {
match event {
Event::Block(block) if block.chain_id == 42161 => { /* Arbitrum only */ }
Event::Block(_) => { /* other chains */ }
_ => {}
}
Ok(())
}
}Resolution order:
on_eventdefined -> use it directly (wrap inblock_onif async)- Any of
on_block/on_chain_logs/on_tick/on_messagedefined -> generate the match dispatch - Neither -> compile error
Full async design rationale: 07-rpc-namespace-design.md
// nexum_sdk::prelude
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::signer::Signer;
pub use crate::transport::HostTransport;
pub use crate::provider;
pub use crate::error::{Result, Fault, HostFault, ChainError, RpcError};
// Re-export alloy essentials so modules don't need direct alloy dependencies
pub use alloy_primitives::{Address, B256, U256, Bytes};
pub use alloy_sol_types::sol;
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 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 for the full SDK rename table.
// shepherd_sdk::prelude -- CoW-specific items only; no nexum-sdk re-export
pub use crate::bindings::shepherd::cow::cow_api;
pub use crate::cow::Cow;CoW module authors write use nexum_sdk::prelude::*; alongside use shepherd_sdk::prelude::*; -- the CoW prelude adds only the merged CoW cow-api interface and the typed Cow client.
Raw local-store is string -> list<u8>. The SDK adds a typed layer using serde:
use nexum_sdk::prelude::*;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct TwapProgress {
last_block: u64,
posted_parts: Vec<[u8; 32]>,
}
// Read typed value
let progress: Option<TwapProgress> = TypedState::get("progress")?;
// Write typed value
TypedState::set("progress", &TwapProgress {
last_block: 19_000_001,
posted_parts: vec![part_hash],
})?;
// Delete
TypedState::delete("progress")?;
// List keys by prefix
let keys: Vec<String> = TypedState::list_keys("orders/")?;Implementation:
pub struct TypedState;
impl TypedState {
pub fn get<T: DeserializeOwned>(key: &str) -> Result<Option<T>> {
match local_store::get(key)? {
Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)),
None => Ok(None),
}
}
pub fn set<T: Serialize>(key: &str, value: &T) -> Result<()> {
let bytes = postcard::to_allocvec(value)?;
local_store::set(key, &bytes)?;
Ok(())
}
pub fn delete(key: &str) -> Result<()> {
local_store::delete(key)?;
Ok(())
}
pub fn list_keys(prefix: &str) -> Result<Vec<String>> {
Ok(local_store::list_keys(prefix)?)
}
}Serialisation uses postcard (compact, no-std, deterministic) rather than JSON to minimise local-store storage overhead.
The identity WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed Signer:
use nexum_sdk::prelude::*;
// Get available signing 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 = 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 = Signer::sign_typed_data(&accounts[0], typed_data_json)?;Implementation:
/// Typed client for the identity WIT interface.
///
/// Provides cryptographic signing operations backed by the host engine's
/// key management. The host manages private keys -- modules never see them.
pub struct Signer;
impl Signer {
/// Get available signing accounts (20-byte Ethereum addresses).
pub fn accounts() -> Result<Vec<Vec<u8>>> {
identity::accounts().map_err(Fault::from)
}
/// Get available signing accounts as alloy `Address` types.
pub fn addresses() -> Result<Vec<Address>> {
let accounts = Self::accounts()?;
accounts
.into_iter()
.map(|a| {
Address::try_from(a.as_slice())
.map_err(|_| Fault::InvalidInput("invalid address length".into()))
})
.collect()
}
/// 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<Vec<u8>> {
identity::sign(account, data).map_err(Fault::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<Vec<u8>> {
identity::sign_typed_data(account, typed_data).map_err(Fault::from)
}
}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 Fault::Denied to distinguish "user rejected" from a transport failure -- see the migration guide §2 for the embedder mapping table.
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):
use nexum_sdk::prelude::*;
// Define the contract interface
sol! {
function getTradeableOrderWithSignature(
address owner,
bytes32 ctx,
bytes32 orderHash
) external view returns (
bytes memory order,
bytes memory signature
);
}
// Named handler -- provider is injected by the macro
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?;
// Typed contract calls with sol! + EthCall builder
let tx = TransactionRequest::default()
.to(contract_addr)
.input(getTradeableOrderWithSignatureCall {
owner: owner_addr,
ctx: ctx_bytes,
orderHash: order_hash,
}.abi_encode().into());
let result = provider.call(tx).latest().await?;
let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?;
let order_bytes = decoded.order;
Ok(())
}The SDK re-exports:
alloy_primitives::{Address, B256, U256, Bytes}-- core Ethereum types.alloy_sol_types::sol!-- compile-time ABI codec generation.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 chain::request (and batched chain::request-batch) host functions -- see doc 07 for the full design.
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:
use shepherd_sdk::prelude::*;
let cow = Cow::new(42161);
// 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 / 1000) + 300, // block.timestamp is ms in 0.2
..Default::default()
})?;
// Get a quote
let quote = cow.get_quote(&OrderQuoteRequest { ... })?;
// Get an order by UID
let order = cow.get_order(&uid)?;
// Raw request for endpoints not yet wrapped
let resp = cow.raw_request("GET", "/api/v1/auction", None)?;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.
Wrappers over the logging WIT interface (provided in nexum-sdk; CoW modules import them from nexum-sdk directly):
// nexum_sdk::log
pub fn trace(msg: &str) { logging::log(Level::Trace, msg); }
pub fn debug(msg: &str) { logging::log(Level::Debug, msg); }
pub fn info(msg: &str) { logging::log(Level::Info, msg); }
pub fn warn(msg: &str) { logging::log(Level::Warn, msg); }
pub fn error(msg: &str) { logging::log(Level::Error, msg); }
/// Format + log in one call
#[macro_export]
macro_rules! info {
($($arg:tt)*) => {
$crate::log::info(&format!($($arg)*))
};
}Usage:
nexum_sdk::info!("processing block {} on chain {}", block.number, block.chain_id);In 0.2 each interface declares its own typed error, and they share one payload-bearing Fault vocabulary for the cross-domain cases. The SDK exposes Fault, the HostFault trait (recovers an embedded fault plus a stable snake_case label), and the richer ChainError. A From<ChainError> for Fault fold lets a strategy aggregating store and chain calls ?-propagate into one Fault.
pub enum Fault {
Unsupported(String),
Unavailable(String),
Denied(String),
RateLimited(RateLimit), // { retry_after_ms: Option<u64> }
Timeout,
InvalidInput(String),
Internal(String),
}
/// Recovers the shared fault from a richer, per-interface error, plus a
/// stable snake_case label for logs and metrics.
pub trait HostFault {
fn fault(&self) -> Option<&Fault>;
fn label(&self) -> &'static str;
}
/// The chain interface embeds `Fault` and adds a structured JSON-RPC case.
pub enum ChainError {
Fault(Fault),
Rpc(RpcError), // { code: i32, message: String, data: Option<Vec<u8>> }
}
pub type Result<T> = core::result::Result<T, Fault>;Interfaces with nothing to add report Fault directly (identity, local-store, remote-store, messaging, and the module exports). The module exports return Result<(), Fault>; module-defined failures are plain Fault cases, and the supervisor supplies the module name and derives its log kind from the fault label. Module authors use ? naturally, and match on the case (or on ChainError::Rpc for a revert) for retry/backoff:
// Universal module
async fn on_block(block: Block, provider: &RootProvider) -> Result<()> {
let num = provider.get_block_number().await?; // ChainError -> Fault via From
let decoded = MyCall::abi_decode_returns(&data)
.map_err(|e| Fault::InvalidInput(e.to_string()))?; // module-defined
TypedState::set("last", &decoded)?; // store Fault
let sig = Signer::sign(&account, &data)?; // identity Fault
Ok(())
}
// Inspecting the case for retry decisions
match host.request(chain_id, "eth_blockNumber", "[]") {
Ok(n) => Ok(n),
Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(),
Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms),
Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // structured revert
Err(e) => Err(e.into()),
}
// CoW module
async fn on_block(block: Block, provider: &RootProvider) -> Result<()> {
let num = provider.get_block_number().await?; // chain error
TypedState::set("last", &num)?; // store Fault
Cow::new(block.chain_id).submit_order(&order)?; // cow-api-error
Ok(())
}See ADR-0011 for the model and the migration guide §2 for the embedder-side mapping of backend signals (HTTP codes, transport errors, wallet rejections) to fault cases.
The nexum-sdk provides a mock host so modules can be tested without a live blockchain or runtime.
use nexum_sdk::testing::{MockHost, MockChain};
#[test]
fn test_monitor_processes_block() {
let mut host = MockHost::new();
// Set up mock chain state
host.chain(42161)
.block_number(19_000_001)
.mock_call(
"0xfdaFc9d...", // contract address
&get_active_orders_calldata(), // expected calldata
&mock_orders_response(), // return value
);
// Pre-populate local-store (simulating previous run)
host.local_store()
.set("last_block", &19_000_000u64.to_le_bytes());
// Dispatch a block event
let result = host.dispatch(Event::Block(Block {
chain_id: 42161,
number: 19_000_001,
hash: vec![0; 32],
timestamp: 1_700_000_000_000, // ms since epoch
}));
assert!(result.is_ok());
// Verify local-store was updated
let last = host.local_store().get::<u64>("last_block").unwrap();
assert_eq!(last, Some(19_000_001));
}The MockHost supports identity mocking for modules that use signing:
use nexum_sdk::testing::{MockHost, MockIdentity};
#[test]
fn test_module_signs_data() {
let mut host = MockHost::new();
// Configure mock identity with test accounts
host.identity()
.add_account(hex::decode("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045").unwrap())
.on_sign(|account, data| {
// Return a mock 65-byte signature
Ok(vec![0u8; 65])
})
.on_sign_typed_data(|account, typed_data| {
// Return a mock 65-byte signature for EIP-712
Ok(vec![0u8; 65])
});
let result = host.dispatch(Event::Block(Block {
chain_id: 1,
number: 19_000_001,
hash: vec![0; 32],
timestamp: 1700000000,
}));
assert!(result.is_ok());
// Verify signing was called
assert_eq!(host.identity().sign_calls().len(), 1);
}The shepherd-sdk extends MockHost with CoW-specific assertions:
use shepherd_sdk::testing::{MockHost, MockCow};
#[test]
fn test_twap_monitor_submits_order() {
let mut host = MockHost::new();
host.chain(42161).block_number(19_000_001);
let result = host.dispatch(Event::Block(Block {
chain_id: 42161,
number: 19_000_001,
hash: vec![0; 32],
timestamp: 1_700_000_000_000,
}));
assert!(result.is_ok());
// Verify an order was submitted (CoW-specific)
assert_eq!(host.submitted_orders().len(), 1);
}// nexum-sdk: universal mock host
pub struct MockHost {
local_store: HashMap<String, Vec<u8>>,
chains: HashMap<u64, MockChain>,
identity: MockIdentity,
logs: Vec<(Level, String)>,
}
pub struct MockChain {
block_number: u64,
/// Maps (method, params_json) -> result_json for RPC mocking
rpc_mocks: HashMap<(String, String), String>,
logs: Vec<LogEntry>,
}
pub struct MockIdentity {
accounts: Vec<Vec<u8>>,
sign_handler: Option<Box<dyn Fn(&[u8], &[u8]) -> Result<Vec<u8>>>>,
sign_typed_data_handler: Option<Box<dyn Fn(&[u8], &str) -> Result<Vec<u8>>>>,
sign_calls: Vec<(Vec<u8>, Vec<u8>)>,
sign_typed_data_calls: Vec<(Vec<u8>, String)>,
}
// shepherd-sdk: extends with CoW-specific fields
pub struct CowMockHost {
inner: MockHost, // universal mock
submitted_orders: Vec<(u64, Vec<u8>)>,
cow_requests: Vec<(u64, String, String, Option<String>)>,
}The mock host implements the same trait interface as the real host. Tests run as native Rust (not compiled to WASM) -- the mock substitutes for the WIT imports.
For alloy Provider-based tests, the nexum-sdk also provides MockProvider (backed by alloy's Asserter-based mock transport) -- see doc 07 for details.
For tests that compile to WASM and run in a real wasmtime instance:
use nexum_sdk::testing::WasmTestHarness;
#[test]
fn test_module_as_component() {
let harness = WasmTestHarness::new("target/wasm32-wasip2/release/twap_monitor.wasm");
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(Block {
chain_id: 42161,
number: 100,
hash: vec![0; 32],
timestamp: 1_700_000_000_000,
}));
assert!(result.is_ok());
}This tests the full component boundary (canonical ABI marshalling, host function binding).
Future direction, not in 0.2 scope. The
cargo-nexumcargo subcommand described in this section does not ship in 0.2. Module authors today build withcargo build --target wasm32-wasip2 --release(the M5 reference repo includes ajustfilewith the canonical recipes). Acargo-nexum(or successor) scaffolding/packaging CLI is on the 0.3 roadmap.Two separate tools (design intent):
cargo-nexumwould be a cargo subcommand for module authors (new, build, package, publish). Thenexumbinary is the operator runtime (run, module list/restart, local-store purge). Embedders bypass the binary entirely and drive the runtime through thenexum-runtimelibrary.
cargo nexum new my-moduleGenerates:
my-module/
├── Cargo.toml
├── module.toml # manifest template
└── src/
└── lib.rs # minimal module skeleton
Cargo.toml:
[package]
name = "my-module"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
nexum-sdk = "0.2"
[package.metadata.component]
package = "my:module"src/lib.rs:
use nexum_sdk::prelude::*;
#[nexum::module]
struct MyModule;
impl MyModule {
fn init(config: Config) -> Result<()> {
info!("module initialised");
Ok(())
}
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_chain_logs(logs: Vec<Log>, provider: &RootProvider) -> Result<()> {
info!("received {} logs", logs.len());
Ok(())
}
fn on_tick(tick: Tick) -> Result<()> {
info!("tick fired at {} ms UTC", tick.fired_at);
Ok(())
}
}Cargo.toml:
[package]
name = "my-cow-module"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
shepherd-sdk = "0.2"
[package.metadata.component]
package = "my:module"src/lib.rs:
use shepherd_sdk::prelude::*;
#[shepherd::module]
struct MyCowModule;
impl MyCowModule {
fn init(config: Config) -> Result<()> {
info!("module initialised");
Ok(())
}
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_chain_logs(logs: Vec<Log>, provider: &RootProvider) -> Result<()> {
info!("received {} logs", logs.len());
Ok(())
}
fn on_tick(tick: Tick) -> Result<()> {
info!("tick fired at {} ms UTC", tick.fired_at);
Ok(())
}
}module.toml (same for both):
[module]
name = "my-module"
version = "0.1.0"
description = ""
authors = []
component = "sha256:TODO"
[module.resources]
max_memory_bytes = 10_485_760
max_fuel_per_event = 100_000
max_state_bytes = 52_428_800
[module.restart]
max_consecutive_failures = 10
[chains]
required = []
optional = []
[capabilities]
required = ["chain", "local-store", "logging"]
optional = []
[config]cargo component build --release
# -> target/wasm32-wasip2/release/my_module.wasmcargo nexum package
# Computes sha256, updates module.toml, creates bundle directorycargo nexum publish --swarm http://localhost:1633 --batch-id <stamp>
# Uploads bundle to Swarm, prints content referenceThe WIT definition is versioned (nexum:host@0.2.0). The SDK pins this version. When the WIT evolves:
- 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.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 per-interface typed errors over the shared fault vocabulary, the module.toml schema, the #[nexum::module] macro surface) are stable starting at 0.2.0 -- see the migration guide §10 for the full deprecation policy.
| SDK Layer | Provides |
|---|---|
#[nexum::module] |
Eliminates WIT boilerplate; named event handlers (on_block, on_chain_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::* |
CoW-specific types and interfaces; the universal surface comes from nexum_sdk::prelude::* |
TypedState |
Serde-based typed local-store over raw bytes |
sol! |
Compile-time Ethereum ABI codec (alloy-sol-types) |
log::{info!, ...} |
Formatted logging macros |
Fault / HostFault / ChainError / Result |
Per-interface typed errors over the shared fault vocabulary, with ? support and case-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 / check / migrate CLI |