diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index af944182..05ece59c 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,43 +1,20 @@ # SDK Design: The Two-Persona SDK -This document describes the guest-side SDK crates. There are two -personas, and both are shipped: the **module author**, served by -`nexum-sdk`, and the **venue persona**, served by `videre-sdk` - which -covers both sides of a venue: the adapter author who speaks one venue's -protocol, and the keeper author who drives venues through the typed -client. - -For the architectural decision behind the host-trait seam both personas -build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the -rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc -under `crates/nexum-sdk/` and `crates/videre-sdk/`. +This document describes the guest-side SDK crates. There are two personas, and both are shipped: the **module author**, served by `nexum-sdk`, and the **venue persona**, served by `videre-sdk` - which covers both sides of a venue: the adapter author who speaks one venue's protocol, and the keeper author who drives venues through the typed client. + +For the architectural decision behind the host-trait seam both personas build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc under `crates/nexum-sdk/` and `crates/videre-sdk/`. ## The two personas 1. **Module author.** Writes an automation module against - `nexum:host/event-module`: react to blocks, chain logs, ticks, or - messages; read and write local state. Served by `nexum-sdk` plus the - `#[nexum_sdk::module]` attribute macro (from `nexum-module-macros`). +`nexum:host/event-module`: react to blocks, chain logs, ticks, or messages; read and write local state. Served by `nexum-sdk` plus the `#[nexum_sdk::module]` attribute macro (from `nexum-module-macros`). 2. **Venue author.** Writes the component that exposes a trading venue - (CoW Protocol, a DEX, a lending market, ...) to modules through the - `videre:venue` intent surface, so a module author never sees the - venue's wire format. Served by `videre-sdk`: the `VenueAdapter` - trait under `#[videre_sdk::venue]`, the `IntentBody` derive, and the - `videre-test` conformance kit. - -A keeper - a module that drives venues - sits between the two: it is a -module by world, but it authors with `#[videre_sdk::keeper]` and calls -venues through the typed `VenueClient`. The domain itself (CoW, a DEX) -lives in the venue adapter, never in the host: see -[doc 08](08-platform-generalisation.md#layer-3-domain-extensions-venue-adapters). - -Each persona has its own proc-macro crate (`nexum-module-macros`, -`videre-macros`), reached through the SDK re-exports. Both share the -same host-trait philosophy: guest code is written against small Rust -traits that mirror the WIT interfaces one-for-one, so strategy logic -can be unit-tested against an in-memory mock without a `wasm32-wasip2` -toolchain or a running wasmtime instance. +(CoW Protocol, a DEX, a lending market, ...) to modules through the `videre:venue` intent surface, so a module author never sees the venue's wire format. Served by `videre-sdk`: the `VenueAdapter` trait under `#[videre_sdk::venue]`, the `IntentBody` derive, and the `videre-test` conformance kit. + +A keeper - a module that drives venues - sits between the two: it is a module by world, but it authors with `#[videre_sdk::keeper]` and calls venues through the typed `VenueClient`. The domain itself (CoW, a DEX) lives in the venue adapter, never in the host: see [doc 08](08-platform-generalisation.md#layer-3-domain-extensions-venue-adapters). + +Each persona has its own proc-macro crate (`nexum-module-macros`, `videre-macros`), reached through the SDK re-exports. Both share the same host-trait philosophy: guest code is written against small Rust traits that mirror the WIT interfaces one-for-one, so strategy logic can be unit-tested against an in-memory mock without a `wasm32-wasip2` toolchain or a running wasmtime instance. ## Crate layout @@ -63,11 +40,12 @@ videre-sdk/ # venue SDK: both venue sides ├── lib.rs # crate docs, macro re-exports (venue, keeper, IntentBody) ├── adapter.rs # VenueAdapter trait (init + the five intent functions) ├── body.rs # IntentBody trait + BodyError (versioned borsh codec) - ├── client.rs # Venue, VenueId, VenueClient, VenueTransport, HostVenues + ├── client.rs # Venue, VenueId, VenueClient, VenueTransport, HostVenues; poll_once completes async handlers on the sync guest boundary ├── keeper.rs # Keeper::run - the generic run assembler; Outcome, RunReport ├── transport.rs # HostChain, HostMessaging, http, BoundedFetch ├── faults.rs # VenueFault + conversions across wire fault / SDK fault / VenueError - ├── rt.rs # completes async keeper handlers on the sync guest boundary + ├── event.rs # intent-status event decoding for on_intent_status handlers + ├── value_flow.rs # value-flow asset helpers (erc20 amounts) └── bindings.rs # the shared import-only bindgen the macros remap onto videre-macros/ # #[venue], #[keeper], derive(IntentBody) (proc-macro) @@ -79,24 +57,15 @@ cow-venue/ # the CoW venue, as feature slices composable-cow/ # ComposableCoW keeper machinery (body, poll seam, run) ``` -`nexum-sdk` is host-neutral and domain-free: any module targeting the -runtime pulls helpers and canonical primitive types from it regardless -of which world it exports. `videre-sdk` layers the venue platform's -guest surface on top. Domain crates (`cow-venue`, `composable-cow`) -depend on the SDKs; nothing is re-exported between layers. +`nexum-sdk` is host-neutral and domain-free: any module targeting the runtime pulls helpers and canonical primitive types from it regardless of which world it exports. `videre-sdk` layers the venue platform's guest surface on top. Domain crates (`cow-venue`, `composable-cow`) depend on the SDKs; nothing is re-exported between layers. -Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over -`ChainHost` / `LocalStoreHost` / `LoggingHost`) for modules and -keepers, and `videre-test` for adapters. See -[Testing](#testing-nexum-sdk-test-and-videre-test) below. +Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over `ChainHost` / `LocalStoreHost` / `LoggingHost`) for modules and keepers, and `videre-test` for adapters. See [Testing](#testing-nexum-sdk-test-and-videre-test) below. ## Module-author persona: `nexum-sdk` ### The host-trait seam -`nexum-sdk` never calls `wit_bindgen`-generated functions directly. -Instead `nexum_sdk::host` exposes small traits that mirror the WIT -interfaces: +`nexum-sdk` never calls `wit_bindgen`-generated functions directly. Instead `nexum_sdk::host` exposes small traits that mirror the WIT interfaces: ```rust pub trait ChainHost { @@ -115,44 +84,15 @@ pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} impl Host for T {} ``` -Strategy code takes `&impl Host` (or a narrower -`` bound when it only needs part of the -surface) so tests inject `nexum_sdk_test::MockHost` while the compiled -module injects the wit-bindgen-backed adapter. See -[ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and -[ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error -model. +Strategy code takes `&impl Host` (or a narrower `` bound when it only needs part of the surface) so tests inject `nexum_sdk_test::MockHost` while the compiled module injects the wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and [ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error model. ### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` -Every module keeps its own `wit_bindgen::generate!` call (the macro -emits types into the calling crate; re-exporting wit-bindgen output -from a library crate would duplicate symbols and break the -component-export contract). What the SDK removes is the ~80 lines of -mechanical glue that used to sit next to it: the -`nexum_sdk::bind_host_via_wit_bindgen!()` declarative macro emits a -`WitBindgenHost` struct, the trait impls over the generated import -shims, the `Fault` / `ChainError` converters in both directions, a -`Level` converter, a `From for nexum_sdk::events::Log` impl, -and an `install_tracing()` helper that routes `tracing::info!(...)` -through the bound host logging call. The adapter is -capability-selected: the zero-argument form emits the full set for -blanket-world modules, and the `caps: [chain, logging]` form (what -`#[nexum_sdk::module]` generates from the manifest) emits only the -pieces whose imports the module's world carries. +Every module keeps its own `wit_bindgen::generate!` call (the macro emits types into the calling crate; re-exporting wit-bindgen output from a library crate would duplicate symbols and break the component-export contract). What the SDK removes is the ~80 lines of mechanical glue that used to sit next to it: the `nexum_sdk::bind_host_via_wit_bindgen!()` declarative macro emits a `WitBindgenHost` struct, the trait impls over the generated import shims, the `Fault` / `ChainError` converters in both directions, a `Level` converter, a `From for nexum_sdk::events::Log` impl, and an `install_tracing()` helper that routes `tracing::info!(...)` through the bound host logging call. The adapter is capability-selected: the zero-argument form emits the full set for blanket-world modules, and the `caps: [chain, logging]` form (what `#[nexum_sdk::module]` generates from the manifest) emits only the pieces whose imports the module's world carries. ### The `#[nexum_sdk::module]` macro -`nexum-module-macros` ships one attribute macro, re-exported as -`nexum_sdk::module`. Apply it to an inherent `impl` block whose -methods are named event handlers - `init`, `on_block`, -`on_chain_logs`, `on_tick`, `on_message` - and the macro reads the -crate's `module.toml`, synthesizes the per-module world from its -`[capabilities]`, and generates the `wit_bindgen::generate!` call for -that world, the capability-selected `bind_host_via_wit_bindgen!` -invocation, a `Guest` implementation whose `on_event` dispatches to -whichever handlers are present (absent handlers become a no-op for -that event), and `export!`: +`nexum-module-macros` ships one attribute macro, re-exported as `nexum_sdk::module`. Apply it to an inherent `impl` block whose methods are named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - and the macro reads the crate's `module.toml`, synthesizes the per-module world from its `[capabilities]`, and generates the `wit_bindgen::generate!` call for that world, the capability-selected `bind_host_via_wit_bindgen!` invocation, a `Guest` implementation whose `on_event` dispatches to whichever handlers are present (absent handlers become a no-op for that event), and `export!`: ```rust // modules/examples/http-probe/src/lib.rs (shipped) @@ -181,49 +121,21 @@ impl HttpProbe { Two things worth being precise about: - **The world is derived from the manifest.** The macro generates - against a per-module world whose imports are exactly the - `[capabilities].required`/`optional` declarations: a chain + - local-store module simply has no `identity` bindings to call. Its - imports equal its declarations by construction, and the runtime's - capability check is a backstop rather than a consumer of toolchain - dead-import elision. +against a per-module world whose imports are exactly the `[capabilities].required`/`optional` declarations: a chain + local-store module simply has no `identity` bindings to call. Its imports equal its declarations by construction, and the runtime's capability check is a backstop rather than a consumer of toolchain dead-import elision. - **Handlers are synchronous.** `init` and the named handlers are - plain `fn`, called directly with no `block_on` wrapper. Modules - call `host.request(chain_id, method, params_json)` (or the - `chain::eth_call_params` / `parse_eth_call_result` helpers) directly - against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). - (Keeper handlers are the exception: see below.) - -The `Guest`/`export!` shape the macro emits follows the `strategy.rs` -(pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus -the macro attribute) split from ADR-0009. The keeper primitives in -`nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, -`Poller`, `Retrier` - give conditional-commitment modules -a shared set of `LocalStoreHost` conventions instead of hand-rolled -key schemes; `videre_sdk::keeper` assembles them into the generic -run. +plain `fn`, called directly with no `block_on` wrapper. Modules call `host.request(chain_id, method, params_json)` (or the `chain::eth_call_params` / `parse_eth_call_result` helpers) directly against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). (Keeper handlers are the exception: see below.) + +The `Guest`/`export!` shape the macro emits follows the `strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus the macro attribute) split from ADR-0009. The keeper primitives in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, `Poller`, `Retrier` - give conditional-commitment modules a shared set of `LocalStoreHost` conventions instead of hand-rolled key schemes; `videre_sdk::keeper` assembles them into the generic run. ## Venue persona: `videre-sdk` ### The venue contract -An adapter targets the `videre:venue/venue-adapter` world: it exports -the `adapter` interface (`body-versions`, `derive-header`, `quote`, -`submit`, `status`, `cancel`) plus `init`, and imports scoped -transport only - `chain`, `messaging`, and allowlisted `wasi:http`. -No local-store, remote-store, identity, or logging import: an adapter -structurally cannot touch host key material or persistent state. -Keepers reach venues through the host-implemented -`videre:venue/client` interface, which mirrors `adapter` with a venue -selector per call. The types (`intent-header`, `quotation`, -`submit-outcome`, `receipt`, `intent-status`, `venue-error`) live in -`videre:types`, over the `videre:value-flow` asset vocabulary. +An adapter targets the `videre:venue/venue-adapter` world: it exports the `adapter` interface (`body-versions`, `derive-header`, `quote`, `submit`, `status`, `cancel`) plus `init`, and imports scoped transport only - `chain`, `messaging`, and allowlisted `wasi:http`. No local-store, remote-store, identity, or logging import: an adapter structurally cannot touch host key material or persistent state. Keepers reach venues through the host-implemented `videre:venue/client` interface, which mirrors `adapter` with a venue selector per call. The types (`intent-header`, `quotation`, `submit-outcome`, `receipt`, `intent-status`, `venue-error`) live in `videre:types`, over the `videre:value-flow` asset vocabulary. ### Bodies: the `IntentBody` derive -A venue's intent body is opaque on the wire; typing is a guest-side -agreement between keeper and adapter, spelled as an outer per-venue -version enum under `#[derive(videre_sdk::IntentBody)]`: +A venue's intent body is opaque on the wire; typing is a guest-side agreement between keeper and adapter, spelled as an outer per-venue version enum under `#[derive(videre_sdk::IntentBody)]`: ```rust #[derive(videre_sdk::IntentBody)] @@ -232,36 +144,15 @@ enum EchoBody { } ``` -The wire form is the borsh enum layout: a one-byte version tag (the -variant's declaration index) then the borsh payload - so the tag order -is the schema: append new versions, never reorder. Decoding an unknown -tag fails typedly as `BodyError::UnknownVersion` rather than as a -stringly decode error. The versions an adapter decodes are declared in -its manifest `[venue] body_versions` and asserted at install against -its `body-versions` export; a keeper declares the single -`[venue] body_version` it encodes and install refuses it unless every -installed adapter decodes that version. +The wire form is the borsh enum layout: a one-byte version tag (the variant's declaration index) then the borsh payload - so the tag order is the schema: append new versions, never reorder. Decoding an unknown tag fails typedly as `BodyError::UnknownVersion` rather than as a stringly decode error. The versions an adapter decodes are declared in its manifest `[venue] body_versions` and asserted at install against its `body-versions` export; a keeper declares the single `[venue] body_version` it encodes and install refuses it unless every installed adapter decodes that version. ### `#[videre_sdk::venue]` -The single blessed venue authoring path. Apply it to the adapter's -`impl VenueAdapter for MyVenue` block: the macro reads the crate's -`module.toml`, asserts its `[module] kind` is `venue-adapter`, -synthesizes a per-component world exporting the `videre:venue/adapter` -face and importing exactly the manifest's declared scoped transport, -then emits the `wit_bindgen::generate!` call, the untouched trait -impl, and the export glue. An undeclared capability's bindings do not -exist (using one is a compile error), and a capability outside the -venue-permitted set (`chain`, `messaging`, `http`) is rejected at -expansion. The generated world remaps the shared interfaces onto -`videre_sdk::bindings`, so the impl speaks `videre_sdk` types directly -and shares type identity with the conformance kit and the client core. +The single blessed venue authoring path. Apply it to the adapter's `impl VenueAdapter for MyVenue` block: the macro reads the crate's `module.toml`, asserts its `[module] kind` is `venue-adapter`, synthesizes a per-component world exporting the `videre:venue/adapter` face and importing exactly the manifest's declared scoped transport, then emits the `wit_bindgen::generate!` call, the untouched trait impl, and the export glue. An undeclared capability's bindings do not exist (using one is a compile error), and a capability outside the venue-permitted set (`chain`, `messaging`, `http`) is rejected at expansion. The generated world remaps the shared interfaces onto `videre_sdk::bindings`, so the impl speaks `videre_sdk` types directly and shares type identity with the conformance kit and the client core. ### The typed client and `#[videre_sdk::keeper]` -The wire carries opaque bodies and a stringly venue selector; typing -is recovered in `videre_sdk::client`. A keeper names a venue once, as -a `Venue` marker carrying its `VenueId` and body schema: +The wire carries opaque bodies and a stringly venue selector; typing is recovered in `videre_sdk::client`. A keeper names a venue once, as a `Venue` marker carrying its `VenueId` and body schema: ```rust struct CowVenue; @@ -271,38 +162,15 @@ impl Venue for CowVenue { } ``` -`VenueClient` then drives the venue with typed bodies - `quote` -returns a `Quoted` typestate whose `submit` sends exactly the priced -bytes - encoding through `IntentBody` before the byte-level, -native-AFIT `VenueTransport` seam. `HostVenues` binds the seam to the -module's own `videre:venue/client` import; tests implement -`VenueTransport` in memory. - -`#[videre_sdk::keeper]` is the keeper mirror of `#[module]`: apply it -to an `impl` block whose associated functions are the event handlers -(`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, -`on_intent_status`). It requires the `client` capability (the -`videre:venue/client` import is what makes a keeper a keeper), wires -that import onto the SDK's shared shims, and lets handlers be `async` -so they can await the typed client directly; -`videre_sdk::client::poll_once` completes the futures on the -synchronous guest boundary. A -`From` impl onto the wire fault is emitted, so `?` -applies to client calls inside handlers. - -`videre_sdk::keeper::Keeper::run` assembles the world-neutral -`nexum_sdk::keeper` stores - `WatchSet` to `Gates` to -`Poller::poll` to `Retrier` to `Journal` - over the -`VenueTransport` seam, so a conditional-commitment keeper writes one -`poll` producing the shared `Outcome` outcome and inherits the whole -gate/journal/retry pass. +`VenueClient` then drives the venue with typed bodies - `quote` returns a `Quoted` typestate whose `submit` sends exactly the priced bytes - encoding through `IntentBody` before the byte-level, native-AFIT `VenueTransport` seam. `HostVenues` binds the seam to the module's own `videre:venue/client` import; tests implement `VenueTransport` in memory. + +`#[videre_sdk::keeper]` is the keeper mirror of `#[module]`: apply it to an `impl` block whose associated functions are the event handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`). It requires the `client` capability (the `videre:venue/client` import is what makes a keeper a keeper), wires that import onto the SDK's shared shims, and lets handlers be `async` so they can await the typed client directly; `videre_sdk::client::poll_once` completes the futures on the synchronous guest boundary. A `From` impl onto the wire fault is emitted, so `?` applies to client calls inside handlers. + +`videre_sdk::keeper::Keeper::run` assembles the world-neutral `nexum_sdk::keeper` stores - `WatchSet` to `Gates` to `Poller::poll` to `Retrier` to `Journal` - over the `VenueTransport` seam, so a conditional-commitment keeper writes one `poll` producing the shared `Outcome` outcome and inherits the whole gate/journal/retry pass. ### Testing: `nexum-sdk-test` and `videre-test` -Keeper strategy logic tests exactly as module logic does: against the -host traits with `nexum_sdk_test::MockHost`, plus an in-memory -`VenueTransport` for the client seam. Tests run as plain native Rust - -no `wasm32-wasip2` target, no wasmtime instance, no network. +Keeper strategy logic tests exactly as module logic does: against the host traits with `nexum_sdk_test::MockHost`, plus an in-memory `VenueTransport` for the client seam. Tests run as plain native Rust - no `wasm32-wasip2` target, no wasmtime instance, no network. ```rust use nexum_sdk::host::*; @@ -318,28 +186,20 @@ assert_eq!(host.chain.calls().len(), 1); Adapters are held to the `videre-test` conformance kit instead: - **`CodecVectors`** - the venue's `IntentBody` wire bytes as a JSON - file (bytes as lowercase hex). A Rust adapter checks its derived - enum with `assert_conforms`; a non-Rust author reads the same file - and proves byte-exactness without linking Rust. +file (bytes as lowercase hex). A Rust adapter checks its derived enum with `assert_conforms`; a non-Rust author reads the same file and proves byte-exactness without linking Rust. - **`HeaderGoldens`** - published bodies paired with the header a - conforming `derive-header` projects from them. +conforming `derive-header` projects from them. - **`MockTransport`** - the three transports an adapter is granted - (chain, messaging, outbound HTTP) as programmable in-memory mocks - behind the SDK's own seams. +(chain, messaging, outbound HTTP) as programmable in-memory mocks behind the SDK's own seams. -(The runtime crate separately ships a feature-gated component-level -harness - `nexum-runtime`'s `test_utils::TestRuntime` - that loads a -compiled `.wasm` plus manifest under real wasmtime; that is -runtime-internal tooling, not part of either SDK contract.) +(The runtime crate separately ships a feature-gated component-level harness - `nexum-runtime`'s `test_utils::TestRuntime` - that loads a compiled `.wasm` plus manifest under real wasmtime; that is runtime-internal tooling, not part of either SDK contract.) ## Walkthrough: authoring a venue on videre -The shipped reference pair is `modules/examples/echo-venue` (adapter) -and `modules/examples/echo-keeper` (driver); the production instance -of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. +The shipped reference pair is `modules/examples/echo-venue` (adapter) and `modules/examples/echo-keeper` (driver); the production instance of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. 1. **Declare the manifest.** A venue adapter is a component with a - `module.toml` whose kind names it: +`module.toml` whose kind names it: ```toml [module] @@ -359,7 +219,7 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. ``` 2. **Implement `VenueAdapter`.** One impl block under the macro; the - five intent functions plus `init` and `body_versions`: +five intent functions plus `init` and `body_versions`: ```rust use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, VenueError}; @@ -378,18 +238,13 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. } ``` - `derive_header` is the policy seam: it projects the guard-facing - `IntentHeader` (`gives`, `wants`, `settlement`, `authorisation`) - from a body, pure and I/O-free, and the host's egress guard runs on - it before every submit. +`derive_header` is the policy seam: it projects the guard-facing `IntentHeader` (`gives`, `wants`, `settlement`, `authorisation`) from a body, pure and I/O-free, and the host's egress guard runs on it before every submit. 3. **Publish the fixtures.** Ship the venue's codec vectors and header - goldens, and hold the adapter to them with `videre-test` in the - crate's tests. Non-Rust keeper authors read the same files. +goldens, and hold the adapter to them with `videre-test` in the crate's tests. Non-Rust keeper authors read the same files. 4. **Build and install.** Build the cdylib for `wasm32-wasip2` - (`cargo build --target wasm32-wasip2 --release -p echo-venue`) and - install it via the engine config: +(`cargo build --target wasm32-wasip2 --release -p echo-venue`) and install it via the engine config: ```toml [[adapters]] @@ -398,13 +253,10 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. http_allow = [] # the operator's outbound-HTTP grant ``` - Install boots the component, checks the `body-versions` handshake - against the manifest, and registers the venue under its manifest - name in the `VenueRegistry` ([doc 08](08-platform-generalisation.md)). +Install boots the component, checks the `body-versions` handshake against the manifest, and registers the venue under its manifest name in the `VenueRegistry` ([doc 08](08-platform-generalisation.md)). 5. **Drive it from a keeper.** A keeper declares the `client` - capability plus its `[venue] body_version`, names the venue as a - `Venue` marker, and speaks types end to end: +capability plus its `[venue] body_version`, names the venue as a `Venue` marker, and speaks types end to end: ```rust #[videre_sdk::keeper] @@ -420,54 +272,32 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. } ``` - An accepted submit is watched implicitly: the registry polls the - adapter's `status` and fans transitions back as `intent-status` - events, which the keeper subscribes to in its manifest and handles - in `on_intent_status`. +An accepted submit is watched implicitly: the registry polls the adapter's `status` and fans transitions back as `intent-status` events, which the keeper subscribes to in its manifest and handles in `on_intent_status`. ## The CoW venue -CoW ships as the production instance of the persona, in two crates so -the venue stays orderbook-only: +CoW ships as the production instance of the persona, in two crates so the venue stays orderbook-only: - **`cow-venue`** - feature slices. `body` (default, `no_std`): the - order intent body types and codec, light enough for any keeper or - adapter to carry. `client`: the typed `CowClient` bound to the CoW - venue, the deterministic `intent_id` journal key, and the - table-driven retry classification generated from the shipped - `data/classification.toml`. `assembly`: the chain-edge order - projections and orderbook submission bodies. `adapter`: the venue - adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, - manifest at `crates/cow-venue/module.toml`). +order intent body types and codec, light enough for any keeper or adapter to carry. `client`: the typed `CowClient` bound to the CoW venue, the deterministic `intent_id` journal key, and the table-driven retry classification generated from the shipped `data/classification.toml`. `assembly`: the chain-edge order projections and orderbook submission bodies. `adapter`: the venue adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, manifest at `crates/cow-venue/module.toml`). - **`composable-cow`** - the ComposableCoW keeper machinery, kept out - of the venue: the conditional-order `ComposableBody`, the structured - poll seam (`Verdict`, with the deployed 1.x reverting wire - quarantined behind `LegacyRevertAdapter`, per - [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the - `run` slice composing the poll loop over the typed `CowClient`. +of the venue: the conditional-order `ComposableBody`, the structured poll seam (`Verdict`, with the deployed 1.x reverting wire quarantined behind `LegacyRevertAdapter`, per [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the `run` slice composing the poll loop over the typed `CowClient`. -The shipped CoW keepers - `modules/twap-monitor`, -`modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` -modules on this surface. +The shipped CoW keepers - `modules/twap-monitor`, `modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` modules on this surface. ## Non-Rust module and adapter authors -For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is -relevant - they generate bindings directly from the WIT package for -their target world with their language's `wit-bindgen`, and prove body -conformance against the venue's published `videre-test` fixture files. -The WIT is the universal contract; both Rust SDKs are an ergonomics -layer on top of it, not a requirement. +For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is relevant - they generate bindings directly from the WIT package for their target world with their language's `wit-bindgen`, and prove body conformance against the venue's published `videre-test` fixture files. The WIT is the universal contract; both Rust SDKs are an ergonomics layer on top of it, not a requirement. ## Where to go next - [`sdk.md`](sdk.md) - the day-to-day API reference and rustdoc entry - point. +point. - [ADR-0009](adr/0009-host-trait-surface.md) - the host-trait seam - decision this document builds on. +decision this document builds on. - [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed - error model the host traits return. +error model the host traits return. - [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough - design and why module authors call `host.request` directly. +design and why module authors call `host.request` directly. - [doc 08](08-platform-generalisation.md) - the layered WIT and why - venue adapters are the domain-extension mechanism. +venue adapters are the domain-extension mechanism. diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index 25246aac..117e636d 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -1,1243 +1,78 @@ -# RPC Namespace Design: Generic JSON-RPC Passthrough +# RPC Namespace Design: the `chain` interface -> **Status: Partially shipped.** The reference runtime ships the single -> `chain::request(chain_id, method, params)` WIT entry point, and the -> seam behind it is typed to a closed read-only method set: a method -> outside that surface is refused with a `chain-error` carrying a `denied` fault before it -> reaches the provider. The rest of the "Method Allowlisting" section -> below remains design intent: per-module `[module.chain] -> extra_allowed_methods` (now bounded by the typed surface rather than -> free-form) and identity-delegated signing methods are not wired into -> `chain::request` in the shipped binary. +Modules reach chain state through one host function, `chain.request`, plus a batch form `chain.request-batch`. A single generic JSON-RPC entry point means no WIT change per method: the guest SDK layers an alloy `Provider` on top, so every read method on the permitted surface works without host-side per-method plumbing. -> **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 `chain-error` rather than the 0.1-era `json-rpc-error`. -> -> **SDK-shape note (0.2):** The macro-driven authoring model below (`#[nexum::module]` / `#[shepherd::module]` with named event handlers and `&RootProvider` injection) and the separate `nexum-sdk` crate are **future direction, not in 0.2 scope** - see [ADR-0009](adr/0009-host-trait-surface.md) for the shipped host-trait seam that replaces the macro design. 0.2 modules call `host.request(chain_id, method, params_json)` directly against `ChainHost`. The WIT contract for `chain` is unchanged; only the guest-side ergonomics differ. +## The WIT interface -## Problem Statement - -The 0.1 design started with a `blockchain` interface that defined individual functions for each Ethereum RPC method: - -```wit -interface blockchain { - eth-call: func(chain-id: chain-id, to: list, data: list) -> result, string>; - eth-get-logs: func(filter: log-filter) -> result, string>; - eth-block-number: func(chain-id: chain-id) -> result; -} -``` - -This creates several problems: - -1. **Boilerplate multiplication.** Every new `eth_` method requires changes in three places: WIT definition, host trait implementation, and SDK wrapper. The Ethereum JSON-RPC namespace has 30+ methods; most modules will need more than the three currently exposed. - -2. **Alloy incompatibility.** Module authors using Rust cannot use alloy's `Provider` API - which provides 80+ typed convenience methods - because the transport layer is locked behind per-method WIT functions. They're forced to manually ABI-encode calldata, call `blockchain::eth_call`, and ABI-decode the result for every interaction. - -3. **Namespace rigidity.** Adding a `cow_` namespace for CoW Protocol API calls would duplicate the same per-method pattern. Future namespaces (debug_, trace_, etc.) compound this further. - -The goal: **one WIT function to rule the entire `eth_` namespace**, with a guest-side SDK that gives module authors the full alloy `Provider` API - no manual ABI wrangling, no WIT changes when new methods are needed. - -## Design: Generic JSON-RPC Passthrough - -### Core Insight - -alloy's `Transport` trait is a Tower `Service`. If we expose a single JSON-RPC dispatch function in WIT, the SDK can implement `Transport` on top of it. This gives guest modules the entire alloy `Provider` API for free - every current and future `eth_` method works automatically. - -From the guest's perspective, host function calls are synchronous (they block until the host returns). The returned future resolves in a single poll. This means alloy's async `Provider` methods work with a trivial executor - no real async machinery needed. - -### Architecture - -```mermaid -flowchart TD - A["Module author code - provider.get_block_number() - provider.call(tx).latest() - provider.get_logs(&filter)"] -->|full alloy Provider API| B - - B["HostTransport (SDK) - implements alloy Transport trait"] -->|"chain::request(chain_id, "eth_blockNumber", "[]")"| C - - C["WIT boundary - single generic function"] --> D - - D["Host chain::request impl - forwards to alloy provider"] -->|"provider.raw_request_dyn(method, params)"| E - - E["Alloy provider stack - timeout -> retry -> rate-limit -> fallback -> RPC"] -``` - -## Updated WIT Interface - -Replace the `blockchain` interface with `chain`: +`nexum:host/chain` (`wit/nexum-host/chain.wit`): ```wit -package nexum:host@0.1.0; - interface chain { use types.{chain-id, fault}; - /// A structured JSON-RPC error carrying the node code and revert bytes. record rpc-error { code: s32, message: string, data: option> } - - /// Either a shared host `fault` or a structured JSON-RPC error. variant chain-error { fault(fault), rpc(rpc-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. The method string should include the namespace prefix - /// (e.g. "eth_call", "eth_getBlockByNumber"). - /// - /// `params` and the success return value are JSON-encoded strings matching - /// the JSON-RPC specification. The host handles id/jsonrpc framing; the - /// guest only provides method + params and receives the `result` field. + record rpc-request { method: string, params: string } + variant rpc-result { ok(string), err(chain-error) } + request: func(chain-id: chain-id, method: string, params: string) -> 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>, chain-error>; + request-batch: func(chain-id: chain-id, requests: list) + -> result, chain-error>; } ``` -Errors are reported via `chain-error`: either a shared `fault` (see doc 00 and ADR-0011) or a structured `rpc` case carrying the node code and decoded revert bytes - the 0.1 `json-rpc-error` shape is gone. Modules match on the `fault` case (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) for retry/backoff, and on the `rpc` case to decode a revert without parsing numeric JSON-RPC codes by hand. - -The `types` interface now exposes the shared `fault` / `rate-limit`. The `local-store`, `remote-store`, `messaging`, and `logging` interfaces are unchanged in shape (the first three report `fault` directly). - -The `identity` interface provides cryptographic identity - key management and signing: - -```wit -interface identity { - use types.{fault}; - - /// Get available signing accounts (20-byte Ethereum addresses). - accounts: func() -> result>, fault>; +`method` carries the namespace prefix (`eth_call`). `params` and the success value are JSON strings; the host frames the id/jsonrpc envelope. A failure is a `chain-error`: either a shared host `fault` (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) that a module matches for retry and backoff, or a structured `rpc` case carrying the node code and the host-decoded revert bytes so a revert reads without parsing numeric JSON-RPC codes by hand. `request-batch` runs several calls against one chain in a single round trip where the transport supports it, falling back to sequential `request` otherwise; the result list matches `requests` in length and order, each entry independently `ok` or `err`. - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r ‖ s ‖ v). - sign: func(account: list, data: list) -> result, fault>; +## Permitted method surface - /// Sign EIP-712 typed data with the specified account. - sign-typed-data: func(account: list, typed-data: string) -> result, fault>; -} -``` +The reference server host forwards only a closed read-only set, the `ChainMethod` enum in `nexum-world`. Host dispatch and the guest-side allowlist re-export the same type, so the two cannot drift. A method outside the set, which is every signing or mutating method, is refused with a `denied` fault before it reaches the provider. -The universal `event-module` world (in `nexum:host`) contains the platform-agnostic interfaces - six imports in 0.2: +The surface is `eth_blockNumber`, `eth_call`, `eth_chainId`, `eth_estimateGas`, `eth_feeHistory`, `eth_gasPrice`, `eth_maxPriorityFeePerGas`, `eth_getBalance`, `eth_getBlockByHash`, `eth_getBlockByNumber`, `eth_getBlockReceipts`, `eth_getCode`, `eth_getLogs`, `eth_getProof`, `eth_getStorageAt`, `eth_getTransactionByHash`, `eth_getTransactionCount`, `eth_getTransactionReceipt`, and `net_version`. -```wit -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 messaging; - import logging; +Enforcement is host-side string-to-`ChainMethod` resolution, not a compile-time guarantee. The Component Model already sandboxes I/O, so a chain-capable module can only call `chain.request`, and the closed surface adds method-level defence in depth on top. - export init: func(config: types.config) -> result<_, fault>; - export on-event: func(event: types.event) -> result<_, fault>; -} -``` +## Signing -The CoW-specific `shepherd` world (in `shepherd:cow`) extends it with the merged `cow-api` interface: +`chain.request` neither signs nor delegates signing; signing methods simply fall outside the read surface. Signing is the separate `nexum:host/identity` interface: ```wit -world shepherd { - include nexum:host/event-module; - import cow-api; -} -``` - -### What This Replaces - -| Before (per-method) | After (generic) | -|---|---| -| `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`) - -- The Ethereum JSON-RPC spec is JSON. alloy serialises params to JSON internally. Using `string` means zero intermediate format - the guest produces JSON, the host forwards JSON to alloy's `raw_request_dyn` which accepts `&RawValue` (a JSON string). -- Debuggability: JSON is human-readable in logs and traces. -- The canonical ABI cost of copying a JSON string across the component boundary is negligible relative to the network RTT of an actual RPC call. -- Binary encoding (CBOR, postcard) would require custom (de)serialisation on both sides, defeating the purpose of minimising boilerplate. - -## Host Implementation - -The host implementation is minimal - one function handles the entire `eth_` namespace: - -```rust -use serde_json::value::RawValue; - -impl nexum::host::chain::Host for NexumHostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> 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; - } - - // 2. Method allowlisting for read-only methods - if !self.is_read_method_allowed(&method) { - return Ok(Err(ChainError::Fault(Fault::Denied(format!( - "method not allowed: {method}" - ))))); - } - - // 3. Resolve the provider for this chain - let provider = self.provider_for(chain_id).map_err(|_| { - ChainError::Fault(Fault::Unsupported(format!("unknown chain: {chain_id}"))) - })?; - - // 4. Parse params as raw JSON and forward to alloy - let raw_params: Box = RawValue::from_string(params) - .map_err(|e| wasmtime::Error::msg(format!("invalid JSON params: {e}")))?; - - // A structured JSON-RPC error folds to ChainError::Rpc (node code + - // decoded revert bytes); a transport failure folds to a Fault. - match provider.raw_request_dyn(method.into(), &raw_params).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } -} -``` - -That's it. The alloy provider already has the timeout/retry/rate-limit/fallback tower stack configured per chain (see doc 01). Every read-only `eth_*` method automatically inherits that middleware. - -### Method Allowlisting - -> **Status: Future direction (0.3+ target).** The shipped 0.2 host -> implementation of `chain::request` forwards any method string to -> the alloy provider; it does **not** consult a read-only allowlist -> and it does **not** intercept signing methods to delegate to the -> identity backend. The categorisation below is the planned 0.3 -> enforcement model. Until that tracking issue lands the gating -> code, operators must treat any chain-capable module as having -> access to the full RPC surface their configured provider exposes. - -The host maintains two categories of methods: **read-only methods** (always allowed through the RPC passthrough) and **signing methods** (delegated to the `identity` backend). - -#### Read-Only Methods (RPC Passthrough) - -```rust -impl NexumHostState { - fn is_read_method_allowed(&self, method: &str) -> bool { - // Default allowlist: read-only eth_ methods - matches!(method, - "eth_blockNumber" - | "eth_call" - | "eth_chainId" - | "eth_estimateGas" - | "eth_feeHistory" - | "eth_gasPrice" - | "eth_maxPriorityFeePerGas" - | "eth_getBalance" - | "eth_getBlockByHash" - | "eth_getBlockByNumber" - | "eth_getBlockReceipts" - | "eth_getCode" - | "eth_getLogs" - | "eth_getProof" - | "eth_getStorageAt" - | "eth_getTransactionByHash" - | "eth_getTransactionCount" - | "eth_getTransactionReceipt" - // net_ methods - | "net_version" - ) - } -} -``` - -This could be made configurable per-module via `module.toml`: - -```toml -[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 `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 `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 { - fn is_signing_method(&self, method: &str) -> bool { - matches!(method, - "eth_sendTransaction" - | "eth_accounts" - | "eth_signTypedData_v4" - | "personal_sign" - ) - } -} -``` - -These methods are deliberately **not** in the read-only allowlist. They follow a completely different code path through the identity backend. - -### Identity Delegation Flow - -When a module calls a signing method through `chain::request`, the host intercepts it and delegates to the `Identity` trait: - -```mermaid -sequenceDiagram - participant M as Module (guest) - participant C as CsnHost - participant I as Identity backend - participant R as RPC provider - - 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) - I-->>C: 65-byte signature (r ‖ s ‖ v) - C->>C: Assemble signed transaction (RLP-encode with signature) - C->>R: eth_sendRawTransaction(signed_tx) - R-->>C: tx_hash - C-->>M: Ok(tx_hash) -``` - -The key insight: modules never call `eth_sendRawTransaction` directly (it's not in the read-only allowlist). Instead, `eth_sendTransaction` is intercepted by the host, which uses the `identity` backend to sign, then broadcasts the signed transaction itself. - -This pattern applies to all signing methods: - -| Method | Identity Delegation | -|---|---| -| `eth_accounts` | Returns accounts from `Identity::accounts()` | -| `eth_sendTransaction` | Signs the transaction via `Identity::sign()`, broadcasts via `eth_sendRawTransaction` | -| `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 ChainHost - -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 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>, 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, IdentityBackendError>; - - /// Sign EIP-712 typed data with the specified account. - fn sign_typed_data(&self, account: &[u8], typed_data: &str) -> Result, IdentityBackendError>; -} - -/// The host state is generic over the identity backend. -pub struct ChainHost { - providers: HashMap, - identity: I, -} - -impl nexum::host::chain::Host for ChainHost { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> 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(ChainError::Fault(Fault::Denied(format!( - "method not allowed: {method}" - ))))); - } - - let provider = self.provider_for(chain_id)?; - let raw_params: Box = RawValue::from_string(params) - .map_err(|e| wasmtime::Error::msg(format!("invalid JSON params: {e}")))?; - - match provider.raw_request_dyn(method.into(), &raw_params).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } -} - -impl ChainHost { - /// Dispatch signing methods to the identity backend. - async fn dispatch_signing( - &self, - chain_id: u64, - method: &str, - params: &str, - ) -> wasmtime::Result> { - match method { - "eth_accounts" => { - let accounts = self.identity.accounts() - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - let hex_accounts: Vec = accounts - .iter() - .map(|a| format!("0x{}", hex::encode(a))) - .collect(); - Ok(Ok(serde_json::to_string(&hex_accounts)?)) - } - - "eth_sendTransaction" => { - let provider = self.provider_for(chain_id)?; - // Parse the transaction params - let tx_params: Vec = serde_json::from_str(params)?; - let tx = &tx_params[0]; - - let from = parse_address(tx.get("from"))?; - - // Fill missing fields (nonce, gas, etc.) via the provider - let filled_tx = self.fill_transaction(provider, tx).await?; - - // 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| ChainError::Fault(Fault::Internal(e.to_string())))?; - - // Assemble signed transaction and broadcast - let signed_tx = filled_tx.with_signature(&signature); - let raw_tx = signed_tx.rlp_encode(); - - let raw_params = serde_json::to_string(&[format!("0x{}", hex::encode(&raw_tx))])?; - 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())), - } - } - - "eth_signTypedData_v4" => { - let params_arr: Vec = serde_json::from_str(params)?; - let account = parse_address(¶ms_arr[0])?; - let typed_data = params_arr[1].to_string(); - - let signature = self.identity.sign_typed_data(&account, &typed_data) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) - } - - "personal_sign" => { - let params_arr: Vec = serde_json::from_str(params)?; - let data = parse_hex_bytes(¶ms_arr[0])?; - let account = parse_address(¶ms_arr[1])?; - - // EIP-191 prefix - let prefixed = format!("\x19Ethereum Signed Message:\n{}", data.len()); - let mut msg = prefixed.into_bytes(); - msg.extend_from_slice(&data); - let hash = keccak256(&msg); - - let signature = self.identity.sign(&account, &hash) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) - } - - _ => Ok(Err(ChainError::Fault(Fault::InvalidInput(format!( - "unknown signing method: {method}" - ))))), - } - } -} -``` - -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. The interface is the failure domain, so it reports a plain `fault`: - -```rust -impl nexum::host::identity::Host for ChainHost { - fn accounts(&mut self) -> wasmtime::Result>, Fault>> { - // From picks unavailable/denied/internal. - Ok(self.identity.accounts().map_err(Fault::from)) - } - - fn sign( - &mut self, - account: Vec, - data: Vec, - ) -> wasmtime::Result, Fault>> { - Ok(self.identity.sign(&account, &data).map_err(Fault::from)) - } - - fn sign_typed_data( - &mut self, - account: Vec, - typed_data: String, - ) -> wasmtime::Result, Fault>> { - Ok(self.identity.sign_typed_data(&account, &typed_data).map_err(Fault::from)) - } -} -``` - -## Guest SDK: `HostTransport` - -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 - -```rust -use alloy_json_rpc::{ - ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, - SerializedRequest, -}; -use alloy_transport::{BoxTransport, Transport, TransportError, TransportFut}; -use tower::Service; -use std::task::{Context, Poll}; - -/// An alloy-compatible transport that routes JSON-RPC requests through the -/// 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 { - chain_id: u64, -} - -impl HostTransport { - pub fn new(chain_id: u64) -> Self { - Self { chain_id } - } -} - -impl Service for HostTransport { - type Response = ResponsePacket; - type Error = TransportError; - type Future = TransportFut<'static>; - - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - // Always ready - host function calls are synchronous from the guest. - Poll::Ready(Ok(())) - } - - fn call(&mut self, req: RequestPacket) -> Self::Future { - let chain_id = self.chain_id; - Box::pin(async move { - match req { - RequestPacket::Single(req) => { - let resp = dispatch_single(chain_id, &req)?; - Ok(ResponsePacket::Single(resp)) - } - RequestPacket::Batch(reqs) => { - // 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)) - } - } - }) - } -} - -impl Transport for HostTransport { - fn boxed(self) -> BoxTransport - where - Self: Sized + Clone + Send + Sync + 'static, - { - BoxTransport::new(self) - } -} - -/// Dispatch a single JSON-RPC request through the host function. -fn dispatch_single( - chain_id: u64, - req: &SerializedRequest, -) -> Result>, TransportError> { - let method = req.method(); - let params_json = req.params().map(|p| p.get()).unwrap_or("[]"); - - // 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 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"))?; - Ok(Response { - id: req.id().clone(), - payload: ResponsePayload::Success(payload), - }) - } - Err(e) => { - // Map the chain-error onto an alloy error payload. A structured - // `rpc` case carries the node code and decoded revert bytes; a - // shared `fault` becomes a generic -32000 with the fault message. - let payload = match e { - ChainError::Rpc(rpc) => ErrorPayload { - code: rpc.code as i64, - message: rpc.message, - data: rpc.data.map(|d| { - RawValue::from_string(format!("\"0x{}\"", hex::encode(d))).unwrap() - }), - }, - ChainError::Fault(fault) => ErrorPayload { - code: -32000, - message: fault.to_string(), - data: None, - }, - }; - Ok(Response { - id: req.id().clone(), - payload: ResponsePayload::Failure(payload), - }) - } - } -} -``` - -### Why This Works Without Real Async - -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: - -```rust -/// Drive a future to completion. Since the HostTransport resolves -/// synchronously, this is a single-poll operation - no actual async -/// scheduling occurs. -pub fn block_on(future: F) -> F::Output { - futures_executor::block_on(future) -} -``` - -`futures-executor` is no-std-compatible and adds no meaningful overhead. - -### Provider Constructor - -```rust -use alloy_provider::RootProvider; -use alloy_rpc_client::RpcClient; - -/// 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 -/// the host's RPC stack (timeout, retry, rate-limit, failover). -/// -/// ```rust -/// let provider = nexum_sdk::provider(42161); -/// let block = provider.get_block_number().await?; -/// ``` -pub fn provider(chain_id: u64) -> RootProvider { - let transport = HostTransport::new(chain_id); - let client = RpcClient::new(transport, false); // false = not local - RootProvider::new(client) -} -``` - -## Eliminating `block_on`: Async Module Functions - -### The Problem - -alloy's `Provider` is async. Without help, module authors would need `block_on()` around every RPC call: - -```rust -let block_num = block_on(provider.get_block_number())?; // noisy -let balance = block_on(provider.get_balance(addr).latest())?; // everywhere -``` - -This is verbose and obscures the actual logic. But we can't reimplement every `Provider` method as a synchronous wrapper - that defeats the purpose of the generic passthrough. - -### The Solution: Named Event Handlers + `async fn` - -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_chain_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. - -**What the module author writes (universal module):** - -```rust -#[nexum::module] -struct MyModule; - -impl MyModule { - 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_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - for log in &logs { - // ... - } - Ok(()) - } - - // on_tick / on_message not defined -> those events are silently ignored -} -``` - -**What the module author writes (CoW module):** - -```rust -#[shepherd::module] -struct MyModule; - -impl MyModule { - 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(()) - } -} -``` - -**What the macro generates:** - -```rust -impl Guest for MyModule { - 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); - MyModule::on_block(block, &provider).await - } - Event::ChainLogs(batch) => { - let provider = nexum_sdk::provider(batch.chain_id); - MyModule::on_chain_logs(batch.logs, &provider).await - } - Event::Tick(_) => Ok(()), // no handler defined - Event::Message(_) => Ok(()), // no handler defined - } - }) - } -} -``` - -The generated code calls `block_on` exactly once - at the top-level export boundary. Inside the async block, all `.await` calls resolve immediately (the `HostTransport` is synchronous under the hood). No real async scheduler runs. No tokio. No waker machinery. It's syntactic sugar that costs nothing at runtime. - -### Named Handler Conventions - -| Handler | Payload | Optional injectable context | -|---|---|---| -| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_chain_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)` -- **No second parameter** -> pass only the payload -- **Async handlers** -> wrapped in `block_on`; sync handlers called directly -- **Missing handlers** -> `Ok(())` for that variant (no-op) - -**Escape hatch:** defining `on_event` directly takes precedence - the macro uses it as-is (wrapping in `block_on` if async) and ignores named handlers. - -### Why This Works - -1. **WIT exports are synchronous.** The Component Model export signature is `func(event) -> result<_, string>` - no async. The macro bridges this by wrapping the generated dispatch in `block_on`. - -2. **The transport resolves in one poll.** `HostTransport::call()` returns a future whose body is entirely synchronous (it calls the WIT host function, which blocks). When alloy's `Provider` awaits the transport, the future completes immediately. - -3. **`futures_executor::block_on` is trivial.** It creates a waker, polls the future once, gets `Poll::Ready`. No thread parking, no event loop. On WASM single-threaded targets this is a no-op wrapper. - -4. **Composability.** Module authors can use alloy's builder patterns naturally inside any handler: - - ```rust - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // EthCall builder - .latest() and .await both work - let result = provider.call(tx).latest().await?; - - // Filter builder - standard alloy ergonomics - let logs = provider.get_logs(&filter).await?; - - // Raw request for unlisted methods - let proof: EIP1186AccountProofResponse = provider - .raw_request("eth_getProof".into(), (addr, keys, "latest")) - .await?; - Ok(()) - } - ``` - -5. **Sync handlers still work.** Handlers that don't need RPC can be plain `fn`: - - ```rust - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } - ``` - -### Comparison - -| Approach | Event dispatch boilerplate | RPC call boilerplate | New methods need shimming? | alloy-native? | -|---|---|---|---|---| -| Manual `on_event` + `block_on()` | `match event { ... }` every module | `block_on(...)` every call | No | Yes | -| **Named handlers + async macro** | **None (generated)** | **None (`.await`)** | **No** | **Yes** | - -The named handler + async macro approach eliminates boilerplate at both the event dispatch level and the RPC call level. - -## Module Author Experience - -### Before (Per-Method WIT) - -```rust -use nexum_sdk::prelude::*; -use nexum_sdk::abi::sol; - -sol! { - function balanceOf(address owner) view returns (uint256); -} - -#[nexum::module] -struct MyModule; - -impl MyModule { - fn on_event(event: Event) -> Result<()> { - if let Event::Block(block) = event { - // Manual ABI encode - let calldata = balanceOfCall { owner: addr }.abi_encode(); - - // Raw host call - returns opaque bytes - let result_bytes = blockchain::eth_call( - block.chain_id, - &token_addr.to_vec(), - &calldata, - )?; - - // Manual ABI decode - let balance = balanceOfCall::abi_decode_returns(&result_bytes)?; - - // Want eth_getBalance? Not available. Want eth_getCode? Not available. - // Each new method needs WIT + host + SDK changes. - } - Ok(()) - } -} -``` - -### After (Generic RPC + named handlers + provider injection) - -```rust -use nexum_sdk::prelude::*; - -sol! { - function balanceOf(address owner) view returns (uint256); -} - -#[nexum::module] -struct MyModule; - -impl MyModule { - // Named handler - macro generates the match dispatch + provider injection - 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?; - let code = provider.get_code_at(contract).latest().await?; - - // Typed contract calls with the EthCall builder - let tx = TransactionRequest::default() - .to(token_addr) - .input(balanceOfCall { owner: addr }.abi_encode().into()); - - let result = provider.call(tx).latest().await?; - let balance = balanceOfCall::abi_decode_returns(&result)?; - - // Log queries with alloy's Filter builder - let filter = Filter::new() - .address(contract) - .event_signature(Transfer::SIGNATURE_HASH) - .from_block(block.number - 100); - let logs = provider.get_logs(&filter).await?; - - // Raw request for anything not wrapped by Provider - let proof: EIP1186AccountProofResponse = provider - .raw_request("eth_getProof".into(), (addr, keys, "latest")) - .await?; - - Ok(()) - } - - // Only implement handlers for event types you care about. - // No on_chain_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-api` Namespace - -CoW Protocol's API is REST-based, not JSON-RPC. Two options: - -### Option A: Separate REST Interface (Recommended - chosen for 0.2) - -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: - -```wit -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - /// A non-2xx reply with no typed rejection envelope; `body` is raw text. - record http-failure { status: u16, body: option } - - /// A typed orderbook rejection, parsed host-side from `{errorType, description}`. - record order-rejection { status: u16, error-type: string, description: string, data: option } - - /// A shared host `fault`, a raw HTTP failure, or a typed order rejection. - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - /// HTTP-style request to the CoW Protocol API. - /// - /// The host routes to the correct CoW API base URL for the given chain - /// (e.g. https://api.cow.fi/mainnet for chain 1, /arbitrum for chain - /// 42161). The path is relative to the base URL. - /// - /// method: "GET" | "POST" | "PUT" | "DELETE" - /// path: relative API path, e.g. "/api/v1/orders" - /// body: optional JSON request body - /// - /// Returns the response body as a JSON string. - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> 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 { - include nexum:host/event-module; - import cow-api; -} -``` - -The host implementation is similarly minimal: - -```rust -impl shepherd::cow::cow_api::Host for NexumHostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - path: String, - body: Option, - ) -> wasmtime::Result> { - let base_url = self.cow_api_url_for(chain_id)?; - let url = format!("{base_url}{path}"); - - let req = self.http_client.request(method.parse()?, &url); - let req = match body { - Some(b) => req.header("content-type", "application/json").body(b), - None => req, - }; - - let resp = req.send().await - .map_err(|e| CowApiError::Fault(Fault::Unavailable(e.to_string())))?; - let status = resp.status().as_u16(); - - if status >= 400 { - // A non-2xx with no typed rejection envelope surfaces as `http` - // so a caller matches on `status` (e.g. 404) and reads `body` - // only for diagnostics; submit-order parses the orderbook's - // `{errorType, description}` into the `rejected` case instead. - let body = resp.text().await.ok(); - return Ok(Err(CowApiError::Http(HttpFailure { status, body }))); - } - - Ok(Ok(resp.text().await.unwrap_or_default())) - } +interface identity { + use types.{fault}; + accounts: func() -> result>, fault>; + sign: func(account: list, message: list) -> result, fault>; + sign-typed-data: func(account: list, typed-data: string) -> result, fault>; } ``` -### Option B: JSON-RPC Style (Unified) - -Route `cow_*` methods through the same `chain::request` function: - -```rust -// Guest usage (illustrative): -let order_uid: String = block_on(provider.raw_request( - "cow_submitOrder".into(), - serde_json::json!({ "sellToken": "0x...", "buyToken": "0x...", ... }), -))?; -``` - -The host would dispatch by method prefix: +`accounts` returns the 20-byte addresses the host will sign for (an empty list means no signing capability); `sign` applies `personal_sign` semantics (the EIP-191 prefix) and returns a 65-byte signature; `sign-typed-data` signs an EIP-712 JSON payload. `nexum-sdk`'s `IdentityHost` trait mirrors the interface one-for-one. -```rust -async fn request(&mut self, chain_id: u64, method: String, params: String) - -> 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(ChainError::Fault(Fault::InvalidInput("unknown namespace".into())))) - } -} -``` +## Guest SDK: the alloy provider seam -**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. +`nexum_sdk::chain` fronts `chain.request` with an alloy `Provider`, so strategy code calls typed provider methods instead of hand-building JSON-RPC: -### SDK: `Cow` +- `HostTransport` implements alloy's `Service` over any `ChainHost`, dispatching single requests through `chain.request` and batches through `chain.request-batch`. +- `ProviderHost::provider(chain)` mints an alloy `RootProvider` over that transport, blanket-implemented for every cloneable `ChainHost`. +- `block_on` drives the returned futures. The transport is a synchronous WIT import, so a future resolves on its first poll; a `Pending` panics, signalling that an alloy layer awaiting a reactor or timer has been introduced. ```rust -/// Typed client for the CoW Protocol API, backed by the host engine. -pub struct Cow { - chain_id: u64, -} - -impl Cow { - pub fn new(chain_id: u64) -> Self { - Self { chain_id } - } - - /// Submit an order via the typed cow-api::submit-order function. - pub fn submit_order(&self, order: &OrderCreation) -> Result { - let bytes = postcard::to_allocvec(order)?; - let uid = cow_api::submit_order(self.chain_id, &bytes)?; - Ok(uid.parse()?) - } +use nexum_sdk::chain::{Chain, ProviderHost, block_on}; - /// Get an order by UID. - pub fn get_order(&self, uid: &OrderUid) -> Result { - 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_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_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_api::request(self.chain_id, method, path, body)?) - } -} +let provider = host.provider(Chain::mainnet()); +let block = block_on(provider.get_block_number())?; ``` -Usage in a module: - -```rust -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); +A module that only needs raw JSON calls `host.request(chain_id, method, params)` directly, or reaches for the `chain::eth_call_params` and `parse_eth_call_result` helpers. - // Read chain state via alloy - provider injected by macro - let block_num = provider.get_block_number().await?; +## Handlers are synchronous - // Submit order via CoW API - cow.submit_order(&OrderCreation { - sell_token: usdc, - buy_token: weth, - sell_amount: U256::from(1_000_000_000), - kind: OrderKind::Sell, - // 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() - })?; +`#[nexum_sdk::module]` dispatches events to synchronous named handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_custom`); an absent handler is a no-op for that event. There is no `block_on` wrapper around a handler and no provider injection: a handler that wants the alloy provider builds it with `host.provider(chain)` and drives calls with `block_on` itself. Keeper handlers are the exception, where `#[videre_sdk::keeper]` allows `async fn` completed by `videre_sdk::client::poll_once`; see [doc 05](05-sdk-design.md). - Ok(()) -} -``` +## Order submission -## Updated SDK Crate Structure - -``` -nexum-sdk/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor -│ ├── bindings.rs # generated WIT bindings -│ ├── transport.rs # HostTransport (alloy Transport impl, batches via chain::request-batch) -│ ├── local_store.rs # TypedState helpers (serde over local-store) -│ ├── signer.rs # Signer (typed identity helpers) -│ ├── abi.rs # alloy-sol-types integration -│ ├── log.rs # logging macros -│ ├── error.rs # Fault, HostFault, ChainError -│ └── testing.rs # mock host, test harness -└── macros/ - └── src/ - └── lib.rs # #[nexum::module] proc macro - -shepherd-sdk/ -├── Cargo.toml # depends on nexum-sdk, re-exports it -├── src/ -│ ├── lib.rs # re-exports nexum-sdk + CoW additions -│ └── cow.rs # Cow typed wrapper (submit + REST passthrough) -└── macros/ - └── src/ - └── lib.rs # #[shepherd::module] proc macro (extends nexum::module) -``` - -New dependencies (in `nexum-sdk`): - -```toml -[dependencies] -alloy-transport = { version = "1.5", default-features = false } -alloy-json-rpc = { version = "1.5", default-features = false } -alloy-rpc-client = { version = "1.5", default-features = false } -alloy-provider = { version = "1.5", default-features = false } -alloy-rpc-types = { version = "1.5", default-features = false } -alloy-primitives = { version = "1.5", default-features = false } -alloy-sol-types = { version = "1.5", default-features = false } -futures-executor = { version = "0.3", default-features = false } -serde = { version = "1", default-features = false, features = ["derive"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -tower = { version = "0.5", default-features = false } -``` - -All alloy crates with `default-features = false` to avoid pulling in reqwest, tokio, or other dependencies that won't compile for `wasm32-wasip2`. The key crates (`alloy-primitives`, `alloy-sol-types`, `alloy-json-rpc`) are already `no_std`-compatible or have WASM-friendly feature flags. - -## Updated Prelude - -```rust -// 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; -``` - -```rust -// shepherd_sdk::prelude (re-exports nexum_sdk::prelude + CoW additions) -pub use nexum_sdk::prelude::*; -pub use crate::bindings::shepherd::cow::cow_api; -pub use crate::cow::Cow; -``` +Submitting an order or intent is not a chain namespace. It is the `videre:venue` venue-adapter contract: a keeper calls `videre:venue/client`, and the installed venue adapter (for CoW, `crates/cow-venue`) speaks the orderbook wire. See [doc 08](08-platform-generalisation.md) for the layer model and [doc 05](05-sdk-design.md) for the venue SDK. ## Testing -### MockTransport for Unit Tests - -The SDK testing module provides a mock transport that mirrors alloy's own `Asserter`-based testing pattern: - -```rust -use nexum_sdk::testing::MockProvider; - -#[test] -fn test_reads_balance() { - // block_on is still useful in tests - tests are sync by default. - // (Or use #[tokio::test] - MockProvider works with any executor.) - let mut mock = MockProvider::new(42161); - - // Queue mock responses (FIFO) - mock.push_success(&U256::from(1_000_000)); // for get_balance - mock.push_success(&19_000_001u64); // for get_block_number - - let provider = mock.provider(); - - let balance = block_on(provider.get_balance(addr).latest()).unwrap(); - assert_eq!(balance, U256::from(1_000_000)); - - let block = block_on(provider.get_block_number()).unwrap(); - assert_eq!(block, 19_000_001); -} -``` - -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. - -### MockCow for Unit Tests - -```rust -use shepherd_sdk::testing::MockCow; - -#[test] -fn test_submits_order() { - let mut mock_cow = MockCow::new(42161); - mock_cow.on_submit(|order| { - assert_eq!(order.sell_token, usdc); - Ok(OrderUid::from([0x42; 56])) - }); - - let uid = mock_cow.submit_order(&order).unwrap(); - assert_eq!(uid, OrderUid::from([0x42; 56])); -} -``` - -## Trade-Offs - -| Concern | Generic passthrough | Per-method WIT functions | -|---|---|---| -| **WIT changes for new methods** | None | New function + types per method | -| **Host implementation** | ~20 lines total | Per-method impl + dispatch | -| **Guest API** | Full alloy Provider (80+ methods) | Only what WIT exposes | -| **alloy compatibility** | Native - IS an alloy transport | Manual ABI encode/decode | -| **Type safety at WIT boundary** | Runtime (JSON strings) | Compile-time (WIT types) | -| **Method allowlisting** | Runtime string match | Implicit (only exposed methods exist) | -| **Debugging** | JSON in/out visible in traces | Structured WIT types in traces | -| **Multi-language guests** | Must handle JSON serialisation | WIT types auto-generated | - -The primary trade-off is **type safety at the WIT boundary**: JSON strings vs. structured WIT types. This is mitigated by: - -1. **Rust guests** use alloy's type system - serialisation errors surface as alloy `TransportError` with clear messages. -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 `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. - -## Summary - -| Component | What 0.2 ships | -|---|---| -| **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 the additive `http` capability 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. Chain calls return `chain-error`, cow-api calls return `cow-api-error`, and the rest report `fault` directly. | -| **SDK** | `nexum-sdk`: `HostTransport` (alloy `Transport` impl, batches via `chain::request-batch`), `provider()` constructor, `Signer` (typed identity wrapper), `Fault` / `HostFault` / `ChainError`. `shepherd-sdk`: `Cow` (extends `nexum-sdk`). `block_on` is internal. | -| **`#[nexum::module]` / `#[shepherd::module]` macros** | Named event handlers (`on_block`, `on_chain_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 the `fault` case (or `ChainError::Rpc`) for retry/backoff. | -| **Existing ABI helpers** | Unchanged - `sol!` macro and `alloy-sol-types` still used for contract calldata encoding/decoding. | +`nexum_sdk_test::MockHost` implements the host traits (`ChainHost`, `IdentityHost`, `LocalStoreHost`, ...); strategy logic tests against `&impl Host` as plain native Rust, with no `wasm32-wasip2` target and no wasmtime instance. Provider-level tests wrap a stub `ChainHost` in `HostTransport` and drive it with `block_on`.