From b2f2eb276bca9c33edda33bcd3e098e6d65829ba Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 14:02:53 +0000 Subject: [PATCH 01/53] wit: carry opaque status bytes so the host stops importing intent Drop the nexum:intent use from nexum:host/types: the intent-status event now carries an opaque versioned status body and an opaque receipt, so nexum:host is a leaf package. The core bindgen resolves against wit/nexum-host alone, module worlds pull the intent packages only with the pool capability, and the intent vocabulary generates in the adapter bindgen. The body codec lives in the new nexum-status-body crate: a leading u8 version tag, then the version's borsh payload (v1: lifecycle status, optional settlement proof, optional fail reason). Unknown tags fail closed, a body is never empty, and goldens pin the wire form. The pool router lowers an adapter-reported status through it (settled is fulfilled plus proof, failed is cancelled plus reason) and keepers decode via nexum_sdk::status_body. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/nexum-macros/src/world.rs | 56 ++--- crates/nexum-runtime/Cargo.toml | 3 + crates/nexum-runtime/src/bindings.rs | 57 +++-- crates/nexum-runtime/src/host/impls/types.rs | 9 +- crates/nexum-runtime/src/host/pool_router.rs | 115 +++++++-- crates/nexum-runtime/src/supervisor/tests.rs | 19 +- crates/nexum-sdk/Cargo.toml | 3 + crates/nexum-sdk/src/lib.rs | 8 + crates/nexum-status-body/Cargo.toml | 14 ++ crates/nexum-status-body/src/lib.rs | 247 +++++++++++++++++++ modules/example/src/lib.rs | 5 +- modules/examples/echo-client/src/lib.rs | 5 +- wit/nexum-host/types.wit | 19 +- 15 files changed, 474 insertions(+), 97 deletions(-) create mode 100644 crates/nexum-status-body/Cargo.toml create mode 100644 crates/nexum-status-body/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b7ab634f..47718667 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3605,6 +3605,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "nexum-runtime", + "nexum-status-body", "nexum-tasks", "redb", "serde", @@ -3634,6 +3635,7 @@ dependencies = [ "http", "nexum-macros", "nexum-sdk-test", + "nexum-status-body", "proptest", "serde_json", "strum", @@ -3651,6 +3653,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "nexum-status-body" +version = "0.1.0" +dependencies = [ + "borsh", + "thiserror 2.0.18", +] + [[package]] name = "nexum-tasks" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6ec91e60..43f68852 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", + "crates/nexum-status-body", "crates/nexum-tasks", "crates/nexum-venue-sdk", "crates/nexum-venue-test", diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index d8791708..95614893 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -73,7 +73,7 @@ const KNOWN: &[Capability] = &[ Capability { name: "pool", import: Some("nexum:intent/pool@0.1.0"), - packages: &["nexum-intent", "nexum-value-flow"], + packages: &["nexum-value-flow", "nexum-intent"], adapter: None, }, Capability { @@ -168,9 +168,9 @@ pub fn synthesize_venue(declared: &[String]) -> Result { let mut imports = String::new(); // The export face (`nexum:intent/adapter`, its types, and the - // value-flow vocabulary they are expressed in) resolves against the - // same base package set every module world carries, in dependency - // order: a package precedes its dependants. + // value-flow vocabulary they are expressed in) needs the intent + // packages on the resolve path beyond the leaf host package, in + // dependency order: a package precedes its dependants. let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { @@ -228,12 +228,12 @@ pub fn synthesize(declared: &[String]) -> Result { } let mut imports = String::new(); - // The host `event` variant carries the intent vocabulary (the - // `intent-status` case), so every module world resolves against the - // intent and value-flow packages regardless of declared capabilities. - // Dependency order: each directory is parsed against the packages - // before it, so a package precedes its dependants. - let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + // `nexum:host` is a leaf package (the `event` variant carries an + // intent-status transition as opaque bytes), so the base resolve set + // is the host package alone; capability declarations append their + // own packages. Dependency order: each directory is parsed against + // the packages before it, so a package precedes its dependants. + let mut packages = vec!["nexum-host"]; let mut adapters = Vec::new(); for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { @@ -273,10 +273,13 @@ pub fn synthesize(declared: &[String]) -> Result { mod tests { use super::*; - /// The base package set every module world resolves against: the host - /// package plus the intent vocabulary its `event` variant carries, in - /// dependency order. - const BASE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; + /// The base package set every module world resolves against: + /// `nexum:host` is a leaf package, so it stands alone. + const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; + + /// The package set every venue world resolves against: the exported + /// adapter face pulls the intent vocabulary, in dependency order. + const VENUE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; #[test] fn logging_only_world_imports_logging_alone() { @@ -284,7 +287,7 @@ mod tests { assert!(world.wit.contains("import nexum:host/logging@0.2.0;")); assert!(!world.wit.contains("import nexum:host/chain")); assert!(!world.wit.contains("shepherd:cow")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, MODULE_PACKAGES); assert_eq!(world.adapters, vec!["logging"]); } @@ -292,22 +295,17 @@ mod tests { fn cow_api_pulls_the_shepherd_cow_package() { let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); assert!(world.wit.contains("import shepherd:cow/cow-api@0.2.0;")); - assert_eq!( - world.packages, - vec![ - "nexum-value-flow", - "nexum-intent", - "nexum-host", - "shepherd-cow" - ] - ); + assert_eq!(world.packages, vec!["nexum-host", "shepherd-cow"]); } #[test] - fn pool_adds_no_packages_beyond_the_base_set() { + fn pool_pulls_the_intent_packages() { let world = synthesize(&["pool".to_string()]).unwrap(); assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!( + world.packages, + vec!["nexum-host", "nexum-value-flow", "nexum-intent"] + ); assert!(world.adapters.is_empty()); } @@ -315,7 +313,7 @@ mod tests { fn http_declares_no_world_import() { let world = synthesize(&["logging".to_string(), "http".to_string()]).unwrap(); assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, MODULE_PACKAGES); } #[test] @@ -343,7 +341,7 @@ mod tests { .contains("export init: func(config: config) -> result<_, fault>;") ); assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, VENUE_PACKAGES); assert!(world.adapters.is_empty()); } @@ -363,7 +361,7 @@ mod tests { let world = synthesize_venue(&["http".to_string()]).unwrap(); assert!(!world.wit.contains("import")); assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, VENUE_PACKAGES); } #[test] diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index a4ddab3e..7abab005 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -35,6 +35,9 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } +# Encoder for the opaque status body the host `event` stream carries; +# the router lowers an adapter-reported status through it. +nexum-status-body = { path = "../nexum-status-body" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 927861bf..52e9a3fa 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -9,20 +9,16 @@ //! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. //! -//! The `nexum:intent` and `nexum:value-flow` packages sit on the core -//! resolve path because the host `event` variant carries the intent -//! vocabulary (the `intent-status` case). Their types therefore generate -//! here first, and the adapter and pool bindgens below remap onto them -//! with `with`, so one Rust type serves the event payload, the router, -//! and the adapter face alike. `PartialEq` is derived so the router can -//! compare a polled status against the last delivered one. +//! `nexum:host` is a leaf package: the host `event` variant carries an +//! intent-status transition as opaque bytes, so the core world resolves +//! against `wit/nexum-host` alone. The intent and value-flow vocabulary +//! generates in the adapter bindgen below, and the pool bindgen remaps +//! onto it with `with`, so one Rust type serves the router and the +//! adapter face alike. `PartialEq` is derived so the router can compare +//! a polled status against the last delivered one. wasmtime::component::bindgen!({ - path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", - "../../wit/nexum-host", - ], + path: ["../../wit/nexum-host"], world: "nexum:host/event-module", imports: { default: async }, exports: { default: async }, @@ -33,11 +29,13 @@ wasmtime::component::bindgen!({ /// `nexum:adapter/venue-adapter` world. An adapter imports only the scoped /// transport it needs (chain and messaging; outbound HTTP is wasi:http, /// linked and allowlisted separately as for event-module) and exports the -/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host`, -/// `nexum:intent`, and `nexum:value-flow` interfaces are reused from the -/// `event-module` bindings above via `with`, so the `chain`/`messaging` -/// `Host` impls, the `fault` type, and the intent vocabulary an adapter -/// sees are the very ones the core host constructs. +/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host` +/// interfaces are reused from the `event-module` bindings above via +/// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type +/// an adapter sees are the very ones the core host constructs. The +/// `nexum:intent` and `nexum:value-flow` types generate here (the leaf +/// host world no longer reaches them) and the pool bindgen below remaps +/// onto them. mod venue_adapter { wasmtime::component::bindgen!({ path: [ @@ -53,9 +51,8 @@ mod venue_adapter { "nexum:host/types": super::nexum::host::types, "nexum:host/chain": super::nexum::host::chain, "nexum:host/messaging": super::nexum::host::messaging, - "nexum:intent/types": super::nexum::intent::types, - "nexum:value-flow/types": super::nexum::value_flow::types, }, + additional_derives: [PartialEq], }); } @@ -63,9 +60,9 @@ pub use venue_adapter::VenueAdapter; /// The strategy-facing `nexum:intent/pool` import bound host-side. The pool /// world imports the interface a module calls; the intent and value-flow -/// types it uses are reused from the core bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the router hands back to a module are -/// the very ones an adapter's `submit` produced - no lift between two +/// types it uses are reused from the adapter bindings above via `with`, so +/// the `SubmitOutcome` and `VenueError` the router hands back to a module +/// are the very ones an adapter's `submit` produced - no lift between two /// structurally identical copies. Async, because the `Host` impl awaits the /// per-adapter mutex and the adapter's own async guest calls. mod pool_host { @@ -79,8 +76,8 @@ mod pool_host { path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], imports: { default: async }, with: { - "nexum:value-flow/types": super::nexum::value_flow::types, - "nexum:intent/types": super::nexum::intent::types, + "nexum:value-flow/types": super::venue_adapter::nexum::value_flow::types, + "nexum:intent/types": super::venue_adapter::nexum::intent::types, }, }); } @@ -88,14 +85,16 @@ mod pool_host { /// The router-observed status transition delivered through the `event` /// variant, re-exported at the plain spelling the router names. pub use nexum::host::types::IntentStatusUpdate; -/// The shared intent ontology, re-exported at the plain spellings the router -/// and the `pool::Host` impl name. -pub use nexum::intent::types::{AuthScheme, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; -/// The value-flow vocabulary the header is expressed in. -pub use nexum::value_flow::types as value_flow; /// The host-bound pool interface: the `Host` trait the router implements and /// the `add_to_linker` the module linker calls. pub use pool_host::nexum::intent::pool; +/// The shared intent ontology, re-exported at the plain spellings the router +/// and the `pool::Host` impl name. +pub use venue_adapter::nexum::intent::types::{ + AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::nexum::value_flow::types as value_flow; /// Bindgen smoke for the `nexum:value-flow` types package. The package has /// no host consumer yet (the intent router that will bind it lands later), diff --git a/crates/nexum-runtime/src/host/impls/types.rs b/crates/nexum-runtime/src/host/impls/types.rs index a035a97d..f9516569 100644 --- a/crates/nexum-runtime/src/host/impls/types.rs +++ b/crates/nexum-runtime/src/host/impls/types.rs @@ -1,13 +1,8 @@ -//! `nexum:host/types` and the intent vocabulary it uses are type-only -//! interfaces (no functions). The generated traits are empty; we just -//! provide the marker impls. +//! `nexum:host/types` is a type-only interface (no functions). The +//! generated trait is empty; we just provide the marker impl. use crate::bindings::nexum; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; impl nexum::host::types::Host for HostState {} - -impl nexum::intent::types::Host for HostState {} - -impl nexum::value_flow::types::Host for HostState {} diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/pool_router.rs index a90797f0..6353cf8a 100644 --- a/crates/nexum-runtime/src/host/pool_router.rs +++ b/crates/nexum-runtime/src/host/pool_router.rs @@ -27,6 +27,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use futures::future::BoxFuture; +use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; use tracing::warn; use wasmtime::Store; @@ -271,6 +272,35 @@ fn is_terminal(status: &IntentStatus) -> bool { ) } +/// Lower an adapter-reported status onto the opaque status body the host +/// `event` stream carries: `settled` is `fulfilled` plus its proof, and +/// `failed` is `cancelled` plus its reason (the body's lifecycle enum has +/// no failed case). +fn status_body(status: &IntentStatus) -> StatusBody { + use nexum_status_body::IntentStatus as Lifecycle; + + let (status, proof, reason) = match status { + IntentStatus::Pending => (Lifecycle::Pending, None, None), + IntentStatus::Open => (Lifecycle::Open, None, None), + IntentStatus::Settled(proof) => (Lifecycle::Fulfilled, proof.clone(), None), + IntentStatus::Failed(reason) => ( + Lifecycle::Cancelled, + None, + Some(nexum_status_body::FailReason { + code: reason.code.clone(), + detail: reason.detail.clone(), + }), + ), + IntentStatus::Expired => (Lifecycle::Expired, None, None), + IntentStatus::Cancelled => (Lifecycle::Cancelled, None, None), + }; + StatusBody { + status, + proof, + reason, + } +} + /// The shared router state. Cloning a [`PoolRouter`] is an `Arc` bump; every /// module store carries the same handle, so a submission from any module /// reaches the same adapters and the same quota ledger. @@ -462,7 +492,9 @@ impl PoolRouter { /// Fold one polled status into the watch entry: `Some(update)` when it /// differs from the last reported status, pruning the entry when the /// status is terminal. `None` also covers an entry that disappeared - /// while the poll was in flight. + /// while the poll was in flight, and an update whose status body + /// failed to encode (the entry is left untouched for the next + /// cadence). fn record_polled_status( &self, venue: &str, @@ -474,16 +506,31 @@ impl PoolRouter { .iter() .position(|w| w.venue == venue && w.receipt == receipt)?; let changed = watched[pos].last.as_ref() != Some(&status); + let update = if changed { + match status_body(&status).encode() { + Ok(body) => Some(IntentStatusUpdate { + venue: venue.to_owned(), + receipt: receipt.to_vec(), + status: body, + }), + Err(err) => { + warn!( + venue = %venue, + error = %err, + "status body failed to encode - retrying on the next cadence", + ); + return None; + } + } + } else { + None + }; if is_terminal(&status) { watched.remove(pos); } else { - watched[pos].last = Some(status.clone()); + watched[pos].last = Some(status); } - changed.then(|| IntentStatusUpdate { - venue: venue.to_owned(), - receipt: receipt.to_vec(), - status, - }) + update } /// Drop a `(venue, receipt)` pair from the status watch. @@ -602,12 +649,27 @@ pub struct DuplicateVenue { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use crate::bindings::nexum::intent::types::UnsignedTx; + use nexum_status_body::IntentStatus as Lifecycle; + use crate::bindings::value_flow::Settlement; - use crate::bindings::{AuthScheme, IntentHeader}; + use crate::bindings::{AuthScheme, FailReason, IntentHeader, UnsignedTx}; use super::*; + /// Decode an update's opaque status body. + fn decoded(update: &IntentStatusUpdate) -> StatusBody { + StatusBody::decode(&update.status).expect("status body decodes") + } + + /// A body carrying a bare lifecycle state. + fn plain(status: Lifecycle) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + /// A programmable adapter that records call counts and returns canned /// outcomes, so the router's sequencing, guard seam, and quota are tested /// without a wasmtime store. @@ -989,7 +1051,7 @@ mod tests { assert_eq!(first.len(), 1); assert_eq!(first[0].venue, "cow"); assert_eq!(first[0].receipt, b"receipt"); - assert_eq!(first[0].status, IntentStatus::Open); + assert_eq!(decoded(&first[0]), plain(Lifecycle::Open)); // Second poll: same status, nothing to report. assert!(router.poll_status_transitions().await.is_empty()); @@ -1016,13 +1078,17 @@ mod tests { for _ in 0..4 { seen.extend(router.poll_status_transitions().await); } - let statuses: Vec<&IntentStatus> = seen.iter().map(|u| &u.status).collect(); + let statuses: Vec = seen.iter().map(decoded).collect(); assert_eq!( statuses, vec![ - &IntentStatus::Pending, - &IntentStatus::Open, - &IntentStatus::Settled(Some(b"tx".to_vec())), + plain(Lifecycle::Pending), + plain(Lifecycle::Open), + StatusBody { + status: Lifecycle::Fulfilled, + proof: Some(b"tx".to_vec()), + reason: None, + }, ], "the repeated pending is deduplicated; each transition reports once", ); @@ -1052,7 +1118,26 @@ mod tests { // The venue recovered: the next poll reports the current status. let updates = router.poll_status_transitions().await; assert_eq!(updates.len(), 1); - assert_eq!(updates[0].status, IntentStatus::Open); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); + } + + #[test] + fn failed_lowers_to_cancelled_plus_reason() { + let body = status_body(&IntentStatus::Failed(FailReason { + code: "oc".into(), + detail: "od".into(), + })); + assert_eq!( + body, + StatusBody { + status: Lifecycle::Cancelled, + proof: None, + reason: Some(nexum_status_body::FailReason { + code: "oc".into(), + detail: "od".into(), + }), + }, + ); } #[tokio::test] diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index e2779f85..ec5da908 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -702,7 +702,13 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { let foreign = crate::bindings::IntentStatusUpdate { venue: "other".to_owned(), receipt: b"receipt".to_vec(), - status: IntentStatus::Open, + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), }; assert_eq!(supervisor.dispatch_intent_status(foreign).await, 0); } @@ -790,7 +796,6 @@ async fn e2e_intent_status_flows_through_the_event_loop() { /// scripted stand-ins on either side. #[tokio::test] async fn e2e_echo_module_router_adapter_round_trip() { - use crate::bindings::IntentStatus; use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; use crate::host::component::ChainMethod; use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; @@ -855,10 +860,12 @@ async fn e2e_echo_module_router_adapter_round_trip() { for _ in 0..2 { for update in router.poll_status_transitions().await { assert_eq!(update.venue, "echo-venue"); - assert!( - matches!(update.status, IntentStatus::Settled(_)), - "echo settles instantly; got {:?}", - update.status, + let body = + nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + assert_eq!( + body.status, + nexum_status_body::IntentStatus::Fulfilled, + "echo settles instantly", ); delivered += supervisor.dispatch_intent_status(update).await; } diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index f713c5c1..655e28ed 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -23,6 +23,9 @@ stderr-echo = [] # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). nexum-macros = { path = "../nexum-macros" } +# Decoder for the opaque status body an `intent-status` event carries; +# re-exported as `nexum_sdk::status_body`. +nexum-status-body = { path = "../nexum-status-body" } alloy-primitives.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index bd255a8e..04e8fffd 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -47,6 +47,9 @@ //! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind //! macro fills to rebuild it from the wire record. //! +//! - [`status_body`] - decoder for the opaque versioned status body an +//! `intent-status` event carries ([`StatusBody`]). +//! //! - [`config`] - `(key, value)` config-table lookups and decimal //! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). //! @@ -95,6 +98,7 @@ //! [`read_latest_answer`]: chain::chainlink::read_latest_answer //! [`Log`]: events::Log //! [`ChainLogParts`]: events::ChainLogParts +//! [`StatusBody`]: status_body::StatusBody //! [`get_required`]: config::get_required //! [`get_optional`]: config::get_optional //! [`scale_decimal`]: config::scale_decimal @@ -125,6 +129,10 @@ pub mod prelude; pub mod tracing; pub mod wit_bindgen_macro; +/// The opaque status-body codec: decode an `intent-status` event's +/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). +pub use nexum_status_body as status_body; + /// The level vocabulary for every SDK log path: the host logging trait, /// the guest tracing facade sink, and the module mocks all speak /// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the diff --git a/crates/nexum-status-body/Cargo.toml b/crates/nexum-status-body/Cargo.toml new file mode 100644 index 00000000..cd4361b3 --- /dev/null +++ b/crates/nexum-status-body/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nexum-status-body" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Versioned codec for the opaque status body the host event stream carries: a leading version tag, then that version's borsh payload; unknown tags fail closed." + +[lints] +workspace = true + +[dependencies] +borsh.workspace = true +thiserror.workspace = true diff --git a/crates/nexum-status-body/src/lib.rs b/crates/nexum-status-body/src/lib.rs new file mode 100644 index 00000000..d5cc4fab --- /dev/null +++ b/crates/nexum-status-body/src/lib.rs @@ -0,0 +1,247 @@ +//! The versioned opaque status-body codec. +//! +//! The host `event` stream carries an intent-status transition as opaque +//! bytes: a leading `u8` version tag, then that version's borsh payload. +//! The emitter encodes, the keeper decodes, the host never inspects the +//! bytes. An unknown tag fails closed and a body is never empty. +//! +//! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the +//! borsh `option` encodings of `proof` and `reason`. + +#![warn(missing_docs)] + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// Wire tag of the v1 payload. +pub const VERSION_V1: u8 = 1; + +/// Where an intent is in its life at the venue. The borsh discriminant +/// is the wire form: append new states, never reorder. +#[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntentStatus { + /// Accepted for processing but not yet live at the venue. + Pending, + /// Live at the venue and eligible for settlement. + Open, + /// Settled. + Fulfilled, + /// Withdrawn or terminally refused before settlement. + Cancelled, + /// Reached its expiry without settling. + Expired, +} + +/// Why an intent failed terminally, as reported by the venue. +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct FailReason { + /// Venue-scoped machine-readable code, stable enough to match on. + pub code: String, + /// Human-readable detail for logs and the consent surface. + pub detail: String, +} + +/// One decoded status body. +/// +/// `proof` is display-grade venue bytes (for an EVM venue, typically +/// the settlement transaction hash). There is no `failed` status: a +/// venue-reported terminal failure reads as a non-[`Fulfilled`] +/// terminal `status` plus a `reason`. +/// +/// [`Fulfilled`]: IntentStatus::Fulfilled +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct StatusBody { + /// Where the intent is in its life at the venue. + pub status: IntentStatus, + /// Venue-defined settlement proof. + pub proof: Option>, + /// Terminal-failure reason. + pub reason: Option, +} + +impl StatusBody { + /// Encode as the version tag plus the borsh payload. Never empty: + /// at minimum the tag and the status discriminant. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = vec![VERSION_V1]; + borsh::to_writer(&mut out, self).map_err(|err| EncodeError { + detail: err.to_string(), + })?; + Ok(out) + } + + /// Decode, failing typedly on an empty body, an unknown version + /// tag (fail-closed), or a payload that does not parse as the + /// tagged version (including trailing bytes). + pub fn decode(bytes: &[u8]) -> Result { + match bytes { + [] => Err(DecodeError::Empty), + [VERSION_V1, payload @ ..] => { + borsh::from_slice(payload).map_err(|err| DecodeError::Malformed { + version: VERSION_V1, + detail: err.to_string(), + }) + } + [version, ..] => Err(DecodeError::UnknownVersion { version: *version }), + } + } +} + +/// A payload failed to encode. Only reachable when a field's length +/// exceeds the wire's `u32` bound. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("status body failed to encode: {detail}")] +pub struct EncodeError { + /// Borsh's encode failure detail. + pub detail: String, +} + +/// Why bytes failed to decode as a status body. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DecodeError { + /// No bytes at all: not even a version tag. + #[error("empty status body: missing the version tag")] + Empty, + /// The version tag names no published version. Fail-closed: a + /// keeper never guesses at a future layout. + #[error("unknown status-body version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} payload: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(status: IntentStatus) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + + #[test] + fn golden_minimal_open() { + let encoded = body(IntentStatus::Open).encode().expect("encode"); + assert_eq!(encoded, [VERSION_V1, 1, 0, 0]); + } + + #[test] + fn golden_fulfilled_with_proof() { + let encoded = StatusBody { + status: IntentStatus::Fulfilled, + proof: Some(vec![0xaa, 0xbb]), + reason: None, + } + .encode() + .expect("encode"); + assert_eq!(encoded, [VERSION_V1, 2, 1, 2, 0, 0, 0, 0xaa, 0xbb, 0]); + } + + #[test] + fn golden_terminal_failure() { + let encoded = StatusBody { + status: IntentStatus::Cancelled, + proof: None, + reason: Some(FailReason { + code: "oc".into(), + detail: "od".into(), + }), + } + .encode() + .expect("encode"); + assert_eq!( + encoded, + [ + VERSION_V1, 3, 0, 1, 2, 0, 0, 0, b'o', b'c', 2, 0, 0, 0, b'o', b'd' + ], + ); + } + + #[test] + fn round_trips_every_status() { + for status in [ + IntentStatus::Pending, + IntentStatus::Open, + IntentStatus::Fulfilled, + IntentStatus::Cancelled, + IntentStatus::Expired, + ] { + let original = StatusBody { + status, + proof: Some(b"proof".to_vec()), + reason: Some(FailReason { + code: "code".into(), + detail: "detail".into(), + }), + }; + let decoded = StatusBody::decode(&original.encode().expect("encode")).expect("decode"); + assert_eq!(decoded, original); + } + } + + #[test] + fn a_body_is_never_empty() { + let encoded = body(IntentStatus::Pending).encode().expect("encode"); + assert!(encoded.len() >= 2, "at minimum the tag and the status"); + } + + #[test] + fn empty_bytes_fail_typedly() { + assert_eq!(StatusBody::decode(&[]), Err(DecodeError::Empty)); + } + + #[test] + fn unknown_version_fails_closed() { + assert_eq!( + StatusBody::decode(&[2, 1, 0, 0]), + Err(DecodeError::UnknownVersion { version: 2 }), + ); + } + + #[test] + fn unknown_status_discriminant_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 5, 0, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn trailing_bytes_are_malformed() { + let mut encoded = body(IntentStatus::Open).encode().expect("encode"); + encoded.push(0); + assert!(matches!( + StatusBody::decode(&encoded), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn truncated_payload_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 1, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } +} diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 5c7445b0..da1ddd26 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -68,11 +68,14 @@ impl ExampleModule { } fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, &format!( - "intent status update from venue {} ({} receipt bytes)", + "intent status update from venue {}: {:?} ({} receipt bytes)", update.venue, + body.status, update.receipt.len(), ), ); diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 08eb05ee..62407488 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -55,11 +55,14 @@ impl EchoClient { } fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, &format!( - "intent status from venue {} ({} receipt bytes)", + "intent status from venue {}: {:?} ({} receipt bytes)", update.venue, + body.status, update.receipt.len(), ), ); diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 6c48dc29..b4349717 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -5,8 +5,6 @@ package nexum:host@0.2.0; /// All `u64` timestamps in this package are milliseconds since the Unix /// epoch, UTC. interface types { - use nexum:intent/types@0.1.0.{receipt, intent-status}; - type chain-id = u64; record block { @@ -61,16 +59,19 @@ interface types { } /// A host-observed status transition for a previously submitted - /// intent, expressed in the venue-neutral `nexum:intent` vocabulary. - /// Transport-blind: the subscriber sees only where the intent is in - /// its life, never how the host learnt it. + /// intent. Transport-blind: the subscriber sees only where the + /// intent is in its life, never how the host learnt it. record intent-status-update { /// Venue id the receipt was issued by. venue: string, - /// The venue-scoped intent identifier. - receipt: receipt, - /// Where the intent now is in its life at the venue. - status: intent-status, + /// The venue-scoped intent identifier, opaque to the host. + receipt: list, + /// Opaque versioned status body: a leading u8 version tag, + /// then that version's borsh payload (v1: lifecycle status, + /// optional settlement proof, optional failure reason). An + /// unknown tag is a decode error and the body is never empty. + /// The host never inspects it. + status: list, } variant event { From cdda2babb0c2ba1fa5bf603583acd3108f068a5f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 14:56:38 +0000 Subject: [PATCH 02/53] wit: rename the intent contract to the pinned videre surface Rename the pre-release intent packages into the videre namespace and reshape them to the pinned 0.1.0 surface: nexum:value-flow becomes videre:value-flow, nexum:intent splits into videre:types and the two-faced videre:venue (worker client, provider adapter), and nexum:adapter retires with its venue-adapter world folded into videre:venue. nexum:host and shepherd:cow are untouched. The 0.1 shapes are EVM-only and thin: single-asset gives/wants, auth-scheme {eip1271, eip712}, settlement {chain}, uint amounts (big-endian, minimal-length), asset {native, erc20{token}}, plain-enum intent-status, the seven-case venue-error with rate-limit, unsigned-tx {chain, to, value, data}, and the quotation record. Dropped cases return as additive 0.2+ variants; quote functions land separately. Rust follows the wire: PoolRouter is VenueRegistry, AdapterActor is VenueActor, GuardPolicy/AllowAllGuard collapse into EgressGuard (the unit guard allows every egress), IntentPool is VenueClient, PoolQuota is SubmitQuota, and a VenueId newtype keys the registry. Quota exhaustion now reports rate-limited with the window; adapter traps project onto unavailable. The module capability is client; goldens and the conformance mirrors follow the single-asset header. --- crates/cow-venue/src/client.rs | 28 +- crates/nexum-macros/src/lib.rs | 24 +- crates/nexum-macros/src/world.rs | 57 +- crates/nexum-runtime/src/bindings.rs | 207 +++--- crates/nexum-runtime/src/builder.rs | 4 +- crates/nexum-runtime/src/engine_config.rs | 14 +- crates/nexum-runtime/src/host/impls/mod.rs | 2 +- crates/nexum-runtime/src/host/impls/pool.rs | 30 - .../src/host/impls/venue_client.rs | 36 ++ crates/nexum-runtime/src/host/mod.rs | 2 +- crates/nexum-runtime/src/host/state.rs | 8 +- .../{pool_router.rs => venue_registry.rs} | 606 ++++++++++-------- .../src/manifest/capabilities.rs | 42 +- .../nexum-runtime/src/runtime/event_loop.rs | 14 +- crates/nexum-runtime/src/supervisor.rs | 98 +-- crates/nexum-runtime/src/supervisor/tests.rs | 84 ++- crates/nexum-venue-sdk/src/adapter.rs | 4 +- crates/nexum-venue-sdk/src/bindings.rs | 14 +- crates/nexum-venue-sdk/src/body.rs | 7 +- crates/nexum-venue-sdk/src/client.rs | 40 +- crates/nexum-venue-sdk/src/faults.rs | 45 +- crates/nexum-venue-sdk/src/lib.rs | 13 +- crates/nexum-venue-sdk/tests/adapter.rs | 52 +- .../goldens/reference-header.json | 59 +- crates/nexum-venue-test/src/header.rs | 187 ++---- crates/nexum-venue-test/src/reference.rs | 42 +- crates/nexum-venue-test/tests/conformance.rs | 13 +- crates/shepherd-cow-host/src/ext_cow.rs | 2 - .../0013-composable-cow-structured-poll.md | 2 +- modules/ethflow-watcher/src/lib.rs | 2 - modules/examples/echo-client/Cargo.toml | 2 +- modules/examples/echo-client/module.toml | 15 +- modules/examples/echo-client/src/lib.rs | 24 +- modules/examples/echo-venue/src/lib.rs | 151 ++--- modules/examples/stop-loss/src/lib.rs | 2 - modules/fixtures/clock-reader/src/lib.rs | 2 - modules/fixtures/flaky-bomb/src/lib.rs | 2 - modules/fixtures/fuel-bomb/src/lib.rs | 2 - modules/fixtures/memory-bomb/src/lib.rs | 2 - modules/fixtures/panic-bomb/src/lib.rs | 2 - modules/fixtures/slow-host/src/lib.rs | 2 - modules/twap-monitor/src/lib.rs | 2 - wit/nexum-adapter/venue-adapter.wit | 30 - wit/nexum-intent/adapter.wit | 31 - wit/nexum-intent/pool.wit | 26 - wit/nexum-intent/types.wit | 132 ---- wit/nexum-value-flow/types.wit | 98 --- wit/videre-types/types.wit | 83 +++ wit/videre-value-flow/types.wit | 32 + wit/videre-venue/venue.wit | 39 ++ 50 files changed, 1101 insertions(+), 1316 deletions(-) delete mode 100644 crates/nexum-runtime/src/host/impls/pool.rs create mode 100644 crates/nexum-runtime/src/host/impls/venue_client.rs rename crates/nexum-runtime/src/host/{pool_router.rs => venue_registry.rs} (67%) delete mode 100644 wit/nexum-adapter/venue-adapter.wit delete mode 100644 wit/nexum-intent/adapter.wit delete mode 100644 wit/nexum-intent/pool.wit delete mode 100644 wit/nexum-intent/types.wit delete mode 100644 wit/nexum-value-flow/types.wit create mode 100644 wit/videre-types/types.wit create mode 100644 wit/videre-value-flow/types.wit create mode 100644 wit/videre-venue/venue.wit diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 81bd4e5c..174bb4e8 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -1,19 +1,19 @@ //! The typed CoW intent client. //! -//! [`CowClient`] binds the strategy-facing [`IntentClient`] to the CoW +//! [`CowClient`] binds the keeper-facing [`IntentClient`] to the CoW //! venue id and speaks the venue's own [`CowIntentBody`] over it, so -//! strategy code submits a typed CoW body without naming the venue on +//! keeper code submits a typed CoW body without naming the venue on //! every call or handling wire bytes. The classification API //! ([`classify`](crate::classification::classify)) travels in the same //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use nexum_venue_sdk::client::{ClientError, IntentClient, IntentPool}; +use nexum_venue_sdk::client::{ClientError, IntentClient, VenueClient}; use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; use crate::body::CowIntentBody; -/// The venue id the CoW adapter registers under and the router resolves. +/// The venue id the CoW adapter registers under and the registry resolves. /// Every [`CowClient`] call routes here. pub const VENUE: &str = "cow"; @@ -25,11 +25,11 @@ pub struct CowClient

{ inner: IntentClient

, } -impl CowClient

{ - /// Bind a pool handle to the CoW venue. - pub fn new(pool: P) -> Self { +impl CowClient

{ + /// Bind a client handle to the CoW venue. + pub fn new(venues: P) -> Self { Self { - inner: IntentClient::new(pool, VENUE), + inner: IntentClient::new(venues, VENUE), } } @@ -66,13 +66,13 @@ mod tests { /// Records the venue every call routed to and the bytes submitted. /// Cloneable over a shared log so the test can inspect it after the - /// pool moves into the client. + /// handle moves into the client. #[derive(Clone, Default)] - struct SpyPool { + struct SpyClient { submitted: SubmitLog, } - impl IntentPool for SpyPool { + impl VenueClient for SpyClient { fn submit(&self, venue: &str, body: Vec) -> Result { self.submitted .borrow_mut() @@ -112,15 +112,15 @@ mod tests { fn submit_routes_to_the_cow_venue_with_encoded_body() { use nexum_venue_sdk::IntentBody; - let pool = SpyPool::default(); + let spy = SpyClient::default(); let body = sample_body(); let expected = body.to_bytes().expect("body encodes"); - let client = CowClient::new(pool.clone()); + let client = CowClient::new(spy.clone()); assert_eq!(client.venue(), VENUE); client.submit(&body).expect("submit succeeds"); - let calls = pool.submitted.borrow(); + let calls = spy.submitted.borrow(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, VENUE); assert_eq!(calls[0].1, expected); diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index c760b37f..d9566925 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -9,7 +9,7 @@ //! //! [`venue`] is the adapter counterpart: it emits the same per-cdylib //! wit-bindgen and `export!`, but for a per-component venue-adapter -//! world exporting the `nexum:intent/adapter` face and importing only +//! world exporting the `videre:venue/adapter` face and importing only //! the manifest's declared scoped transport. //! //! [`derive@IntentBody`] implements the venue SDK's versioned body codec @@ -271,7 +271,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// The associated functions the `nexum:intent/adapter` face mandates. A +/// The associated functions the `videre:venue/adapter` face mandates. A /// venue adapter must define all four; `init` is separate (a no-op when /// absent, exactly as in a module). const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"]; @@ -280,7 +280,7 @@ const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"] /// /// Apply to an inherent `impl` block whose associated functions are the /// adapter face: `derive_header`, `submit`, `status`, `cancel` (all -/// required, from `nexum:intent/adapter`), plus an optional `init` +/// required, from `videre:venue/adapter`), plus an optional `init` /// (absent means a no-op). Each takes and returns the per-cdylib /// wit-bindgen payloads for its signature. The macro reads the crate's /// `module.toml`, synthesizes a per-component world exporting the @@ -298,7 +298,7 @@ const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"] /// /// The same crate-root resolution invariants as [`macro@module`] apply: /// the wit-bindgen output lands at the module crate root (so the emitted -/// glue resolves `Guest`, `Fault`, and the `nexum::*` type modules +/// glue resolves `Guest`, `Fault`, and the `nexum::*`/`videre::*` type modules /// there), the consuming crate must declare `wit-bindgen` as a direct /// dependency, and the crate root must not shadow std prelude names. #[proc_macro_attribute] @@ -423,12 +423,12 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { #init_impl } - impl exports::nexum::intent::adapter::Guest for __NexumVenueAdapterExport { + impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result< - nexum::intent::types::IntentHeader, - nexum::intent::types::VenueError, + videre::types::types::IntentHeader, + videre::types::types::VenueError, > { <#self_ty>::derive_header(body) } @@ -436,8 +436,8 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { fn submit( body: ::std::vec::Vec, ) -> ::core::result::Result< - nexum::intent::types::SubmitOutcome, - nexum::intent::types::VenueError, + videre::types::types::SubmitOutcome, + videre::types::types::VenueError, > { <#self_ty>::submit(body) } @@ -445,15 +445,15 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { fn status( receipt: ::std::vec::Vec, ) -> ::core::result::Result< - nexum::intent::types::IntentStatus, - nexum::intent::types::VenueError, + videre::types::types::IntentStatus, + videre::types::types::VenueError, > { <#self_ty>::status(receipt) } fn cancel( receipt: ::std::vec::Vec, - ) -> ::core::result::Result<(), nexum::intent::types::VenueError> { + ) -> ::core::result::Result<(), videre::types::types::VenueError> { <#self_ty>::cancel(receipt) } } diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index 95614893..3fcf3818 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -32,7 +32,7 @@ struct Capability { /// Every capability the macro recognises, in emission order. Mirrors /// the runtime's core registry plus the extension namespaces the -/// workspace ships (`nexum:intent/pool`, `shepherd:cow/cow-api`). +/// workspace ships (`videre:venue/client`, `shepherd:cow/cow-api`). const KNOWN: &[Capability] = &[ Capability { name: "chain", @@ -71,9 +71,9 @@ const KNOWN: &[Capability] = &[ adapter: Some("logging"), }, Capability { - name: "pool", - import: Some("nexum:intent/pool@0.1.0"), - packages: &["nexum-value-flow", "nexum-intent"], + name: "client", + import: Some("videre:venue/client@0.1.0"), + packages: &["videre-value-flow", "videre-types", "videre-venue"], adapter: None, }, Capability { @@ -149,7 +149,7 @@ const VENUE_CAPABILITIES: &[&str] = &["chain", "messaging", "http"]; /// Build the per-component venue-adapter world from the declared /// capability names. The world exports `init` and the -/// `nexum:intent/adapter` face and imports exactly the declared scoped +/// `videre:venue/adapter` face and imports exactly the declared scoped /// transport, so a macro-built adapter's imports equal its declarations /// by construction. A capability outside the venue-permitted set is a /// compile error: an adapter that reaches for host key material or @@ -167,11 +167,16 @@ pub fn synthesize_venue(declared: &[String]) -> Result { } let mut imports = String::new(); - // The export face (`nexum:intent/adapter`, its types, and the - // value-flow vocabulary they are expressed in) needs the intent + // The export face (`videre:venue/adapter`, its types, and the + // value-flow vocabulary they are expressed in) needs the videre // packages on the resolve path beyond the leaf host package, in // dependency order: a package precedes its dependants. - let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + let mut packages = vec![ + "videre-value-flow", + "videre-types", + "nexum-host", + "videre-venue", + ]; for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { continue; @@ -198,7 +203,7 @@ pub fn synthesize_venue(declared: &[String]) -> Result { wit.push_str(&imports); wit.push_str( "\n export init: func(config: config) -> result<_, fault>;\n \ - export nexum:intent/adapter@0.1.0;\n}\n", + export videre:venue/adapter@0.1.0;\n}\n", ); Ok(ModuleWorld { @@ -278,8 +283,13 @@ mod tests { const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; /// The package set every venue world resolves against: the exported - /// adapter face pulls the intent vocabulary, in dependency order. - const VENUE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; + /// adapter face pulls the videre vocabulary, in dependency order. + const VENUE_PACKAGES: [&str; 4] = [ + "videre-value-flow", + "videre-types", + "nexum-host", + "videre-venue", + ]; #[test] fn logging_only_world_imports_logging_alone() { @@ -299,12 +309,17 @@ mod tests { } #[test] - fn pool_pulls_the_intent_packages() { - let world = synthesize(&["pool".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); + fn client_pulls_the_videre_packages() { + let world = synthesize(&["client".to_string()]).unwrap(); + assert!(world.wit.contains("import videre:venue/client@0.1.0;")); assert_eq!( world.packages, - vec!["nexum-host", "nexum-value-flow", "nexum-intent"] + vec![ + "nexum-host", + "videre-value-flow", + "videre-types", + "videre-venue" + ] ); assert!(world.adapters.is_empty()); } @@ -340,7 +355,7 @@ mod tests { .wit .contains("export init: func(config: config) -> result<_, fault>;") ); - assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); + assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); assert_eq!(world.packages, VENUE_PACKAGES); assert!(world.adapters.is_empty()); } @@ -368,12 +383,18 @@ mod tests { fn venue_world_with_no_capabilities_imports_nothing() { let world = synthesize_venue(&[]).unwrap(); assert!(!world.wit.contains("import")); - assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); + assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); } #[test] fn venue_world_refuses_non_transport_capabilities() { - for cap in ["local-store", "remote-store", "identity", "logging", "pool"] { + for cap in [ + "local-store", + "remote-store", + "identity", + "logging", + "client", + ] { let err = synthesize_venue(&[cap.to_string()]).unwrap_err(); assert!(err.contains(cap), "message was: {err}"); assert!(err.contains("venue adapter"), "message was: {err}"); diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 52e9a3fa..f587536a 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -11,11 +11,11 @@ //! //! `nexum:host` is a leaf package: the host `event` variant carries an //! intent-status transition as opaque bytes, so the core world resolves -//! against `wit/nexum-host` alone. The intent and value-flow vocabulary -//! generates in the adapter bindgen below, and the pool bindgen remaps -//! onto it with `with`, so one Rust type serves the router and the -//! adapter face alike. `PartialEq` is derived so the router can compare -//! a polled status against the last delivered one. +//! against `wit/nexum-host` alone. The videre vocabulary generates in the +//! adapter bindgen below, and the client bindgen remaps onto it with +//! `with`, so one Rust type serves the registry and the adapter face +//! alike. `PartialEq` is derived so the registry can compare a polled +//! status against the last delivered one. wasmtime::component::bindgen!({ path: ["../../wit/nexum-host"], @@ -26,25 +26,25 @@ wasmtime::component::bindgen!({ }); /// WIT bindings for the second component kind: the -/// `nexum:adapter/venue-adapter` world. An adapter imports only the scoped +/// `videre:venue/venue-adapter` world. An adapter imports only the scoped /// transport it needs (chain and messaging; outbound HTTP is wasi:http, /// linked and allowlisted separately as for event-module) and exports the -/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host` +/// `videre:venue/adapter` face plus `init`. The shared `nexum:host` /// interfaces are reused from the `event-module` bindings above via /// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type /// an adapter sees are the very ones the core host constructs. The -/// `nexum:intent` and `nexum:value-flow` types generate here (the leaf -/// host world no longer reaches them) and the pool bindgen below remaps +/// `videre:types` and `videre:value-flow` types generate here (the leaf +/// host world no longer reaches them) and the client bindgen below remaps /// onto them. mod venue_adapter { wasmtime::component::bindgen!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", + "../../wit/videre-value-flow", + "../../wit/videre-types", "../../wit/nexum-host", - "../../wit/nexum-adapter", + "../../wit/videre-venue", ], - world: "nexum:adapter/venue-adapter", + world: "videre:venue/venue-adapter", imports: { default: async }, exports: { default: async }, with: { @@ -58,86 +58,77 @@ mod venue_adapter { pub use venue_adapter::VenueAdapter; -/// The strategy-facing `nexum:intent/pool` import bound host-side. The pool -/// world imports the interface a module calls; the intent and value-flow -/// types it uses are reused from the adapter bindings above via `with`, so -/// the `SubmitOutcome` and `VenueError` the router hands back to a module +/// The keeper-facing `videre:venue/client` import bound host-side. The +/// world imports the interface a module calls; the videre types it uses +/// are reused from the adapter bindings above via `with`, so the +/// `SubmitOutcome` and `VenueError` the registry hands back to a module /// are the very ones an adapter's `submit` produced - no lift between two /// structurally identical copies. Async, because the `Host` impl awaits the /// per-adapter mutex and the adapter's own async guest calls. -mod pool_host { +mod client_host { wasmtime::component::bindgen!({ inline: " - package nexum:pool-host; - world pool-host { - import nexum:intent/pool@0.1.0; + package videre:client-host; + world client-host { + import videre:venue/client@0.1.0; } ", - path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], imports: { default: async }, with: { - "nexum:value-flow/types": super::venue_adapter::nexum::value_flow::types, - "nexum:intent/types": super::venue_adapter::nexum::intent::types, + "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, + "videre:types/types": super::venue_adapter::videre::types::types, }, }); } -/// The router-observed status transition delivered through the `event` -/// variant, re-exported at the plain spelling the router names. +/// The host-bound client interface: the `Host` trait the registry implements +/// and the `add_to_linker` the module linker calls. +pub use client_host::videre::venue::client; +/// The registry-observed status transition delivered through the `event` +/// variant, re-exported at the plain spelling the registry names. pub use nexum::host::types::IntentStatusUpdate; -/// The host-bound pool interface: the `Host` trait the router implements and -/// the `add_to_linker` the module linker calls. -pub use pool_host::nexum::intent::pool; -/// The shared intent ontology, re-exported at the plain spellings the router -/// and the `pool::Host` impl name. -pub use venue_adapter::nexum::intent::types::{ - AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, +/// The shared intent ontology, re-exported at the plain spellings the +/// registry and the `client::Host` impl name. +pub use venue_adapter::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, + VenueError, }; /// The value-flow vocabulary the header is expressed in. -pub use venue_adapter::nexum::value_flow::types as value_flow; - -/// Bindgen smoke for the `nexum:value-flow` types package. The package has -/// no host consumer yet (the intent router that will bind it lands later), -/// so this compiles it under test only, through a throwaway world that -/// imports the interface. Its value is the identifier-hygiene gate: the -/// test names every generated type, variant, and field by its plain Rust -/// spelling, so a WIT id that collided with a Rust keyword would surface as -/// an `r#` escape and fail to compile here rather than in a downstream -/// binding. +pub use venue_adapter::videre::value_flow::types as value_flow; + +/// Bindgen smoke for the `videre:value-flow` types package, compiled under +/// test through a throwaway world that imports the interface. Its value is +/// the identifier-hygiene gate: the test names every generated type, +/// variant, and field by its plain Rust spelling, so a WIT id that collided +/// with a Rust keyword would surface as an `r#` escape and fail to compile +/// here rather than in a downstream binding. #[cfg(test)] mod value_flow_smoke { wasmtime::component::bindgen!({ inline: " - package nexum:value-flow-smoke; + package videre:value-flow-smoke; world smoke { - import nexum:value-flow/types@0.1.0; + import videre:value-flow/types@0.1.0; } ", - path: ["../../wit/nexum-value-flow"], + path: ["../../wit/videre-value-flow"], }); #[test] fn identifiers_bind_unescaped() { - use nexum::value_flow::types::{Asset, AssetAmount, OffchainDesc, ServiceDesc, Settlement}; + use videre::value_flow::types::{Asset, AssetAmount, Erc20}; - let _ = Settlement::EvmChain(1); - let _ = Settlement::Offchain(String::new()); - - let service = ServiceDesc { - kind: String::new(), - summary: String::new(), - }; - let offchain = OffchainDesc { - domain: String::new(), - summary: String::new(), + let erc20 = Erc20 { + token: vec![0u8; 20], }; - - let _ = Asset::NativeToken(Settlement::EvmChain(1)); - let _ = Asset::Erc20((1, Vec::new())); - let _ = Asset::Erc721((1, Vec::new(), Vec::new())); - let _ = Asset::Erc1155((1, Vec::new(), Vec::new())); - let _ = Asset::Service(service); - let asset = Asset::Offchain(offchain); + let _ = Asset::Native; + let asset = Asset::Erc20(erc20); let amount = AssetAmount { asset, @@ -147,34 +138,39 @@ mod value_flow_smoke { } } -/// Bindgen smoke for the `nexum:intent` package, mirroring the value-flow -/// smoke above: no host consumer exists yet (the pool router lands later), -/// so the package compiles under test only, through a throwaway world that -/// imports the pool interface and, transitively, the types interface and -/// its value-flow dependency. The test names every generated type, case, -/// and field by its plain Rust spelling, and a dummy `pool` host impl pins +/// Bindgen smoke for the `videre:types` and `videre:venue` packages, +/// mirroring the value-flow smoke above: a throwaway world imports the +/// client interface and, transitively, the types interface and its +/// value-flow dependency. The test names every generated type, case, and +/// field by its plain Rust spelling, and a dummy `client` host impl pins /// the three function signatures, so a keyword collision or an accidental /// signature change fails this build rather than a downstream binding. #[cfg(test)] -mod intent_smoke { +mod client_smoke { wasmtime::component::bindgen!({ inline: " - package nexum:intent-smoke; + package videre:client-smoke; world smoke { - import nexum:intent/pool@0.1.0; + import videre:venue/client@0.1.0; } ", - path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], }); - use nexum::intent::types::{ - AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, + use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, + VenueError, }; - use nexum::value_flow::types::Settlement; + use videre::value_flow::types::{Asset, AssetAmount}; - struct DummyPool; + struct DummyClient; - impl nexum::intent::pool::Host for DummyPool { + impl videre::venue::client::Host for DummyClient { fn submit(&mut self, _venue: String, _body: Vec) -> Result { Err(VenueError::UnknownVenue) } @@ -192,55 +188,56 @@ mod intent_smoke { } } + fn amount(bytes: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: bytes, + } + } + #[test] fn identifiers_bind_unescaped() { - use nexum::intent::pool::Host; + use videre::venue::client::Host; - let _ = AuthScheme::Eip712; let _ = AuthScheme::Eip1271; - let _ = AuthScheme::Presign; - let _ = AuthScheme::OffchainSig; - let _ = AuthScheme::Unsigned; + let _ = AuthScheme::Eip712; let header = IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), + gives: amount(vec![1]), + wants: amount(Vec::new()), + settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip712, }; - assert!(header.gives.is_empty() && header.wants.is_empty()); + assert!(header.wants.amount.is_empty()); let _ = IntentStatus::Pending; let _ = IntentStatus::Open; - let _ = IntentStatus::Settled(None); - let _ = IntentStatus::Failed(FailReason { - code: String::new(), - detail: String::new(), - }); - let _ = IntentStatus::Expired; + let _ = IntentStatus::Fulfilled; let _ = IntentStatus::Cancelled; + let _ = IntentStatus::Expired; let tx = UnsignedTx { - chain_id: 1, + chain: 1, to: Vec::new(), value: Vec::new(), - input: Vec::new(), + data: Vec::new(), }; let _ = SubmitOutcome::Accepted(Vec::new()); let _ = SubmitOutcome::RequiresSigning(tx); + let _ = VenueError::UnknownVenue; let _ = VenueError::InvalidBody(String::new()); - let _ = VenueError::InvalidReceipt; - let _ = VenueError::Rejected(String::new()); + let _ = VenueError::Unsupported; let _ = VenueError::Denied(String::new()); - let _ = VenueError::Unsupported(String::new()); + let _ = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); let _ = VenueError::Unavailable(String::new()); - let _ = VenueError::InternalError(String::new()); + let _ = VenueError::Timeout; - let mut pool = DummyPool; - assert!(pool.submit(String::new(), Vec::new()).is_err()); - assert!(pool.status(String::new(), Vec::new()).is_err()); - assert!(pool.cancel(String::new(), Vec::new()).is_err()); + let mut client = DummyClient; + assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.status(String::new(), Vec::new()).is_err()); + assert!(client.cancel(String::new(), Vec::new()).is_err()); } } diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 262e5939..34ad617b 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -268,7 +268,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // will see: at least one intent-status subscriber and at least one // installed adapter to poll. let poll_statuses = supervisor.has_intent_status_subscribers() - && supervisor.pool_router().venue_count() > 0; + && supervisor.venue_registry().venue_count() > 0; // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. @@ -308,7 +308,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { ); let intent_status_stream = poll_statuses.then(|| { event_loop::open_intent_status_stream( - supervisor.pool_router(), + supervisor.venue_registry(), engine_cfg.limits.status_poll_interval(), &executor, &mut reconnect_tasks, diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index ea278de3..06cbc08a 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,7 +26,7 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; -use crate::host::pool_router::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, PoolQuota}; +use crate::host::venue_registry::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, SubmitQuota}; use crate::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; @@ -284,7 +284,7 @@ const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; /// history for diagnosis without unbounded growth. const DEFAULT_LOG_RUNS_RETAINED: usize = 16; -/// Default cadence for router-driven intent status polling (5 s). Fast +/// Default cadence for registry-driven intent status polling (5 s). Fast /// enough that a settling intent is observed within a block time or two, /// slow enough that per-receipt venue calls stay negligible. const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); @@ -488,11 +488,11 @@ impl ModuleLimits { } /// Resolved per-caller submission quota (overrides or defaults). A zero - /// `max_charges` is saturated up to 1 by the router builder, so a + /// `max_charges` is saturated up to 1 by the registry builder, so a /// misconfigured budget still admits one submission rather than bricking /// every venue. - pub fn quota(&self) -> PoolQuota { - PoolQuota::new( + pub fn quota(&self) -> SubmitQuota { + SubmitQuota::new( self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), self.quota .window_secs @@ -588,7 +588,7 @@ pub struct PoisonLimitsSection { } /// `[limits.quota]` per-caller intent submission budget. Both optional; -/// omitted values resolve to the router defaults via [`ModuleLimits::quota`]. +/// omitted values resolve to the registry defaults via [`ModuleLimits::quota`]. /// /// A caller (a strategy module, keyed by its namespace) may accrue at most /// `max_charges` submissions within a sliding `window_secs`; a decode failure @@ -607,7 +607,7 @@ pub struct QuotaLimitsSection { /// omitted value resolves to the built-in default and a degenerate zero /// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. /// -/// The cadence is how often the router polls each installed adapter's +/// The cadence is how often the registry polls each installed adapter's /// `status` export for the receipts it watches; only observed transitions /// fan out as `intent-status` events. #[derive(Debug, Default, Deserialize)] diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index a5b80c14..40b65ade 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -11,6 +11,6 @@ mod identity; mod local_store; mod logging; mod messaging; -mod pool; mod remote_store; mod types; +mod venue_client; diff --git a/crates/nexum-runtime/src/host/impls/pool.rs b/crates/nexum-runtime/src/host/impls/pool.rs deleted file mode 100644 index d02e29e5..00000000 --- a/crates/nexum-runtime/src/host/impls/pool.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! `nexum:intent/pool`: the strategy-facing intent import. Every method is a -//! thin delegation to the shared [`PoolRouter`](crate::host::pool_router) -//! carried in the store; the router owns the venue resolution, per-adapter -//! serialisation, guard seam, and quota. The caller identity the router meters -//! against is this store's module namespace. - -use crate::bindings::pool::Host; -use crate::bindings::{IntentStatus, SubmitOutcome, VenueError}; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -impl Host for HostState { - async fn submit(&mut self, venue: String, body: Vec) -> Result { - self.pool_router - .submit(&self.run.module, &venue, body) - .await - } - - async fn status( - &mut self, - venue: String, - receipt: Vec, - ) -> Result { - self.pool_router.status(&venue, receipt).await - } - - async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { - self.pool_router.cancel(&venue, receipt).await - } -} diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/nexum-runtime/src/host/impls/venue_client.rs new file mode 100644 index 00000000..26e76f59 --- /dev/null +++ b/crates/nexum-runtime/src/host/impls/venue_client.rs @@ -0,0 +1,36 @@ +//! `videre:venue/client`: the keeper-facing venue import. Every method is a +//! thin delegation to the shared +//! [`VenueRegistry`](crate::host::venue_registry) carried in the store; the +//! registry owns the venue resolution, per-adapter serialisation, guard +//! seam, and quota. The caller identity the registry meters against is this +//! store's module namespace. + +use crate::bindings::client::Host; +use crate::bindings::{IntentStatus, SubmitOutcome, VenueError}; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; +use crate::host::venue_registry::VenueId; + +impl Host for HostState { + async fn submit(&mut self, venue: String, body: Vec) -> Result { + self.venue_registry + .submit(&self.run.module, &VenueId::from(venue), body) + .await + } + + async fn status( + &mut self, + venue: String, + receipt: Vec, + ) -> Result { + self.venue_registry + .status(&VenueId::from(venue), receipt) + .await + } + + async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + self.venue_registry + .cancel(&VenueId::from(venue), receipt) + .await + } +} diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 5c41e467..1cf10f5d 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -31,6 +31,6 @@ pub mod http; mod impls; pub mod local_store_redb; pub mod logs; -pub mod pool_router; pub mod provider_pool; pub mod state; +pub mod venue_registry; diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index d299ccd0..5dd07d85 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -13,7 +13,7 @@ use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; -use super::pool_router::PoolRouter; +use super::venue_registry::VenueRegistry; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -51,10 +51,10 @@ pub struct HostState { /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, - /// The intent pool router the `nexum:intent/pool` import dispatches to. + /// The venue registry the `videre:venue/client` import dispatches to. /// Every module store carries the same shared handle; an adapter store, - /// which cannot call pool, carries an empty one. - pub pool_router: PoolRouter, + /// which cannot call the client face, carries an empty one. + pub venue_registry: VenueRegistry, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/venue_registry.rs similarity index 67% rename from crates/nexum-runtime/src/host/pool_router.rs rename to crates/nexum-runtime/src/host/venue_registry.rs index 6353cf8a..dd4637b7 100644 --- a/crates/nexum-runtime/src/host/pool_router.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -1,16 +1,16 @@ -//! The intent pool router: the strategy-facing `nexum:intent/pool` import +//! The venue registry: the keeper-facing `videre:venue/client` import //! resolved to installed venue adapters. //! -//! A module's `pool::submit(venue, body)` reaches the host here. The router -//! resolves the venue id to the one installed adapter that answers for it, -//! then drives a fixed sequence against that adapter: derive the header, -//! run the guard interposition seam on it, and only then submit. Status and -//! cancel are pass-throughs; they are not submissions, so they skip the -//! header, the guard, and the quota. +//! A module's `client::submit(venue, body)` reaches the host here. The +//! registry resolves the venue id to the one installed adapter that answers +//! for it, then drives a fixed sequence against that adapter: derive the +//! header, run the guard interposition seam on it, and only then submit. +//! Status and cancel are pass-throughs; they are not submissions, so they +//! skip the header, the guard, and the quota. //! //! Invocation is serialised per adapter. A wasmtime `Store` is not `Sync`, -//! so each adapter sits behind its own async mutex: concurrent pool calls to -//! the same venue queue on that mutex, while calls to different venues run +//! so each adapter sits behind its own async mutex: concurrent client calls +//! to the same venue queue on that mutex, while calls to different venues run //! in parallel. The lock is held across the guest await, which is the whole //! point - it is the actor boundary that keeps one adapter store //! single-threaded. @@ -23,6 +23,7 @@ //! own budget rather than the adapter's. use std::collections::{HashMap, VecDeque}; +use std::fmt; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -33,7 +34,8 @@ use tracing::warn; use wasmtime::Store; use crate::bindings::{ - IntentHeader, IntentStatus, IntentStatusUpdate, SubmitOutcome, VenueAdapter, VenueError, + IntentHeader, IntentStatus, IntentStatusUpdate, RateLimit, SubmitOutcome, VenueAdapter, + VenueError, }; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; @@ -43,18 +45,48 @@ pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; /// Default sliding window the per-caller submission budget is counted over. pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Venue identifier: the id an adapter registers under and a submission +/// names. Opaque beyond equality. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct VenueId(String); + +impl VenueId { + /// The id at its wire spelling. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for VenueId { + fn from(id: String) -> Self { + Self(id) + } +} + +impl From<&str> for VenueId { + fn from(id: &str) -> Self { + Self(id.to_owned()) + } +} + +impl fmt::Display for VenueId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + /// Per-caller submission quota. Both a forwarded submission and a charged /// decode failure consume one unit; the window slides so a caller's budget /// refills as old charges age out. #[derive(Debug, Clone, Copy)] -pub struct PoolQuota { +pub struct SubmitQuota { /// Maximum charges a single caller may accrue within `window`. pub max_charges: u32, /// Sliding window the charges are counted across. pub window: Duration, } -impl PoolQuota { +impl SubmitQuota { /// Pair a budget with the window it is counted over. pub const fn new(max_charges: u32, window: Duration) -> Self { Self { @@ -64,30 +96,37 @@ impl PoolQuota { } } -impl Default for PoolQuota { +impl Default for SubmitQuota { fn default() -> Self { Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) } } -/// The guard interposition seam. The router runs this on the adapter-derived -/// header after `derive-header` and before `submit`. The shipped policy is a -/// no-op that allows every egress; the egress-guard epic replaces the -/// installed policy with the real facts-plus-analysers pipeline without the -/// router changing shape. -pub trait GuardPolicy: Send + Sync { +/// The guard interposition seam. The registry runs this on the +/// adapter-derived header after `derive-header` and before `submit`. The +/// shipped policy is the unit guard, which allows every egress; the +/// egress-guard epic replaces the installed policy with the real +/// facts-plus-analysers pipeline without the registry changing shape. +pub trait EgressGuard: Send + Sync { /// Decide whether the derived header may proceed to the adapter's submit. fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; } +/// The unit guard: allow every egress. +impl EgressGuard for () { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Allow + } +} + /// What the guard sees: who is submitting, to which venue, and the header the /// adapter derived from the opaque body. The header is the stable ontology /// policy has teeth on; the raw body never reaches the guard. pub struct GuardContext<'a> { /// Namespace of the calling module. pub caller: &'a str, - /// Venue id the submission is routed to. - pub venue: &'a str, + /// Venue the submission is routed to. + pub venue: &'a VenueId, /// Adapter-derived header for the body. pub header: &'a IntentHeader, } @@ -100,24 +139,15 @@ pub enum GuardVerdict { Deny(String), } -/// The shipped no-op policy: allow every egress. Named so the composition -/// root reads plainly and the egress-guard epic has an obvious thing to swap. -pub struct AllowAllGuard; - -impl GuardPolicy for AllowAllGuard { - fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { - GuardVerdict::Allow - } -} - /// The per-adapter invocation seam. One installed adapter answers for exactly -/// one venue; the router owns the adapter's `Store` behind an async mutex and -/// reaches it only through this trait, so the router's sequencing and quota -/// logic is testable against a stub that never spins up a wasmtime store. +/// one venue; the registry owns the adapter's `Store` behind an async mutex +/// and reaches it only through this trait, so the registry's sequencing and +/// quota logic is testable against a stub that never spins up a wasmtime +/// store. /// -/// The futures are boxed so the router can hold heterogeneous adapters behind -/// one `dyn` slot without the whole router turning generic over an adapter -/// type it never names. +/// The futures are boxed so the registry can hold heterogeneous adapters +/// behind one `dyn` slot without the whole registry turning generic over an +/// adapter type it never names. pub trait VenueInvoker: Send { /// Project the opaque body onto the stable header the guard runs on. fn derive_header<'a>( @@ -139,16 +169,16 @@ pub trait VenueInvoker: Send { /// The live adapter: a supervised wasmtime `Store` plus the `venue-adapter` /// bindings, refuelled before each guest call. A trap is projected onto -/// `internal-error` rather than propagated: a misbehaving adapter must not be -/// the caller's fault, and it must not unwind through the router into the +/// `unavailable` rather than propagated: a misbehaving adapter must not be +/// the caller's fault, and it must not unwind through the registry into the /// calling module's store. -pub struct AdapterActor { +pub struct VenueActor { store: Store>, bindings: VenueAdapter, fuel_per_call: u64, } -impl AdapterActor { +impl VenueActor { /// Wrap an instantiated adapter store for routing. pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { Self { @@ -163,7 +193,7 @@ impl AdapterActor { fn refuel(&mut self) -> Result<(), VenueError> { self.store .set_fuel(self.fuel_per_call) - .map_err(|e| VenueError::InternalError(format!("adapter refuel failed: {e}"))) + .map_err(|e| VenueError::Unavailable(format!("adapter refuel failed: {e}"))) } } @@ -171,10 +201,10 @@ impl AdapterActor { /// carried so an operator sees why the adapter died without the wasm frame /// list leaking to the calling module. fn trap_to_venue_error(trap: wasmtime::Error) -> VenueError { - VenueError::InternalError(format!("adapter trapped: {}", trap.root_cause())) + VenueError::Unavailable(format!("adapter trapped: {}", trap.root_cause())) } -impl VenueInvoker for AdapterActor { +impl VenueInvoker for VenueActor { fn derive_header<'a>( &'a mut self, body: &'a [u8], @@ -183,7 +213,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_derive_header(&mut self.store, body) .await { @@ -201,7 +231,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_submit(&mut self.store, body) .await { @@ -216,7 +246,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_status(&mut self.store, &receipt) .await { @@ -231,7 +261,7 @@ impl VenueInvoker for AdapterActor { self.refuel()?; match self .bindings - .nexum_intent_adapter() + .videre_venue_adapter() .call_cancel(&mut self.store, &receipt) .await { @@ -251,86 +281,74 @@ struct QuotaLedger { per_caller: HashMap>, } -/// One receipt the router polls for status transitions. `last` starts +/// One receipt the registry polls for status transitions. `last` starts /// `None` so the first successful poll always reports, giving a /// subscriber the intent's current state without waiting for a change. struct WatchedIntent { - venue: String, + venue: VenueId, receipt: Vec, last: Option, } /// A polled status is terminal when the intent can never change again: -/// the router stops watching the receipt after reporting it. -fn is_terminal(status: &IntentStatus) -> bool { +/// the registry stops watching the receipt after reporting it. +fn is_terminal(status: IntentStatus) -> bool { matches!( status, - IntentStatus::Settled(_) - | IntentStatus::Failed(_) - | IntentStatus::Expired - | IntentStatus::Cancelled + IntentStatus::Fulfilled | IntentStatus::Cancelled | IntentStatus::Expired ) } -/// Lower an adapter-reported status onto the opaque status body the host -/// `event` stream carries: `settled` is `fulfilled` plus its proof, and -/// `failed` is `cancelled` plus its reason (the body's lifecycle enum has -/// no failed case). -fn status_body(status: &IntentStatus) -> StatusBody { +/// Lower a polled status onto the opaque status body the host `event` +/// stream carries. The registry attests the lifecycle state alone; proof +/// and failure reason ride the body only when the venue supplies them. +fn status_body(status: IntentStatus) -> StatusBody { use nexum_status_body::IntentStatus as Lifecycle; - let (status, proof, reason) = match status { - IntentStatus::Pending => (Lifecycle::Pending, None, None), - IntentStatus::Open => (Lifecycle::Open, None, None), - IntentStatus::Settled(proof) => (Lifecycle::Fulfilled, proof.clone(), None), - IntentStatus::Failed(reason) => ( - Lifecycle::Cancelled, - None, - Some(nexum_status_body::FailReason { - code: reason.code.clone(), - detail: reason.detail.clone(), - }), - ), - IntentStatus::Expired => (Lifecycle::Expired, None, None), - IntentStatus::Cancelled => (Lifecycle::Cancelled, None, None), + let status = match status { + IntentStatus::Pending => Lifecycle::Pending, + IntentStatus::Open => Lifecycle::Open, + IntentStatus::Fulfilled => Lifecycle::Fulfilled, + IntentStatus::Cancelled => Lifecycle::Cancelled, + IntentStatus::Expired => Lifecycle::Expired, }; StatusBody { status, - proof, - reason, + proof: None, + reason: None, } } -/// The shared router state. Cloning a [`PoolRouter`] is an `Arc` bump; every -/// module store carries the same handle, so a submission from any module -/// reaches the same adapters and the same quota ledger. -struct PoolRouterInner { - adapters: HashMap, - guard: Arc, - quota: PoolQuota, +/// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; +/// every module store carries the same handle, so a submission from any +/// module reaches the same adapters and the same quota ledger. +struct VenueRegistryInner { + adapters: HashMap, + guard: Arc, + quota: SubmitQuota, ledger: Mutex, /// Receipts under status watch, appended by accepted submissions and /// pruned as they reach a terminal status. watched: Mutex>, } -/// The strategy-facing pool router, cheap to clone and shared across every +/// The keeper-facing venue registry, cheap to clone and shared across every /// module store. #[derive(Clone)] -pub struct PoolRouter { - inner: Arc, +pub struct VenueRegistry { + inner: Arc, } -impl PoolRouter { - /// An empty router: no adapters, the no-op guard, the default quota. This - /// is what an adapter store (which cannot call pool) and the single-module - /// `just run` path carry. +impl VenueRegistry { + /// An empty registry: no adapters, the unit guard, the default quota. + /// This is what an adapter store (which cannot call the client face) and + /// the single-module `just run` path carry. pub fn empty() -> Self { - PoolRouterBuilder::new(PoolQuota::default()).build() + VenueRegistryBuilder::new(SubmitQuota::default()).build() } /// Resolve a venue id to its installed adapter slot. - fn resolve(&self, venue: &str) -> Result { + fn resolve(&self, venue: &VenueId) -> Result { self.inner .adapters .get(venue) @@ -372,16 +390,17 @@ impl PoolRouter { pub async fn submit( &self, caller: &str, - venue: &str, + venue: &VenueId, body: Vec, ) -> Result { let slot = self.resolve(venue)?; // Gate before touching the adapter so a quota-exhausted caller never - // reaches the adapter store or its mutex. + // reaches the adapter store or its mutex. Exhaustion is retryable + // once the window slides, so it is rate-limited, never denied. if !self.quota_admits(caller) { - return Err(VenueError::Denied(format!( - "submission quota exhausted for caller {caller}" - ))); + return Err(VenueError::RateLimited(RateLimit { + retry_after_ms: Some(window_ms(self.inner.quota.window)), + })); } let mut adapter = slot.lock().await; let header = match adapter.derive_header(&body).await { @@ -416,16 +435,16 @@ impl PoolRouter { /// Put a `(venue, receipt)` pair under status watch. Idempotent: a /// re-submitted receipt keeps its existing watch entry. - fn watch(&self, venue: &str, receipt: Vec) { + fn watch(&self, venue: &VenueId, receipt: Vec) { let mut watched = self.inner.watched.lock().expect("watch list poisoned"); if watched .iter() - .any(|w| w.venue == venue && w.receipt == receipt) + .any(|w| w.venue == *venue && w.receipt == receipt) { return; } watched.push(WatchedIntent { - venue: venue.to_owned(), + venue: venue.clone(), receipt, last: None, }); @@ -444,12 +463,11 @@ impl PoolRouter { /// return the transitions: statuses that differ from the last one /// reported for that receipt (the first successful poll always /// reports). A terminal status is reported once and the receipt is - /// dropped from the watch; a transport failure leaves the entry - /// untouched for the next cadence, except `invalid-receipt`, which - /// means the venue disowns the receipt, so watching is pointless. + /// dropped from the watch; a failure leaves the entry untouched for + /// the next cadence. pub async fn poll_status_transitions(&self) -> Vec { // Snapshot so the std mutex is never held across the guest await. - let snapshot: Vec<(String, Vec)> = { + let snapshot: Vec<(VenueId, Vec)> = { let watched = self.inner.watched.lock().expect("watch list poisoned"); watched .iter() @@ -458,7 +476,7 @@ impl PoolRouter { }; let mut updates = Vec::new(); for (venue, receipt) in snapshot { - // Installed adapters never leave the router, so a resolve + // Installed adapters never leave the registry, so a resolve // failure here is unreachable; skip defensively regardless. let Ok(slot) = self.resolve(&venue) else { continue; @@ -473,10 +491,6 @@ impl PoolRouter { updates.push(update); } } - Err(VenueError::InvalidReceipt) => { - warn!(venue = %venue, "venue disowns a watched receipt - dropping it"); - self.unwatch(&venue, &receipt); - } Err(err) => { warn!( venue = %venue, @@ -497,19 +511,19 @@ impl PoolRouter { /// cadence). fn record_polled_status( &self, - venue: &str, + venue: &VenueId, receipt: &[u8], status: IntentStatus, ) -> Option { let mut watched = self.inner.watched.lock().expect("watch list poisoned"); let pos = watched .iter() - .position(|w| w.venue == venue && w.receipt == receipt)?; - let changed = watched[pos].last.as_ref() != Some(&status); + .position(|w| w.venue == *venue && w.receipt == receipt)?; + let changed = watched[pos].last != Some(status); let update = if changed { - match status_body(&status).encode() { + match status_body(status).encode() { Ok(body) => Some(IntentStatusUpdate { - venue: venue.to_owned(), + venue: venue.as_str().to_owned(), receipt: receipt.to_vec(), status: body, }), @@ -525,7 +539,7 @@ impl PoolRouter { } else { None }; - if is_terminal(&status) { + if is_terminal(status) { watched.remove(pos); } else { watched[pos].last = Some(status); @@ -533,15 +547,13 @@ impl PoolRouter { update } - /// Drop a `(venue, receipt)` pair from the status watch. - fn unwatch(&self, venue: &str, receipt: &[u8]) { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - watched.retain(|w| !(w.venue == venue && w.receipt == receipt)); - } - /// Report where a previously submitted intent is in its life. Not a /// submission: no header, no guard, no quota, just the serialised call. - pub async fn status(&self, venue: &str, receipt: Vec) -> Result { + pub async fn status( + &self, + venue: &VenueId, + receipt: Vec, + ) -> Result { let slot = self.resolve(venue)?; let mut adapter = slot.lock().await; adapter.status(receipt).await @@ -549,7 +561,7 @@ impl PoolRouter { /// Ask the venue to withdraw an intent. Not a submission, so it skips the /// header, guard, and quota like `status`. - pub async fn cancel(&self, venue: &str, receipt: Vec) -> Result<(), VenueError> { + pub async fn cancel(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { let slot = self.resolve(venue)?; let mut adapter = slot.lock().await; adapter.cancel(receipt).await @@ -561,6 +573,11 @@ impl PoolRouter { } } +/// A quota window as whole milliseconds, saturating at `u64::MAX`. +fn window_ms(window: Duration) -> u64 { + u64::try_from(window.as_millis()).unwrap_or(u64::MAX) +} + /// Drop charge timestamps that have aged out of the window. fn prune(history: &mut VecDeque, window: Duration) { let now = Instant::now(); @@ -573,29 +590,29 @@ fn prune(history: &mut VecDeque, window: Duration) { } } -/// Assembles a [`PoolRouter`]: adapters install first (at supervisor boot, -/// before any module store carries the built router), then the router -/// freezes. The guard defaults to the no-op [`AllowAllGuard`]; the -/// egress-guard epic overrides it here. -pub struct PoolRouterBuilder { - adapters: HashMap, - guard: Arc, - quota: PoolQuota, +/// Assembles a [`VenueRegistry`]: adapters install first (at supervisor +/// boot, before any module store carries the built registry), then the +/// registry freezes. The guard defaults to the unit guard; the egress-guard +/// epic overrides it here. +pub struct VenueRegistryBuilder { + adapters: HashMap, + guard: Arc, + quota: SubmitQuota, } -impl PoolRouterBuilder { - /// Start an empty builder with the given quota and the no-op guard. - pub fn new(quota: PoolQuota) -> Self { +impl VenueRegistryBuilder { + /// Start an empty builder with the given quota and the unit guard. + pub fn new(quota: SubmitQuota) -> Self { Self { adapters: HashMap::new(), - guard: Arc::new(AllowAllGuard), + guard: Arc::new(()), quota, } } /// Override the guard policy. The egress-guard epic wires the real /// pipeline through here; tests inject a denying policy to prove the seam. - pub fn with_guard(mut self, guard: Arc) -> Self { + pub fn with_guard(mut self, guard: Arc) -> Self { self.guard = guard; self } @@ -605,7 +622,7 @@ impl PoolRouterBuilder { /// which is a config error worth failing boot over. pub fn install( &mut self, - venue: String, + venue: VenueId, invoker: impl VenueInvoker + 'static, ) -> Result<(), DuplicateVenue> { if self.adapters.contains_key(&venue) { @@ -616,17 +633,17 @@ impl PoolRouterBuilder { Ok(()) } - /// Freeze the builder into a shared router. - pub fn build(self) -> PoolRouter { + /// Freeze the builder into a shared registry. + pub fn build(self) -> VenueRegistry { if self.quota.max_charges == 0 { - // A zero budget would deny every submission; saturate up to one so - // a misconfigured quota still admits a single submission rather + // A zero budget would refuse every submission; saturate up to one + // so a misconfigured quota still admits a single submission rather // than bricking every venue. Mirrors the poison-policy clamp. - warn!("pool submission quota max_charges is 0; clamping to 1"); + warn!("submission quota max_charges is 0; clamping to 1"); } - let quota = PoolQuota::new(self.quota.max_charges.max(1), self.quota.window); - PoolRouter { - inner: Arc::new(PoolRouterInner { + let quota = SubmitQuota::new(self.quota.max_charges.max(1), self.quota.window); + VenueRegistry { + inner: Arc::new(VenueRegistryInner { adapters: self.adapters, guard: self.guard, quota, @@ -639,10 +656,10 @@ impl PoolRouterBuilder { /// Two installed adapters claimed the same venue id. #[derive(Debug, thiserror::Error)] -#[error("venue id {venue:?} is claimed by more than one installed adapter")] +#[error("venue id {venue} is claimed by more than one installed adapter")] pub struct DuplicateVenue { /// The colliding venue id. - pub venue: String, + pub venue: VenueId, } #[cfg(test)] @@ -651,11 +668,16 @@ mod tests { use nexum_status_body::IntentStatus as Lifecycle; - use crate::bindings::value_flow::Settlement; - use crate::bindings::{AuthScheme, FailReason, IntentHeader, UnsignedTx}; + use crate::bindings::value_flow::{Asset, AssetAmount}; + use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; use super::*; + /// The venue id every test installs its stub adapter under. + fn cow() -> VenueId { + VenueId::from("cow") + } + /// Decode an update's opaque status body. fn decoded(update: &IntentStatusUpdate) -> StatusBody { StatusBody::decode(&update.status).expect("status body decodes") @@ -671,8 +693,8 @@ mod tests { } /// A programmable adapter that records call counts and returns canned - /// outcomes, so the router's sequencing, guard seam, and quota are tested - /// without a wasmtime store. + /// outcomes, so the registry's sequencing, guard seam, and quota are + /// tested without a wasmtime store. #[derive(Default)] struct StubCalls { derive: AtomicUsize, @@ -774,7 +796,7 @@ mod tests { /// A guard that refuses every egress with a fixed reason. struct DenyGuard; - impl GuardPolicy for DenyGuard { + impl EgressGuard for DenyGuard { fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { GuardVerdict::Deny("blocked by test policy".to_owned()) } @@ -782,36 +804,43 @@ mod tests { fn header() -> IntentHeader { IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), - authorisation: AuthScheme::Unsigned, + gives: AssetAmount { + asset: Asset::Native, + amount: vec![1], + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, } } - fn router_with( - quota: PoolQuota, - guard: Option>, + fn registry_with( + quota: SubmitQuota, + guard: Option>, adapter: StubAdapter, - ) -> PoolRouter { - let mut builder = PoolRouterBuilder::new(quota); + ) -> VenueRegistry { + let mut builder = VenueRegistryBuilder::new(quota); if let Some(guard) = guard { builder = builder.with_guard(guard); } - builder - .install("cow".to_owned(), adapter) - .expect("install adapter"); + builder.install(cow(), adapter).expect("install adapter"); builder.build() } #[tokio::test] async fn submit_round_trips_through_derive_guard_submit() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); - let outcome = router - .submit("mod-a", "cow", b"body".to_vec()) + let outcome = registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); @@ -823,10 +852,14 @@ mod tests { #[tokio::test] async fn unknown_venue_is_rejected_without_touching_an_adapter() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); - let err = router - .submit("mod-a", "unlisted", b"body".to_vec()) + let err = registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) .await .expect_err("unknown venue rejected"); @@ -838,14 +871,14 @@ mod tests { #[tokio::test] async fn guard_deny_blocks_submit_after_deriving_the_header() { let calls = Arc::new(StubCalls::default()); - let router = router_with( - PoolQuota::default(), + let registry = registry_with( + SubmitQuota::default(), Some(Arc::new(DenyGuard)), StubAdapter::new(calls.clone()), ); - let err = router - .submit("mod-a", "cow", b"body".to_vec()) + let err = registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect_err("guard denies"); @@ -857,19 +890,34 @@ mod tests { } #[tokio::test] - async fn submission_quota_denies_once_the_budget_is_spent() { + async fn submission_quota_rate_limits_once_the_budget_is_spent() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(2, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); - assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); - let err = router - .submit("mod-a", "cow", b"b".to_vec()) + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + let err = registry + .submit("mod-a", &cow(), b"b".to_vec()) .await .expect_err("third submit over quota"); - assert!(matches!(err, VenueError::Denied(reason) if reason.contains("quota"))); + // Exhaustion is retryable once the window slides: rate-limited + // carrying the window, never denied. + assert!(matches!( + err, + VenueError::RateLimited(rl) if rl.retry_after_ms == Some(3_600_000) + )); // The over-quota call is stopped at the gate, so the adapter saw only // the two admitted submits. assert_eq!(calls.submit.load(Ordering::SeqCst), 2); @@ -878,17 +926,28 @@ mod tests { #[tokio::test] async fn quota_is_per_caller() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); assert!( - router.submit("mod-a", "cow", b"b".to_vec()).await.is_err(), + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_err(), "mod-a is over its own budget" ); // A different caller has its own budget. assert!( - router.submit("mod-b", "cow", b"b".to_vec()).await.is_ok(), + registry + .submit("mod-b", &cow(), b"b".to_vec()) + .await + .is_ok(), "mod-b has an independent budget" ); } @@ -896,18 +955,18 @@ mod tests { #[tokio::test] async fn decode_failures_are_charged_and_stop_re_invoking_the_adapter() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); let adapter = StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); - let router = router_with(quota, None, adapter); + let registry = registry_with(quota, None, adapter); // First garbage body: derive fails, the failure is charged. - let first = router.submit("mod-a", "cow", b"junk".to_vec()).await; + let first = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; assert!(matches!(first, Err(VenueError::InvalidBody(_)))); // Second: the charge from the decode failure exhausts the budget, so // the caller is stopped at the gate and the adapter is not re-invoked. - let second = router.submit("mod-a", "cow", b"junk".to_vec()).await; - assert!(matches!(second, Err(VenueError::Denied(_)))); + let second = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; + assert!(matches!(second, Err(VenueError::RateLimited(_)))); assert_eq!( calls.derive.load(Ordering::SeqCst), 1, @@ -918,19 +977,19 @@ mod tests { #[tokio::test] async fn non_decode_venue_errors_are_not_charged() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); let adapter = StubAdapter::new(calls.clone()) .with_derive(Err(VenueError::Unavailable("rpc down".into()))); - let router = router_with(quota, None, adapter); + let registry = registry_with(quota, None, adapter); assert!(matches!( - router.submit("mod-a", "cow", b"b".to_vec()).await, + registry.submit("mod-a", &cow(), b"b".to_vec()).await, Err(VenueError::Unavailable(_)) )); // A venue-side failure did not spend the caller's budget: it may try // again, so derive is reached a second time. assert!(matches!( - router.submit("mod-a", "cow", b"b".to_vec()).await, + registry.submit("mod-a", &cow(), b"b".to_vec()).await, Err(VenueError::Unavailable(_)) )); assert_eq!(calls.derive.load(Ordering::SeqCst), 2); @@ -941,14 +1000,14 @@ mod tests { let calls = Arc::new(StubCalls::default()); // A spent budget must not block reads: status and cancel are not // submissions. - let quota = PoolQuota::new(1, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); assert!(matches!( - router.status("cow", b"r".to_vec()).await, + registry.status(&cow(), b"r".to_vec()).await, Ok(IntentStatus::Open) )); - assert!(router.cancel("cow", b"r".to_vec()).await.is_ok()); + assert!(registry.cancel(&cow(), b"r".to_vec()).await.is_ok()); assert_eq!(calls.status.load(Ordering::SeqCst), 1); assert_eq!(calls.cancel.load(Ordering::SeqCst), 1); } @@ -956,14 +1015,14 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_calls_to_one_adapter_are_serialised() { let calls = Arc::new(StubCalls::default()); - let quota = PoolQuota::new(1000, Duration::from_secs(3600)); - let router = router_with(quota, None, StubAdapter::new(calls.clone())); + let quota = SubmitQuota::new(1000, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); let mut handles = Vec::new(); for _ in 0..8 { - let router = router.clone(); + let registry = registry.clone(); handles.push(tokio::spawn(async move { - let _ = router.submit("mod-a", "cow", b"b".to_vec()).await; + let _ = registry.submit("mod-a", &cow(), b"b".to_vec()).await; })); } for h in handles { @@ -976,22 +1035,23 @@ mod tests { #[test] fn duplicate_venue_id_is_rejected() { - let mut builder = PoolRouterBuilder::new(PoolQuota::default()); + let mut builder = VenueRegistryBuilder::new(SubmitQuota::default()); let a = Arc::new(StubCalls::default()); let b = Arc::new(StubCalls::default()); builder - .install("cow".to_owned(), StubAdapter::new(a)) + .install(cow(), StubAdapter::new(a)) .expect("first install"); let err = builder - .install("cow".to_owned(), StubAdapter::new(b)) + .install(cow(), StubAdapter::new(b)) .expect_err("second install collides"); - assert_eq!(err.venue, "cow"); + assert_eq!(err.venue, cow()); } #[test] fn zero_quota_saturates_to_one() { - let router = PoolRouterBuilder::new(PoolQuota::new(0, Duration::from_secs(60))).build(); - assert_eq!(router.inner.quota.max_charges, 1); + let registry = + VenueRegistryBuilder::new(SubmitQuota::new(0, Duration::from_secs(60))).build(); + assert_eq!(registry.inner.quota.max_charges, 1); } // ── status watch + polling ──────────────────────────────────────── @@ -999,21 +1059,21 @@ mod tests { #[tokio::test] async fn accepted_submission_goes_under_status_watch() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls)); + let registry = registry_with(SubmitQuota::default(), None, StubAdapter::new(calls)); - assert_eq!(router.watched_count(), 0); - router - .submit("mod-a", "cow", b"body".to_vec()) + assert_eq!(registry.watched_count(), 0); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); - assert_eq!(router.watched_count(), 1); + assert_eq!(registry.watched_count(), 1); // Re-submitting the same receipt does not double-watch it. - router - .submit("mod-a", "cow", b"body".to_vec()) + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); - assert_eq!(router.watched_count(), 1); + assert_eq!(registry.watched_count(), 1); } #[tokio::test] @@ -1021,42 +1081,46 @@ mod tests { let calls = Arc::new(StubCalls::default()); let adapter = StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { - chain_id: 1, + chain: 1, to: vec![0u8; 20], value: Vec::new(), - input: Vec::new(), + data: Vec::new(), }))); - let router = router_with(PoolQuota::default(), None, adapter); + let registry = registry_with(SubmitQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); // No receipt exists yet, so there is nothing to poll. - assert_eq!(router.watched_count(), 0); - assert!(router.poll_status_transitions().await.is_empty()); + assert_eq!(registry.watched_count(), 0); + assert!(registry.poll_status_transitions().await.is_empty()); } #[tokio::test] async fn poll_reports_the_first_status_then_dedupes_repeats() { let calls = Arc::new(StubCalls::default()); - let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); - router - .submit("mod-a", "cow", b"body".to_vec()) + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); // First poll: `last` is unset, so the current status reports. - let first = router.poll_status_transitions().await; + let first = registry.poll_status_transitions().await; assert_eq!(first.len(), 1); assert_eq!(first[0].venue, "cow"); assert_eq!(first[0].receipt, b"receipt"); assert_eq!(decoded(&first[0]), plain(Lifecycle::Open)); // Second poll: same status, nothing to report. - assert!(router.poll_status_transitions().await.is_empty()); + assert!(registry.poll_status_transitions().await.is_empty()); assert_eq!(calls.status.load(Ordering::SeqCst), 2); - assert_eq!(router.watched_count(), 1, "open is not terminal"); + assert_eq!(registry.watched_count(), 1, "open is not terminal"); } #[tokio::test] @@ -1066,17 +1130,17 @@ mod tests { Ok(IntentStatus::Pending), Ok(IntentStatus::Pending), Ok(IntentStatus::Open), - Ok(IntentStatus::Settled(Some(b"tx".to_vec()))), + Ok(IntentStatus::Fulfilled), ]); - let router = router_with(PoolQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) + let registry = registry_with(SubmitQuota::default(), None, adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); let mut seen = Vec::new(); for _ in 0..4 { - seen.extend(router.poll_status_transitions().await); + seen.extend(registry.poll_status_transitions().await); } let statuses: Vec = seen.iter().map(decoded).collect(); assert_eq!( @@ -1084,17 +1148,13 @@ mod tests { vec![ plain(Lifecycle::Pending), plain(Lifecycle::Open), - StatusBody { - status: Lifecycle::Fulfilled, - proof: Some(b"tx".to_vec()), - reason: None, - }, + plain(Lifecycle::Fulfilled), ], "the repeated pending is deduplicated; each transition reports once", ); - assert_eq!(router.watched_count(), 0, "settled prunes the watch"); + assert_eq!(registry.watched_count(), 0, "fulfilled prunes the watch"); // A further poll has nothing left to ask the adapter about. - assert!(router.poll_status_transitions().await.is_empty()); + assert!(registry.poll_status_transitions().await.is_empty()); } #[tokio::test] @@ -1102,59 +1162,35 @@ mod tests { let calls = Arc::new(StubCalls::default()); let adapter = StubAdapter::new(calls) .with_status_script([Err(VenueError::Unavailable("venue down".into()))]); - let router = router_with(PoolQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) + let registry = registry_with(SubmitQuota::default(), None, adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) .await .expect("submit succeeds"); - assert!(router.poll_status_transitions().await.is_empty()); + assert!(registry.poll_status_transitions().await.is_empty()); assert_eq!( - router.watched_count(), + registry.watched_count(), 1, "transient failure keeps the entry" ); // The venue recovered: the next poll reports the current status. - let updates = router.poll_status_transitions().await; + let updates = registry.poll_status_transitions().await; assert_eq!(updates.len(), 1); assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); } #[test] - fn failed_lowers_to_cancelled_plus_reason() { - let body = status_body(&IntentStatus::Failed(FailReason { - code: "oc".into(), - detail: "od".into(), - })); - assert_eq!( - body, - StatusBody { - status: Lifecycle::Cancelled, - proof: None, - reason: Some(nexum_status_body::FailReason { - code: "oc".into(), - detail: "od".into(), - }), - }, - ); - } - - #[tokio::test] - async fn disowned_receipt_is_dropped_from_the_watch() { - let calls = Arc::new(StubCalls::default()); - let adapter = StubAdapter::new(calls).with_status_script([Err(VenueError::InvalidReceipt)]); - let router = router_with(PoolQuota::default(), None, adapter); - router - .submit("mod-a", "cow", b"body".to_vec()) - .await - .expect("submit succeeds"); - - assert!(router.poll_status_transitions().await.is_empty()); - assert_eq!( - router.watched_count(), - 0, - "a receipt the venue disowns is never polled again", - ); + fn every_lifecycle_state_lowers_onto_the_status_body() { + for (wire, lowered) in [ + (IntentStatus::Pending, Lifecycle::Pending), + (IntentStatus::Open, Lifecycle::Open), + (IntentStatus::Fulfilled, Lifecycle::Fulfilled), + (IntentStatus::Cancelled, Lifecycle::Cancelled), + (IntentStatus::Expired, Lifecycle::Expired), + ] { + assert_eq!(status_body(wire), plain(lowered)); + } } } diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 50108786..ef00fcef 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -39,17 +39,18 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// Capability names under the `nexum:intent/` package a module may import. -/// Only the strategy-facing `pool` interface is a capability; the `types` -/// package is type-only and needs no declaration. -pub const INTENT_CAPABILITIES: &[&str] = &["pool"]; - -/// The intent namespace: the `nexum:intent/pool` import is linked into every -/// module linker, so a module that submits intents declares the `pool` -/// capability the same way it declares a `nexum:host/` one. -pub const INTENT_NAMESPACE: NamespaceCaps = NamespaceCaps { - prefix: "nexum:intent/", - ifaces: INTENT_CAPABILITIES, +/// Capability names under the `videre:venue/` package a module may import. +/// Only the keeper-facing `client` interface is a capability; the +/// `videre:types` and `videre:value-flow` packages are type-only and need +/// no declaration. +pub const VENUE_CAPABILITIES: &[&str] = &["client"]; + +/// The venue namespace: the `videre:venue/client` import is linked into +/// every module linker, so a module that submits intents declares the +/// `client` capability the same way it declares a `nexum:host/` one. +pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { + prefix: "videre:venue/", + ifaces: VENUE_CAPABILITIES, }; /// The interfaces a `venue-adapter` world links: the scoped transport @@ -127,10 +128,10 @@ impl Default for CapabilityRegistry { impl CapabilityRegistry { /// The registry with the core `nexum:host/` namespace plus the - /// strategy-facing `nexum:intent/pool` import every module linker carries. + /// keeper-facing `videre:venue/client` import every module linker carries. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE, INTENT_NAMESPACE], + namespaces: vec![CORE_NAMESPACE, VENUE_NAMESPACE], } } @@ -327,12 +328,17 @@ mod tests { } #[test] - fn intent_pool_is_a_core_capability_but_intent_types_is_not() { + fn venue_client_is_a_core_capability_but_videre_types_is_not() { let r = CapabilityRegistry::core(); - assert_eq!(r.wit_import_to_cap("nexum:intent/pool@0.1.0"), Some("pool")); - assert!(r.is_known("pool")); - // The type-only interface is not a capability and needs no declaration. - assert_eq!(r.wit_import_to_cap("nexum:intent/types@0.1.0"), None); + assert_eq!( + r.wit_import_to_cap("videre:venue/client@0.1.0"), + Some("client") + ); + assert!(r.is_known("client")); + // The type-only interfaces are not capabilities and need no + // declaration. + assert_eq!(r.wit_import_to_cap("videre:types/types@0.1.0"), None); + assert_eq!(r.wit_import_to_cap("videre:value-flow/types@0.1.0"), None); } #[test] diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index dd6fa885..a63526df 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,8 +40,8 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; -use crate::host::pool_router::PoolRouter; use crate::host::provider_pool::ProviderError; +use crate::host::venue_registry::VenueRegistry; use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; @@ -147,32 +147,32 @@ where streams } -/// Router-driven intent status polling: one task that, on every cadence +/// Registry-driven intent status polling: one task that, on every cadence /// tick, polls each installed adapter's status export through the shared -/// [`PoolRouter`] and forwards the observed transitions. The task is +/// [`VenueRegistry`] and forwards the observed transitions. The task is /// spawned via `executor` into `tasks` like the reconnect tasks and exits /// cleanly when the loop's receiver drops. pub fn open_intent_status_stream( - router: PoolRouter, + registry: VenueRegistry, cadence: Duration, executor: &TaskExecutor, tasks: &mut TaskSet, ) -> IntentStatusStream { let (tx, rx) = mpsc::channel::(RECONNECT_CHANNEL_BUF); - tasks.push(executor.spawn(Box::pin(status_poll_task(router, cadence, tx)))); + tasks.push(executor.spawn(Box::pin(status_poll_task(registry, cadence, tx)))); Box::pin(receiver_stream(rx)) } /// Poll loop behind [`open_intent_status_stream`]. Sleeps the cadence /// first so the engine's boot dispatch settles before the first poll. async fn status_poll_task( - router: PoolRouter, + registry: VenueRegistry, cadence: Duration, tx: mpsc::Sender, ) -> TaskExit { loop { tokio::time::sleep(cadence).await; - for update in router.poll_status_transitions().await { + for update in registry.poll_status_transitions().await { if tx.send(update).await.is_err() { // Receiver dropped -> engine shutting down. return TaskExit::ReceiverGone; diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 78390678..363b0cc9 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -48,10 +48,10 @@ use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; -use crate::host::pool_router::{AdapterActor, PoolRouter, PoolRouterBuilder}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; +use crate::host::venue_registry::{VenueActor, VenueId, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ self, CapabilityRegistry, LoadedManifest, ModuleKind, ResourceSection, Subscription, }; @@ -61,13 +61,13 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// The intent pool router: every installed venue adapter's serialising + /// The venue registry: every installed venue adapter's serialising /// store, keyed by venue id. Cached so a module restart rebuilds a store /// carrying the same shared handle. Adapters boot through the same store, /// fuel, and memory machinery as modules but carry no subscriptions: - /// modules reach them through this router, not through dispatch. Folding + /// modules reach them through this registry, not through dispatch. Folding /// adapters into the restart and poison sweeps is still a later change. - pool_router: PoolRouter, + venue_registry: VenueRegistry, /// Venue adapters loaded at boot, whether or not `init` succeeded. adapters_total: usize, /// Adapters whose `init` succeeded and that are installed for routing. @@ -254,16 +254,16 @@ struct LoadedModule { } /// A venue adapter instantiated into a supervised store, ready to install in -/// the pool router. It boots through the same store, fuel, and memory +/// the venue registry. It boots through the same store, fuel, and memory /// machinery as a module but carries no subscriptions: modules reach it -/// through the router, not through dispatch. Adapter restart and poison +/// through the registry, not through dispatch. Adapter restart and poison /// handling are still a later change; an `init` failure leaves `alive` false /// so the adapter is loaded but not routable. struct LoadedAdapter { /// Venue id the adapter answers for (its manifest name). - venue_id: String, - /// The refuelable adapter store, ready to serialise behind a router mutex. - actor: AdapterActor, + venue_id: VenueId, + /// The refuelable adapter store, ready to serialise behind a registry mutex. + actor: VenueActor, /// Whether `init` succeeded; a failed adapter is not installed for routing. alive: bool, } @@ -281,14 +281,15 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - // Adapters instantiate first: the pool router must contain them before - // any module store (which carries the built router) is built. Adapters - // link only their scoped transport, against a dedicated linker built - // from the same core backends, and their own stores carry an empty - // router since an adapter cannot call pool. + // Adapters instantiate first: the venue registry must contain them + // before any module store (which carries the built registry) is + // built. Adapters link only their scoped transport, against a + // dedicated linker built from the same core backends, and their own + // stores carry an empty registry since an adapter cannot call the + // client face. let adapter_linker = build_adapter_linker::(engine)?; let adapter_registry = CapabilityRegistry::adapter(); - let mut router_builder = PoolRouterBuilder::new(engine_cfg.limits.quota()); + let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; for entry in &engine_cfg.adapters { @@ -305,7 +306,7 @@ impl Supervisor { .with_context(|| format!("load adapter {}", entry.path.display()))?; if loaded.alive { adapters_alive += 1; - router_builder + registry_builder .install(loaded.venue_id.clone(), loaded.actor) .with_context(|| format!("install adapter {}", loaded.venue_id))?; } else { @@ -315,7 +316,7 @@ impl Supervisor { ); } } - let pool_router = router_builder.build(); + let venue_registry = registry_builder.build(); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { @@ -327,7 +328,7 @@ impl Supervisor { &engine_cfg.limits, ®istry, clocks.as_ref(), - pool_router.clone(), + venue_registry.clone(), ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -343,7 +344,7 @@ impl Supervisor { ); Ok(Self { modules, - pool_router, + venue_registry, adapters_total, adapters_alive, engine: engine.clone(), @@ -377,9 +378,9 @@ impl Supervisor { manifest: manifest.map(Path::to_path_buf), }; // The single-module override path serves `just run`; adapters are - // configured through `engine.toml`, so the router is empty here and - // every pool call resolves to `unknown-venue`. - let pool_router = PoolRouter::empty(); + // configured through `engine.toml`, so the registry is empty here and + // every client call resolves to `unknown-venue`. + let venue_registry = VenueRegistry::empty(); let loaded = Self::load_one( engine, linker, @@ -388,12 +389,12 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), - pool_router.clone(), + venue_registry.clone(), ) .await?; Ok(Self { modules: vec![loaded], - pool_router, + venue_registry, adapters_total: 0, adapters_alive: 0, engine: engine.clone(), @@ -423,7 +424,7 @@ impl Supervisor { chain_response_max_bytes: usize, state_quota: u64, clocks: Option<&WasiClockOverride>, - pool_router: PoolRouter, + venue_registry: VenueRegistry, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -481,7 +482,7 @@ impl Supervisor { chain: components.chain.clone(), chain_response_max_bytes, store: module_store, - pool_router, + venue_registry, }, ); store.limiter(|state| &mut state.limits); @@ -490,7 +491,7 @@ impl Supervisor { } // One flat argument per shared input threaded onto the store, plus the - // pool router the module's `nexum:intent/pool` import dispatches to. + // venue registry the module's `videre:venue/client` import dispatches to. #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, @@ -500,7 +501,7 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - pool_router: PoolRouter, + venue_registry: VenueRegistry, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -565,7 +566,7 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), state_bytes, clocks, - pool_router, + venue_registry, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -661,7 +662,7 @@ impl Supervisor { /// capability set, build a supervised store carrying the operator's /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings /// against the adapter linker, and run `init`. Nothing dispatches to - /// the result yet; it boots so the router can later reach it. + /// the result yet; it boots so the registry can later reach it. async fn load_adapter( engine: &Engine, linker: &Linker>, @@ -732,9 +733,10 @@ impl Supervisor { ); let run = RunId::new(adapter_namespace.clone(), 0); - // An adapter store cannot call pool, so it carries an empty router; - // this also keeps the real router out of the adapter's `HostState`, - // so there is no reference cycle back into the router that owns it. + // An adapter store cannot call the client face, so it carries an + // empty registry; this also keeps the real registry out of the + // adapter's `HostState`, so there is no reference cycle back into + // the registry that owns it. let mut store = Self::build_store( engine, components, @@ -747,7 +749,7 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), limits_cfg.state_bytes(), clocks, - PoolRouter::empty(), + VenueRegistry::empty(), )?; let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) .await @@ -782,8 +784,8 @@ impl Supervisor { store.set_fuel(limits_cfg.fuel())?; Ok(LoadedAdapter { - venue_id: adapter_namespace, - actor: AdapterActor::new(store, bindings, limits_cfg.fuel()), + venue_id: VenueId::from(adapter_namespace), + actor: VenueActor::new(store, bindings, limits_cfg.fuel()), alive: init_succeeded, }) } @@ -799,7 +801,7 @@ impl Supervisor { } /// Number of adapters whose `init` succeeded and that are installed in the - /// pool router for routing. + /// venue registry for routing. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { self.adapters_alive @@ -914,10 +916,10 @@ impl Supervisor { let linker = build_linker::(&self.engine, &self.extensions)?; // Borrowed before the `&mut self.modules[idx]` reborrow so the restart - // path applies the same clock override and the same shared pool router + // path applies the same clock override and the same shared registry // as the initial boot. let clocks = self.clocks.clone(); - let pool_router = self.pool_router.clone(); + let venue_registry = self.venue_registry.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key // apart from the dead run's, which stays readable until evicted. @@ -934,7 +936,7 @@ impl Supervisor { module.chain_response_max_bytes, module.local_store_bytes, clocks.as_ref(), - pool_router, + venue_registry, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -1126,7 +1128,7 @@ impl Supervisor { ok } - /// Dispatch a router-observed intent status transition to every module + /// Dispatch a registry-observed intent status transition to every module /// subscribed to `intent-status` events whose venue filter admits the /// update's venue. Returns the number of modules invoked. Mirrors /// `dispatch_block`: dead modules past their backoff are restarted @@ -1187,9 +1189,9 @@ impl Supervisor { }) } - /// The shared intent pool router carried by every module store. - pub fn pool_router(&self) -> PoolRouter { - self.pool_router.clone() + /// The shared venue registry carried by every module store. + pub fn venue_registry(&self) -> VenueRegistry { + self.venue_registry.clone() } /// Shared per-module dispatch path: refuel, call `on_event`, and @@ -1423,10 +1425,10 @@ pub fn build_linker( ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; - // The intent pool import is linked into every module linker; it dispatches - // to the shared router carried in each store's `HostState`. Modules that do - // not import it are unaffected. - crate::bindings::pool::add_to_linker::, HasSelf>>( + // The venue client import is linked into every module linker; it + // dispatches to the shared registry carried in each store's `HostState`. + // Modules that do not import it are unaffected. + crate::bindings::client::add_to_linker::, HasSelf>>( &mut linker, |state| state, )?; diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index ec5da908..c71f43db 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -542,7 +542,7 @@ chain_id = 1 // ── intent-status subscription E2E ──────────────────────────────────── -/// A scripted venue adapter for the router: accepts every submission with +/// A scripted venue adapter for the registry: accepts every submission with /// a fixed receipt and serves statuses front-first from a script, falling /// back to `open` once drained. struct ScriptedAdapter { @@ -557,7 +557,7 @@ impl ScriptedAdapter { } } -impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { +impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { fn derive_header<'a>( &'a mut self, _body: &'a [u8], @@ -567,11 +567,16 @@ impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { > { Box::pin(async move { Ok(crate::bindings::IntentHeader { - gives: Vec::new(), - wants: Vec::new(), - valid_until: None, - settlement: crate::bindings::value_flow::Settlement::EvmChain(1), - authorisation: crate::bindings::AuthScheme::Unsigned, + gives: crate::bindings::value_flow::AssetAmount { + asset: crate::bindings::value_flow::Asset::Native, + amount: vec![1], + }, + wants: crate::bindings::value_flow::AssetAmount { + asset: crate::bindings::value_flow::Asset::Native, + amount: Vec::new(), + }, + settlement: crate::bindings::Settlement { chain: 1 }, + authorisation: crate::bindings::AuthScheme::Eip712, }) }) } @@ -613,12 +618,14 @@ impl crate::host::pool_router::VenueInvoker for ScriptedAdapter { } } -/// Build a router with one scripted adapter installed under `cow`. -fn scripted_router(adapter: ScriptedAdapter) -> crate::host::pool_router::PoolRouter { - let mut builder = crate::host::pool_router::PoolRouterBuilder::new( - crate::host::pool_router::PoolQuota::default(), +/// Build a registry with one scripted adapter installed under `cow`. +fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { + let mut builder = crate::host::venue_registry::VenueRegistryBuilder::new( + crate::host::venue_registry::SubmitQuota::default(), ); - builder.install("cow".to_owned(), adapter).expect("install"); + builder + .install(crate::host::venue_registry::VenueId::from("cow"), adapter) + .expect("install"); builder.build() } @@ -645,7 +652,7 @@ venue = "cow" } /// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the router observed by polling the adapter's status +/// the transitions the registry observed by polling the adapter's status /// export, and a transition from a venue outside its filter is not /// delivered. #[tokio::test] @@ -678,24 +685,28 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { .expect("boot_single"); assert!(supervisor.has_intent_status_subscribers()); - // The router watches the receipt of an accepted submission and polls + // The registry watches the receipt of an accepted submission and polls // the adapter's status export; each poll here observes a transition. - let router = scripted_router(ScriptedAdapter::new([ + let registry = scripted_registry(ScriptedAdapter::new([ IntentStatus::Pending, - IntentStatus::Settled(None), + IntentStatus::Fulfilled, ])); - router - .submit("test-caller", "cow", b"body".to_vec()) + registry + .submit( + "test-caller", + &crate::host::venue_registry::VenueId::from("cow"), + b"body".to_vec(), + ) .await .expect("submit"); let mut delivered = 0; for _ in 0..2 { - for update in router.poll_status_transitions().await { + for update in registry.poll_status_transitions().await { delivered += supervisor.dispatch_intent_status(update).await; } } - assert_eq!(delivered, 2, "pending then settled, one subscriber each"); + assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); // A venue outside the module's filter is not delivered. @@ -747,9 +758,13 @@ async fn e2e_intent_status_flows_through_the_event_loop() { .await .expect("boot_single"); - let router = scripted_router(ScriptedAdapter::new([])); - router - .submit("test-caller", "cow", b"body".to_vec()) + let registry = scripted_registry(ScriptedAdapter::new([])); + registry + .submit( + "test-caller", + &crate::host::venue_registry::VenueId::from("cow"), + b"body".to_vec(), + ) .await .expect("submit"); @@ -757,7 +772,7 @@ async fn e2e_intent_status_flows_through_the_event_loop() { let executor = manager.executor(); let mut tasks = TaskSet::new(); let stream = crate::runtime::event_loop::open_intent_status_stream( - router, + registry, Duration::from_millis(10), &executor, &mut tasks, @@ -789,13 +804,14 @@ async fn e2e_intent_status_flows_through_the_event_loop() { } /// The first-train acceptance path, end to end over two real components: -/// the echo-client module submits through `nexum:intent/pool`, the host -/// router forwards to the installed echo-venue adapter, and the module -/// receives the settled `intent-status` the router polls back. Proves the -/// intent core round-trips module -> host router -> venue adapter with no +/// the echo-client module submits through `videre:venue/client`, the host +/// registry forwards to the installed echo-venue adapter, and the module +/// receives the fulfilled `intent-status` the registry polls back. Proves +/// the intent core round-trips module -> host registry -> venue adapter +/// with no /// scripted stand-ins on either side. #[tokio::test] -async fn e2e_echo_module_router_adapter_round_trip() { +async fn e2e_echo_module_registry_adapter_round_trip() { use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; use crate::host::component::ChainMethod; use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; @@ -843,7 +859,7 @@ async fn e2e_echo_module_router_adapter_round_trip() { assert!(supervisor.has_intent_status_subscribers()); // A block drives the module's on_block, which submits to the echo venue - // through the shared pool router; the router watches the accepted receipt. + // through the shared registry; the registry watches the accepted receipt. let block = nexum::host::types::Block { chain_id: 1, number: 19_000_000, @@ -852,13 +868,13 @@ async fn e2e_echo_module_router_adapter_round_trip() { }; assert_eq!(supervisor.dispatch_block(block).await, 1); - // Poll the router the module submitted through and fan its transitions + // Poll the registry the module submitted through and fan its transitions // back to the module. echo-venue settles instantly, so the first poll // reports a terminal status and the watch is pruned. - let router = supervisor.pool_router(); + let registry = supervisor.venue_registry(); let mut delivered = 0; for _ in 0..2 { - for update in router.poll_status_transitions().await { + for update in registry.poll_status_transitions().await { assert_eq!(update.venue, "echo-venue"); let body = nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); @@ -886,7 +902,7 @@ async fn e2e_echo_module_router_adapter_round_trip() { messages .iter() .any(|m| m.contains("submitted") && m.contains("echo-venue")), - "module submitted through the pool; records were: {messages:?}", + "module submitted through the client face; records were: {messages:?}", ); assert!( messages diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index 58fc8ffc..f711ea96 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -2,7 +2,7 @@ //! it into the component's `venue-adapter` world surface. //! //! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the four intent functions from `nexum:intent/adapter`. +//! world itself, the four intent functions from `videre:venue/adapter`. //! Functions are associated (no `self`): the component model instantiates //! one adapter per venue and calls exports statically, so adapter state //! lives in the adapter's own statics, exactly as in event modules. @@ -62,7 +62,7 @@ macro_rules! export_venue_adapter { } } - impl $crate::bindings::exports::nexum::intent::adapter::Guest + impl $crate::bindings::exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { fn derive_header( diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/nexum-venue-sdk/src/bindings.rs index 3e88fff2..f40e1585 100644 --- a/crates/nexum-venue-sdk/src/bindings.rs +++ b/crates/nexum-venue-sdk/src/bindings.rs @@ -1,4 +1,4 @@ -//! Guest bindings for the `nexum:adapter/venue-adapter` world. +//! Guest bindings for the `videre:venue/venue-adapter` world. //! //! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, //! the venue SDK generates the adapter world's bindings once, here: the @@ -7,17 +7,17 @@ //! types, and [`export_venue_adapter!`](crate::export_venue_adapter) //! emits the component export glue into the adapter's own cdylib via the //! generated (hidden) export macro. Downstream bindgens wanting type -//! identity with this crate remap `nexum:intent/types` and -//! `nexum:value-flow/types` onto these modules with `with`. +//! identity with this crate remap `videre:types/types` and +//! `videre:value-flow/types` onto these modules with `with`. wit_bindgen::generate!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", + "../../wit/videre-value-flow", + "../../wit/videre-types", "../../wit/nexum-host", - "../../wit/nexum-adapter", + "../../wit/videre-venue", ], - world: "nexum:adapter/venue-adapter", + world: "videre:venue/venue-adapter", generate_all, pub_export_macro: true, export_macro_name: "__export_venue_adapter_world", diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/nexum-venue-sdk/src/body.rs index d542aca2..1f202ce2 100644 --- a/crates/nexum-venue-sdk/src/body.rs +++ b/crates/nexum-venue-sdk/src/body.rs @@ -72,16 +72,15 @@ pub enum BodyError { } /// Fold a codec failure into the wire error an adapter returns: decode -/// failures are the caller's malformed body (`invalid-body`, whose WIT -/// contract names exactly these two causes), an encode failure is the -/// adapter's own bug (`internal-error`). +/// failures are the caller's malformed body (`invalid-body`); an encode +/// failure is the adapter's own bug, reported retryable (`unavailable`). impl From for VenueError { fn from(err: BodyError) -> Self { match err { BodyError::Empty | BodyError::UnknownVersion { .. } | BodyError::Malformed { .. } => { VenueError::InvalidBody(err.to_string()) } - BodyError::Encode { .. } => VenueError::InternalError(err.to_string()), + BodyError::Encode { .. } => VenueError::Unavailable(err.to_string()), } } } diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/nexum-venue-sdk/src/client.rs index edfc8dd7..5048eda1 100644 --- a/crates/nexum-venue-sdk/src/client.rs +++ b/crates/nexum-venue-sdk/src/client.rs @@ -1,12 +1,12 @@ //! The typed intent client core: [`IntentClient`] over the byte-level -//! [`IntentPool`] seam. +//! [`VenueClient`] seam. //! -//! The pool boundary carries opaque bodies; this module is where a +//! The client boundary carries opaque bodies; this module is where a //! typed body meets it. [`IntentClient`] binds one venue and encodes -//! through [`IntentBody`] before submission, so strategy code never +//! through [`IntentBody`] before submission, so keeper code never //! handles wire bytes. The seam is byte-level on purpose: the -//! strategy-module SDK implements [`IntentPool`] over its own -//! `nexum:intent/pool` import shims, tests implement it in memory +//! strategy-module SDK implements [`VenueClient`] over its own +//! `videre:venue/client` import shims, tests implement it in memory //! (an in-process adapter works directly), and the typed layer above is //! shared by both. @@ -14,9 +14,9 @@ use strum::IntoStaticStr; use crate::{BodyError, IntentBody, IntentStatus, SubmitOutcome, VenueError}; -/// Byte-level access to the strategy-facing `nexum:intent/pool` +/// Byte-level access to the keeper-facing `videre:venue/client` /// interface, venue named per call as on the wire. -pub trait IntentPool { +pub trait VenueClient { /// Submit an opaque intent body to the named venue. fn submit(&self, venue: &str, body: Vec) -> Result; @@ -30,18 +30,18 @@ pub trait IntentPool { } /// A typed intent client bound to one venue: encodes an [`IntentBody`] -/// to wire bytes and forwards through the [`IntentPool`] seam. +/// to wire bytes and forwards through the [`VenueClient`] seam. #[derive(Clone, Debug)] pub struct IntentClient

{ - pool: P, + venues: P, venue: String, } -impl IntentClient

{ - /// Bind a pool handle to the venue id the router resolves. - pub fn new(pool: P, venue: impl Into) -> Self { +impl IntentClient

{ + /// Bind a client handle to the venue id the registry resolves. + pub fn new(venues: P, venue: impl Into) -> Self { Self { - pool, + venues, venue: venue.into(), } } @@ -54,39 +54,39 @@ impl IntentClient

{ /// Encode a typed body and submit it to the bound venue. pub fn submit(&self, body: &B) -> Result { let bytes = body.to_bytes()?; - self.pool + self.venues .submit(&self.venue, bytes) .map_err(ClientError::Venue) } /// Report where a previously submitted intent is in its life. pub fn status(&self, receipt: &[u8]) -> Result { - self.pool + self.venues .status(&self.venue, receipt) .map_err(ClientError::Venue) } /// Ask the bound venue to withdraw an intent. pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - self.pool + self.venues .cancel(&self.venue, receipt) .map_err(ClientError::Venue) } } /// Why a typed intent call failed: before the wire (the body failed to -/// encode) or beyond it (the pool or venue refused). +/// encode) or beyond it (the registry or venue refused). /// /// `IntoStaticStr` yields a snake_case label per case for log and /// metric fields. #[derive(Clone, Debug, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] pub enum ClientError { - /// The typed body failed to encode; nothing reached the pool. + /// The typed body failed to encode; nothing crossed the wire. #[error(transparent)] Body(#[from] BodyError), - /// The pool or the venue behind it failed the call. The payload is - /// the wire `venue-error`, which carries no `Display`; format via + /// The registry or the venue behind it failed the call. The payload + /// is the wire `venue-error`, which carries no `Display`; format via /// `Debug`. #[error("venue error: {0:?}")] Venue(VenueError), diff --git a/crates/nexum-venue-sdk/src/faults.rs b/crates/nexum-venue-sdk/src/faults.rs index 9a0e3a1e..f8e00ce1 100644 --- a/crates/nexum-venue-sdk/src/faults.rs +++ b/crates/nexum-venue-sdk/src/faults.rs @@ -11,7 +11,7 @@ use nexum_sdk::host; use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; -use crate::{Fault, VenueError}; +use crate::{Fault, RateLimit, VenueError}; /// Lift the wire fault into the SDK-neutral vocabulary the transport /// seams and `nexum-sdk` helpers speak. Exhaustive: the wire enum is @@ -53,37 +53,39 @@ impl From for Fault { } /// Fold a transport fault into the venue error an intent function -/// returns: a policy refusal stays `denied`, retryable transport states -/// (`unavailable`, `rate-limited`, `timeout`) fold to `unavailable`, -/// `unsupported` passes through, and the caller-shaped cases -/// (`invalid-input`, `internal`) become `internal-error` because inside -/// an intent function the transport's caller is the adapter itself. +/// returns: `denied`, `rate-limited`, `timeout`, and `unsupported` map +/// structurally; `unavailable` keeps its detail; the caller-shaped cases +/// (`invalid-input`, `internal`) fold to retryable `unavailable` because +/// inside an intent function the transport's caller is the adapter +/// itself, never the module. impl From for VenueError { fn from(fault: host::Fault) -> Self { match fault { host::Fault::Denied(s) => VenueError::Denied(s), - host::Fault::Unsupported(s) => VenueError::Unsupported(s), - host::Fault::Unavailable(_) | host::Fault::RateLimited(_) | host::Fault::Timeout => { - VenueError::Unavailable(fault.to_string()) - } - other => VenueError::InternalError(other.to_string()), + host::Fault::Unsupported(_) => VenueError::Unsupported, + host::Fault::RateLimited(rl) => VenueError::RateLimited(RateLimit { + retry_after_ms: rl.retry_after_ms, + }), + host::Fault::Timeout => VenueError::Timeout, + host::Fault::Unavailable(s) => VenueError::Unavailable(s), + other => VenueError::Unavailable(other.to_string()), } } } /// Fold a wasi:http fetch failure into the venue error an intent -/// function returns: an allowlist refusal stays `denied`, timeouts and -/// transport failures are retryable `unavailable`, and a request the -/// adapter itself malformed is `internal-error`. +/// function returns: an allowlist refusal stays `denied`, a timeout is +/// `timeout`, and transport failures (including a request the adapter +/// itself malformed) are retryable `unavailable`. impl From for VenueError { fn from(err: nexum_sdk::http::FetchError) -> Self { use nexum_sdk::http::FetchError; match err { FetchError::Denied => VenueError::Denied(err.to_string()), - FetchError::Timeout(_) | FetchError::Transport(_) => { + FetchError::Timeout(_) => VenueError::Timeout, + FetchError::Transport(_) | FetchError::InvalidRequest(_) => { VenueError::Unavailable(err.to_string()) } - FetchError::InvalidRequest(_) => VenueError::InternalError(err.to_string()), } } } @@ -119,13 +121,16 @@ mod tests { VenueError::from(host::Fault::Denied("nope".into())), VenueError::Denied("nope".into()), ); + assert_eq!(VenueError::from(host::Fault::Timeout), VenueError::Timeout); assert!(matches!( - VenueError::from(host::Fault::Timeout), - VenueError::Unavailable(_) + VenueError::from(host::Fault::RateLimited(host::RateLimit { + retry_after_ms: Some(250), + })), + VenueError::RateLimited(rl) if rl.retry_after_ms == Some(250) )); assert!(matches!( VenueError::from(host::Fault::InvalidInput("bug".into())), - VenueError::InternalError(_) + VenueError::Unavailable(_) )); } @@ -142,7 +147,7 @@ mod tests { )); assert!(matches!( VenueError::from(FetchError::InvalidRequest("bad url".into())), - VenueError::InternalError(_) + VenueError::Unavailable(_) )); } } diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs index 45a5dc43..1c0559e3 100644 --- a/crates/nexum-venue-sdk/src/lib.rs +++ b/crates/nexum-venue-sdk/src/lib.rs @@ -19,7 +19,7 @@ //! //! - [`client`] - the typed intent client core: [`IntentClient`] binds a //! venue and encodes through [`IntentBody`] before the byte-level -//! [`IntentPool`] seam. Lives here (not in the strategy SDK) so the +//! [`VenueClient`] seam. Lives here (not in the strategy SDK) so the //! codec and the client that speaks it version together. //! //! - [`transport`] - typed wrappers over the world's scoped imports: @@ -41,7 +41,7 @@ //! //! [`ChainHost`]: nexum_sdk::host::ChainHost //! [`IntentClient`]: client::IntentClient -//! [`IntentPool`]: client::IntentPool +//! [`VenueClient`]: client::VenueClient #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] @@ -57,7 +57,7 @@ pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, IntentPool}; +pub use client::{ClientError, IntentClient, VenueClient}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`nexum_macros::IntentBody`]. pub use nexum_macros::IntentBody; @@ -76,11 +76,12 @@ pub use nexum_macros::venue; /// The intent ontology at its plain spellings: the types the /// [`VenueAdapter`] face and the client core speak. -pub use bindings::nexum::intent::types::{ - AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, +pub use bindings::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, + VenueError, }; /// The value-flow vocabulary intent headers are expressed in. -pub use bindings::nexum::value_flow::types as value_flow; +pub use bindings::videre::value_flow::types as value_flow; /// The wire config table (`nexum:host/types.config`) `init` receives. pub use bindings::nexum::host::types::Config; diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/nexum-venue-sdk/tests/adapter.rs index c6afd9ca..699915f0 100644 --- a/crates/nexum-venue-sdk/tests/adapter.rs +++ b/crates/nexum-venue-sdk/tests/adapter.rs @@ -3,13 +3,13 @@ //! `export_venue_adapter!`, and round-trips a versioned body through //! `#[derive(IntentBody)]` - including the typed unknown-version //! failure and the typed client core driving the adapter through the -//! [`IntentPool`] seam. +//! [`VenueClient`] seam. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, - IntentPool, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, + IntentStatus, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, }; /// First published body version: a fixed-price quote. @@ -60,15 +60,17 @@ impl VenueAdapter for DemoAdapter { } fn derive_header(body: Vec) -> Result { - let (amount_wei, valid_until) = Self::decode(&body)?; + let (amount_wei, _valid_until_ms) = Self::decode(&body)?; Ok(IntentHeader { - gives: vec![AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(1)), + gives: AssetAmount { + asset: Asset::Native, amount: amount_wei.to_be_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until, - settlement: Settlement::EvmChain(1), + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip712, }) } @@ -82,7 +84,11 @@ impl VenueAdapter for DemoAdapter { if receipt == RECEIPT { Ok(IntentStatus::Open) } else { - Err(VenueError::InvalidReceipt) + // A receipt this venue never issued can never succeed, so the + // refusal is the non-retryable case. + Err(VenueError::Denied( + "receipt not issued by this venue".into(), + )) } } @@ -95,11 +101,11 @@ impl VenueAdapter for DemoAdapter { // venue-adapter world. nexum_venue_sdk::export_venue_adapter!(DemoAdapter); -/// In-process pool: routes the demo venue id straight into the adapter, -/// standing in for the host router the strategy-side seam will bind. -struct InProcessPool; +/// In-process client: routes the demo venue id straight into the adapter, +/// standing in for the host registry the keeper-side seam will bind. +struct InProcessClient; -impl IntentPool for InProcessPool { +impl VenueClient for InProcessClient { fn submit(&self, venue: &str, body: Vec) -> Result { if venue != "demo" { return Err(VenueError::UnknownVenue); @@ -192,9 +198,9 @@ fn empty_and_malformed_bodies_fail_typedly() { fn adapter_projects_the_header_from_a_versioned_body() { let bytes = v2_body().to_bytes().unwrap(); let header = DemoAdapter::derive_header(bytes).unwrap(); - assert_eq!(header.gives.len(), 1); - assert_eq!(header.gives[0].amount, 1_000_000u64.to_be_bytes().to_vec()); - assert_eq!(header.valid_until, Some(1_700_000_000_000)); + assert_eq!(header.gives.asset, Asset::Native); + assert_eq!(header.gives.amount, 1_000_000u64.to_be_bytes().to_vec()); + assert_eq!(header.settlement, Settlement { chain: 1 }); assert_eq!(header.authorisation, AuthScheme::Eip712); } @@ -210,8 +216,8 @@ fn adapter_reports_an_unknown_version_as_invalid_body() { } #[test] -fn typed_client_round_trips_through_the_pool_seam() { - let client = IntentClient::new(InProcessPool, "demo"); +fn typed_client_round_trips_through_the_client_seam() { + let client = IntentClient::new(InProcessClient, "demo"); let outcome = client.submit(&v2_body()).unwrap(); let SubmitOutcome::Accepted(receipt) = outcome else { @@ -224,13 +230,13 @@ fn typed_client_round_trips_through_the_pool_seam() { assert!(matches!( client.status(&[0, 1]).unwrap_err(), - ClientError::Venue(VenueError::InvalidReceipt) + ClientError::Venue(VenueError::Denied(_)) )); } #[test] -fn unbound_venue_is_unknown_at_the_pool() { - let client = IntentClient::new(InProcessPool, "nowhere"); +fn unbound_venue_is_unknown_at_the_client() { + let client = IntentClient::new(InProcessClient, "nowhere"); assert!(matches!( client.submit(&v2_body()).unwrap_err(), ClientError::Venue(VenueError::UnknownVenue) diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/nexum-venue-test/goldens/reference-header.json index 980751be..ded49d81 100644 --- a/crates/nexum-venue-test/goldens/reference-header.json +++ b/crates/nexum-venue-test/goldens/reference-header.json @@ -5,19 +5,16 @@ "name": "v1-small", "body": "00010000000000000002000000676d", "header": { - "gives": [ - { - "asset": { - "native-token": { - "evm-chain": 1 - } - }, - "amount": "01" - } - ], - "wants": [], + "gives": { + "asset": "native", + "amount": "01" + }, + "wants": { + "asset": "native", + "amount": "" + }, "settlement": { - "evm-chain": 1 + "chain": 1 }, "authorisation": "eip712" }, @@ -27,34 +24,24 @@ "name": "v2-full", "body": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", "header": { - "gives": [ - { - "asset": { - "native-token": { - "evm-chain": 1 - } - }, - "amount": "0f4240" - } - ], - "wants": [ - { - "asset": { - "erc20": { - "chain-id": 1, - "address": "0102030405060708090a0b0c0d0e0f1011121314" - } - }, - "amount": "0f4240" - } - ], - "valid-until": 1700000000000, + "gives": { + "asset": "native", + "amount": "0f4240" + }, + "wants": { + "asset": { + "erc20": { + "token": "0102030405060708090a0b0c0d0e0f1011121314" + } + }, + "amount": "0f4240" + }, "settlement": { - "evm-chain": 1 + "chain": 1 }, "authorisation": "eip712" }, - "notes": "v2 adds the expiry and an erc20 want at the recipient address" + "notes": "v2 adds an erc20 want at the recipient token address" } ] } diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index bee21583..f2908622 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -14,8 +14,8 @@ use std::fmt; use std::path::Path; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; -use nexum_venue_sdk::{AuthScheme, IntentHeader}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; +use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement}; use serde::{Deserialize, Serialize}; use crate::fixture::{self, FixtureError, hex_bytes}; @@ -53,12 +53,9 @@ pub struct HeaderGolden { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct GoldenHeader { /// Value leaving the user's control. - pub gives: Vec, - /// Value expected in return. - pub wants: Vec, - /// Expiry in milliseconds since the Unix epoch, UTC. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub valid_until: Option, + pub gives: GoldenAssetAmount, + /// Value expected in return. Display-grade, not host-verified. + pub wants: GoldenAssetAmount, /// Where the deal settles. pub settlement: GoldenSettlement, /// How the venue authorises the intent. @@ -66,29 +63,26 @@ pub struct GoldenHeader { } /// Serde mirror of the wire `asset-amount`. `amount` is big-endian -/// unsigned, hex in the file; an empty string is zero. +/// unsigned, minimal-length, hex in the file; an empty string is zero. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GoldenAssetAmount { /// The asset moving. pub asset: GoldenAsset, - /// Big-endian unsigned amount bytes. + /// Big-endian minimal-length unsigned amount bytes. #[serde(with = "hex_bytes")] pub amount: Vec, } /// Serde mirror of the wire `settlement`. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub enum GoldenSettlement { - /// Settles on an EVM chain, by chain id. - EvmChain(u64), - /// Settles off-chain in the named domain. - Offchain(String), +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GoldenSettlement { + /// EVM chain id the deal settles on. + pub chain: u64, } -/// Serde mirror of the wire `asset`. Token addresses and ids are hex -/// in the file. +/// Serde mirror of the wire `asset`. Token addresses are hex in the file. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde( rename_all = "kebab-case", @@ -96,51 +90,13 @@ pub enum GoldenSettlement { deny_unknown_fields )] pub enum GoldenAsset { - /// The settlement domain's own gas token. - NativeToken(GoldenSettlement), - /// An ERC-20 token. + /// The settlement chain's gas token. + Native, + /// An ERC-20 token on the settlement chain. Erc20 { - /// Chain the token lives on. - chain_id: u64, - /// 20-byte contract address. - #[serde(with = "hex_bytes")] - address: Vec, - }, - /// An ERC-721 NFT. - Erc721 { - /// Chain the token lives on. - chain_id: u64, /// 20-byte contract address. #[serde(with = "hex_bytes")] - address: Vec, - /// Token id, big-endian, arbitrary width. - #[serde(with = "hex_bytes")] - token_id: Vec, - }, - /// An ERC-1155 token. - Erc1155 { - /// Chain the token lives on. - chain_id: u64, - /// 20-byte contract address. - #[serde(with = "hex_bytes")] - address: Vec, - /// Token id, big-endian, arbitrary width. - #[serde(with = "hex_bytes")] - token_id: Vec, - }, - /// A non-token service obligation. - Service { - /// Namespaced service kind, e.g. `swarm:postage`. - kind: String, - /// Human-readable description for the consent sheet. - summary: String, - }, - /// A real-world asset settled off-chain. - Offchain { - /// Jurisdiction or registry domain. - domain: String, - /// Human-readable description for the consent sheet. - summary: String, + token: Vec, }, } @@ -148,24 +104,17 @@ pub enum GoldenAsset { #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum GoldenAuthScheme { - /// EIP-712 typed-data signature by host-held keys. - Eip712, /// EIP-1271 contract signature. Eip1271, - /// Pre-signed authorisation at the settlement contract. - Presign, - /// Venue-defined off-chain signature scheme. - OffchainSig, - /// No authorisation travels with the body. - Unsigned, + /// EIP-712 typed-data signature by host-held keys. + Eip712, } impl From for GoldenHeader { fn from(header: IntentHeader) -> Self { Self { - gives: header.gives.into_iter().map(Into::into).collect(), - wants: header.wants.into_iter().map(Into::into).collect(), - valid_until: header.valid_until, + gives: header.gives.into(), + wants: header.wants.into(), settlement: header.settlement.into(), authorisation: header.authorisation.into(), } @@ -183,9 +132,8 @@ impl From for GoldenAssetAmount { impl From for GoldenSettlement { fn from(settlement: Settlement) -> Self { - match settlement { - Settlement::EvmChain(chain_id) => GoldenSettlement::EvmChain(chain_id), - Settlement::Offchain(domain) => GoldenSettlement::Offchain(domain), + Self { + chain: settlement.chain, } } } @@ -193,26 +141,8 @@ impl From for GoldenSettlement { impl From for GoldenAsset { fn from(asset: Asset) -> Self { match asset { - Asset::NativeToken(settlement) => GoldenAsset::NativeToken(settlement.into()), - Asset::Erc20((chain_id, address)) => GoldenAsset::Erc20 { chain_id, address }, - Asset::Erc721((chain_id, address, token_id)) => GoldenAsset::Erc721 { - chain_id, - address, - token_id, - }, - Asset::Erc1155((chain_id, address, token_id)) => GoldenAsset::Erc1155 { - chain_id, - address, - token_id, - }, - Asset::Service(desc) => GoldenAsset::Service { - kind: desc.kind, - summary: desc.summary, - }, - Asset::Offchain(desc) => GoldenAsset::Offchain { - domain: desc.domain, - summary: desc.summary, - }, + Asset::Native => GoldenAsset::Native, + Asset::Erc20(erc20) => GoldenAsset::Erc20 { token: erc20.token }, } } } @@ -220,11 +150,8 @@ impl From for GoldenAsset { impl From for GoldenAuthScheme { fn from(scheme: AuthScheme) -> Self { match scheme { - AuthScheme::Eip712 => GoldenAuthScheme::Eip712, AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, - AuthScheme::Presign => GoldenAuthScheme::Presign, - AuthScheme::OffchainSig => GoldenAuthScheme::OffchainSig, - AuthScheme::Unsigned => GoldenAuthScheme::Unsigned, + AuthScheme::Eip712 => GoldenAuthScheme::Eip712, } } } @@ -336,47 +263,24 @@ impl HeaderGoldens { #[cfg(test)] mod tests { use nexum_venue_sdk::VenueError; - use nexum_venue_sdk::value_flow::{OffchainDesc, ServiceDesc}; + use nexum_venue_sdk::value_flow::Erc20; use super::*; fn wire_header() -> IntentHeader { IntentHeader { - gives: vec![ - AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(100)), - amount: vec![0x0d, 0xe0, 0xb6], - }, - AssetAmount { - asset: Asset::Erc20((1, vec![0xAA; 20])), - amount: vec![1, 0], - }, - AssetAmount { - asset: Asset::Erc721((1, vec![0xBB; 20], vec![7])), - amount: vec![1], - }, - AssetAmount { - asset: Asset::Erc1155((1, vec![0xCC; 20], vec![8])), - amount: vec![2], - }, - AssetAmount { - asset: Asset::Service(ServiceDesc { - kind: "swarm:postage".to_owned(), - summary: "storage for 30 days".to_owned(), - }), - amount: Vec::new(), - }, - ], - wants: vec![AssetAmount { - asset: Asset::Offchain(OffchainDesc { - domain: "iso:AU".to_owned(), - summary: "a deed".to_owned(), + gives: AssetAmount { + asset: Asset::Native, + amount: vec![0x0d, 0xe0, 0xb6], + }, + wants: AssetAmount { + asset: Asset::Erc20(Erc20 { + token: vec![0xAA; 20], }), - amount: Vec::new(), - }], - valid_until: Some(1_700_000_000_000), - settlement: Settlement::Offchain("acme".to_owned()), - authorisation: AuthScheme::OffchainSig, + amount: vec![1, 0], + }, + settlement: Settlement { chain: 100 }, + authorisation: AuthScheme::Eip1271, } } @@ -395,12 +299,11 @@ mod tests { let json = goldens.to_json(); assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); // The wire spellings are the contract for non-Rust readers. - assert!(json.contains("\"native-token\"")); - assert!(json.contains("\"chain-id\"")); - assert!(json.contains("\"token-id\"")); - assert!(json.contains("\"valid-until\"")); - assert!(json.contains("\"offchain-sig\"")); - assert!(json.contains("\"evm-chain\"")); + assert!(json.contains("\"native\"")); + assert!(json.contains("\"erc20\"")); + assert!(json.contains("\"token\"")); + assert!(json.contains("\"chain\"")); + assert!(json.contains("\"eip1271\"")); } #[test] @@ -432,7 +335,7 @@ mod tests { calls += 1; if calls == 1 { let mut header = wire_header(); - header.valid_until = None; + header.authorisation = AuthScheme::Eip712; Ok(header) } else { Err(VenueError::InvalidBody("nope".to_owned())) @@ -454,6 +357,6 @@ mod tests { goldens .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) .unwrap(); - goldens.assert_conforms(|_| Err::(VenueError::InvalidReceipt)); + goldens.assert_conforms(|_| Err::(VenueError::Timeout)); } } diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/nexum-venue-test/src/reference.rs index 74ce00b2..be6eef02 100644 --- a/crates/nexum-venue-test/src/reference.rs +++ b/crates/nexum-venue-test/src/reference.rs @@ -11,8 +11,8 @@ //! must reproduce them byte for byte. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; -use nexum_venue_sdk::{AuthScheme, IntentBody, IntentHeader, VenueError}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use nexum_venue_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; /// The published codec vector file, verbatim. pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); @@ -59,29 +59,36 @@ pub enum ReferenceBody { } /// The reference venue's pure header derivation, the subject the -/// published goldens pin. Gives the amount as chain-1 native token, -/// wants (for v2) the same amount as an ERC-20 at the recipient -/// address, and authorises via EIP-712. +/// published goldens pin. Gives the amount as the chain's native token, +/// wants (for v2) the same amount as an ERC-20 at the recipient token +/// address, and authorises via EIP-712. V1 wants nothing, spelled as a +/// zero native amount. pub fn derive_reference_header(body: Vec) -> Result { - let (amount_wei, valid_until, wants) = match ReferenceBody::from_bytes(&body)? { - ReferenceBody::V1(quote) => (quote.amount_wei, None, Vec::new()), + let (amount_wei, wants) = match ReferenceBody::from_bytes(&body)? { + ReferenceBody::V1(quote) => ( + quote.amount_wei, + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + ), ReferenceBody::V2(quote) => ( quote.amount_wei, - quote.valid_until_ms, - vec![AssetAmount { - asset: Asset::Erc20((1, quote.recipient)), + AssetAmount { + asset: Asset::Erc20(Erc20 { + token: quote.recipient, + }), amount: minimal_be(quote.amount_wei), - }], + }, ), }; Ok(IntentHeader { - gives: vec![AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(1)), + gives: AssetAmount { + asset: Asset::Native, amount: minimal_be(amount_wei), - }], + }, wants, - valid_until, - settlement: Settlement::EvmChain(1), + settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip712, }) } @@ -227,8 +234,7 @@ mod tests { derive_reference_header, ) .unwrap() - .notes = - Some("v2 adds the expiry and an erc20 want at the recipient address".to_owned()); + .notes = Some("v2 adds an erc20 want at the recipient token address".to_owned()); goldens } diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs index 290c2ef4..d6c37040 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -3,7 +3,7 @@ //! golden files, and a deliberately divergent adapter is caught by //! them. -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ AuthScheme, Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, }; @@ -58,9 +58,7 @@ fn divergent_derivation_is_caught_by_the_published_goldens() { // The classic byte-order bug: little-endian amounts. let derive = |body: Vec| -> Result { let mut header = derive_reference_header(body)?; - for give in &mut header.gives { - give.amount.reverse(); - } + header.gives.amount.reverse(); Ok(header) }; let report = HeaderGoldens::from_json(HEADER_GOLDENS_JSON) @@ -124,10 +122,7 @@ fn published_files_document_the_wire_format_in_hex() { ); // And the expected header speaks the value-flow vocabulary. let derived = derive_reference_header(golden.body.clone()).unwrap(); - assert_eq!( - derived.gives[0].asset, - Asset::NativeToken(Settlement::EvmChain(1)), - ); + assert_eq!(derived.gives.asset, Asset::Native); assert_eq!(derived.authorisation, AuthScheme::Eip712); - let _: &AssetAmount = &derived.gives[0]; + let _: &AssetAmount = &derived.gives; } diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs index 018adaba..c98acb31 100644 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ b/crates/shepherd-cow-host/src/ext_cow.rs @@ -26,8 +26,6 @@ use crate::cow_orderbook::{CowApiError, OrderBookPool}; mod bindings { wasmtime::component::bindgen!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", "../../wit/nexum-host", "../../wit/shepherd-cow", ], diff --git a/docs/adr/0013-composable-cow-structured-poll.md b/docs/adr/0013-composable-cow-structured-poll.md index 70222401..63764ab3 100644 --- a/docs/adr/0013-composable-cow-structured-poll.md +++ b/docs/adr/0013-composable-cow-structured-poll.md @@ -16,7 +16,7 @@ The blocker is deployment. The fork is `abiVersion 2.0.0-dev`, `deployments/netw ## Decision -The CoW module is a generic, handler-agnostic ComposableCoW monitor (a `ccow-monitor`), not a TWAP-specific strategy. It indexes `ConditionalOrderCreated`, polls each authorised order, and switches on `GeneratorResult.code` and nothing else: `POST` (with a signature emitted) submits the order through `nexum:intent/pool`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule at `waitUntil`; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` hands to the offchainInput layer or parks. It decodes no revert selectors, computes no schedule, and holds no per-handler branch. TWAP survives as a watch scope and golden vectors, not as code. +The CoW module is a generic, handler-agnostic ComposableCoW monitor (a `ccow-monitor`), not a TWAP-specific strategy. It indexes `ConditionalOrderCreated`, polls each authorised order, and switches on `GeneratorResult.code` and nothing else: `POST` (with a signature emitted) submits the order through `videre:venue/client`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule at `waitUntil`; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` hands to the offchainInput layer or parks. It decodes no revert selectors, computes no schedule, and holds no per-handler branch. TWAP survives as a watch scope and golden vectors, not as code. The chassis (ADR-0009) supplies the mechanism: the watch-set and journal stores are unchanged; the two-gate store (`next_block:` / `next_epoch:`) now holds the contract-supplied `waitUntil` / `nextPollTimestamp` slots, so its value source moves from off-chain revert-decode to the contract verdict while its shape is unchanged. The `ConditionalSource` seam returns a structured `Verdict` mirroring `GeneratorResultCode` plus the hints; it does not decode or schedule. diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 0c04791b..21fd3d1c 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -38,8 +38,6 @@ use wit_bindgen as _; #[cfg(target_arch = "wasm32")] wit_bindgen::generate!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", "../../wit/nexum-host", "../../wit/shepherd-cow", ], diff --git a/modules/examples/echo-client/Cargo.toml b/modules/examples/echo-client/Cargo.toml index 95e02533..f4cf47a0 100644 --- a/modules/examples/echo-client/Cargo.toml +++ b/modules/examples/echo-client/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Shepherd example module paired with the echo-venue adapter: submits an opaque body through nexum:intent/pool on every block and logs the intent-status transitions the router fans back." +description = "Shepherd example module paired with the echo-venue adapter: submits an opaque body through videre:venue/client on every block and logs the intent-status transitions the registry fans back." [lints] workspace = true diff --git a/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml index f10708d1..4b1df513 100644 --- a/modules/examples/echo-client/module.toml +++ b/modules/examples/echo-client/module.toml @@ -1,7 +1,8 @@ -# echo-client module manifest - the strategy half of the echo pair. It -# submits through nexum:intent/pool and observes intent-status, so it -# declares the `pool` capability alongside `logging`; the per-module world -# the macro derives imports exactly nexum:intent/pool and nexum:host/logging. +# echo-client module manifest - the keeper half of the echo pair. It +# submits through videre:venue/client and observes intent-status, so it +# declares the `client` capability alongside `logging`; the per-module world +# the macro derives imports exactly videre:venue/client and +# nexum:host/logging. [module] name = "echo-client" @@ -10,8 +11,8 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -# `pool` grants the nexum:intent/pool import; `logging` the log sink. -required = ["pool", "logging"] +# `client` grants the videre:venue/client import; `logging` the log sink. +required = ["client", "logging"] optional = [] [capabilities.http] @@ -22,7 +23,7 @@ allow = [] kind = "block" chain_id = 1 -# Observe the status transitions the router polls from the echo-venue adapter. +# Observe the status transitions the registry polls from the echo-venue adapter. [[subscription]] kind = "intent-status" venue = "echo-venue" diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 62407488..6a769517 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -1,15 +1,15 @@ //! # echo-client (reference Shepherd intent module) //! -//! The strategy half of the echo pair. On every chain-1 block it submits an -//! opaque body through `nexum:intent/pool` to the `echo-venue` adapter and -//! logs the receipt, and it logs each `intent-status` transition the router -//! fans back from that venue. Paired with the echo-venue adapter it is the -//! smallest end-to-end demonstration of the intent core: module -> host -//! router -> venue adapter, and the status event back. +//! The keeper half of the echo pair. On every chain-1 block it submits an +//! opaque body through `videre:venue/client` to the `echo-venue` adapter and +//! logs the receipt, and it logs each `intent-status` transition the +//! registry fans back from that venue. Paired with the echo-venue adapter it +//! is the smallest end-to-end demonstration of the intent core: module -> +//! host registry -> venue adapter, and the status event back. //! -//! It declares two capabilities (`pool`, `logging`), so the built component -//! imports `nexum:intent/pool` and `nexum:host/logging` and nothing else: -//! the per-module world matches the manifest by construction. +//! It declares two capabilities (`client`, `logging`), so the built +//! component imports `videre:venue/client` and `nexum:host/logging` and +//! nothing else: the per-module world matches the manifest by construction. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. @@ -17,8 +17,8 @@ #![allow(clippy::too_many_arguments)] use nexum::host::{logging, types}; -use nexum::intent::pool; -use nexum::intent::types::SubmitOutcome; +use videre::types::types::SubmitOutcome; +use videre::venue::client; /// Venue id the paired echo-venue adapter answers for; the module submits /// to and observes exactly this venue. @@ -33,7 +33,7 @@ impl EchoClient { // receipt, so the body content is immaterial; the block number keeps // it non-empty and legible in the logs. let body = block.number.to_be_bytes().to_vec(); - match pool::submit(ECHO_VENUE, &body) { + match client::submit(ECHO_VENUE, &body) { Ok(SubmitOutcome::Accepted(receipt)) => logging::log( logging::Level::Info, &format!( diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index ef80f731..d99726ad 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -2,7 +2,7 @@ //! //! The minimal reference venue adapter: it accepts any body, echoes it back //! as the receipt, and settles instantly (every receipt it issued reports -//! `settled`). It carries no real venue protocol, so it doubles as the +//! `fulfilled`). It carries no real venue protocol, so it doubles as the //! smallest end-to-end demonstration of `#[nexum_venue_sdk::venue]` - the //! attribute supplies the per-cdylib wit-bindgen call for a world derived //! from `module.toml`, the `Guest` export glue, and `export!`, leaving only @@ -19,8 +19,10 @@ #![allow(clippy::too_many_arguments)] use nexum::host::chain; -use nexum::intent::types::{IntentHeader, IntentStatus, SubmitOutcome, VenueError}; -use nexum::value_flow::types::{Asset, AssetAmount, Settlement}; +use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Settlement, SubmitOutcome, VenueError, +}; +use videre::value_flow::types::{Asset, AssetAmount}; struct EchoVenue; @@ -33,16 +35,19 @@ impl EchoVenue { fn derive_header(body: Vec) -> Result { // The echo venue gives back exactly the bytes handed to it, so the // header's `gives` amount is the body length: enough to exercise - // the value-flow vocabulary without a real schema. + // the value-flow vocabulary without a real schema. Wants nothing, + // spelled as a zero native amount. Ok(IntentHeader { - gives: vec![AssetAmount { - asset: Asset::NativeToken(Settlement::EvmChain(1)), - amount: (body.len() as u64).to_be_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until: None, - settlement: Settlement::EvmChain(1), - authorisation: nexum::intent::types::AuthScheme::Unsigned, + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(body.len() as u64), + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, }) } @@ -55,25 +60,25 @@ impl EchoVenue { Ok(SubmitOutcome::Accepted(body)) } - fn status(receipt: Vec) -> Result { - if receipt.is_empty() { - Err(VenueError::InvalidReceipt) - } else { - // Settles instantly: the intent reaches a terminal state on the - // first status poll, with no venue-side settlement proof. - Ok(IntentStatus::Settled(None)) - } + fn status(_receipt: Vec) -> Result { + // Settles instantly: the intent reaches a terminal state on the + // first status poll. + Ok(IntentStatus::Fulfilled) } - fn cancel(receipt: Vec) -> Result<(), VenueError> { - if receipt.is_empty() { - Err(VenueError::InvalidReceipt) - } else { - Ok(()) - } + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) } } +/// Big-endian bytes with leading zeros trimmed: the minimal `uint` +/// spelling, where an empty list is zero. +fn minimal_be(value: u64) -> Vec { + let bytes = value.to_be_bytes(); + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + /// echo-venue as the `nexum-venue-test` conformance target: the adapter's /// pure header derivation is held to a hand-written golden through the kit's /// serde mirror types. The macro mints echo-venue's own bindgen @@ -83,43 +88,15 @@ impl EchoVenue { #[cfg(test)] mod conformance { use super::*; - use nexum::intent::types::AuthScheme; use nexum_venue_test::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, }; - fn settlement_to_golden(settlement: Settlement) -> GoldenSettlement { - match settlement { - Settlement::EvmChain(chain_id) => GoldenSettlement::EvmChain(chain_id), - Settlement::Offchain(domain) => GoldenSettlement::Offchain(domain), - } - } - fn asset_to_golden(asset: Asset) -> GoldenAsset { match asset { - Asset::NativeToken(settlement) => { - GoldenAsset::NativeToken(settlement_to_golden(settlement)) - } - Asset::Erc20((chain_id, address)) => GoldenAsset::Erc20 { chain_id, address }, - Asset::Erc721((chain_id, address, token_id)) => GoldenAsset::Erc721 { - chain_id, - address, - token_id, - }, - Asset::Erc1155((chain_id, address, token_id)) => GoldenAsset::Erc1155 { - chain_id, - address, - token_id, - }, - Asset::Service(desc) => GoldenAsset::Service { - kind: desc.kind, - summary: desc.summary, - }, - Asset::Offchain(desc) => GoldenAsset::Offchain { - domain: desc.domain, - summary: desc.summary, - }, + Asset::Native => GoldenAsset::Native, + Asset::Erc20(erc20) => GoldenAsset::Erc20 { token: erc20.token }, } } @@ -132,20 +109,18 @@ mod conformance { fn auth_to_golden(scheme: AuthScheme) -> GoldenAuthScheme { match scheme { - AuthScheme::Eip712 => GoldenAuthScheme::Eip712, AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, - AuthScheme::Presign => GoldenAuthScheme::Presign, - AuthScheme::OffchainSig => GoldenAuthScheme::OffchainSig, - AuthScheme::Unsigned => GoldenAuthScheme::Unsigned, + AuthScheme::Eip712 => GoldenAuthScheme::Eip712, } } fn header_to_golden(header: IntentHeader) -> GoldenHeader { GoldenHeader { - gives: header.gives.into_iter().map(amount_to_golden).collect(), - wants: header.wants.into_iter().map(amount_to_golden).collect(), - valid_until: header.valid_until, - settlement: settlement_to_golden(header.settlement), + gives: amount_to_golden(header.gives), + wants: amount_to_golden(header.wants), + settlement: GoldenSettlement { + chain: header.settlement.chain, + }, authorisation: auth_to_golden(header.authorisation), } } @@ -155,25 +130,32 @@ mod conformance { EchoVenue::derive_header(body).map(header_to_golden) } + fn zero_native() -> GoldenAssetAmount { + GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: Vec::new(), + } + } + #[test] fn derive_header_conforms_to_the_published_golden() { // The echo contract: gives chain-1 native token whose amount is the - // body length as eight big-endian bytes, wants nothing, and carries - // no authorisation. A conforming adapter reproduces this exactly. + // body length in minimal big-endian bytes, wants zero native, and + // authorises via EIP-1271. A conforming adapter reproduces this + // exactly. let golden = HeaderGolden { name: "four-byte-body".to_owned(), body: vec![1, 2, 3, 4], header: GoldenHeader { - gives: vec![GoldenAssetAmount { - asset: GoldenAsset::NativeToken(GoldenSettlement::EvmChain(1)), - amount: 4u64.to_be_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until: None, - settlement: GoldenSettlement::EvmChain(1), - authorisation: GoldenAuthScheme::Unsigned, + gives: GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: vec![4], + }, + wants: zero_native(), + settlement: GoldenSettlement { chain: 1 }, + authorisation: GoldenAuthScheme::Eip1271, }, - notes: Some("amount is the 8-byte big-endian body length".to_owned()), + notes: Some("amount is the minimal big-endian body length".to_owned()), }; let goldens = HeaderGoldens { venue: "echo-venue".to_owned(), @@ -184,22 +166,21 @@ mod conformance { #[test] fn divergent_derivation_is_caught_by_the_golden() { - // A little-endian amount is the classic byte-order bug; the golden - // must reject it, proving the check has teeth on echo-venue. + // A non-minimal amount is the classic uint bug; the golden must + // reject it, proving the check has teeth on echo-venue. let goldens = HeaderGoldens { venue: "echo-venue".to_owned(), goldens: vec![HeaderGolden { name: "four-byte-body".to_owned(), body: vec![1, 2, 3, 4], header: GoldenHeader { - gives: vec![GoldenAssetAmount { - asset: GoldenAsset::NativeToken(GoldenSettlement::EvmChain(1)), - amount: 4u64.to_le_bytes().to_vec(), - }], - wants: Vec::new(), - valid_until: None, - settlement: GoldenSettlement::EvmChain(1), - authorisation: GoldenAuthScheme::Unsigned, + gives: GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: 4u64.to_be_bytes().to_vec(), + }, + wants: zero_native(), + settlement: GoldenSettlement { chain: 1 }, + authorisation: GoldenAuthScheme::Eip1271, }, notes: None, }], diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index be6ef1b7..ff846bdf 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -24,8 +24,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", "../../../wit/shepherd-cow", ], diff --git a/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs index b072abd0..2f0cea8b 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -17,8 +17,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/flaky-bomb/src/lib.rs b/modules/fixtures/flaky-bomb/src/lib.rs index cd5c7d41..d1b9598a 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/modules/fixtures/flaky-bomb/src/lib.rs @@ -22,8 +22,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/fuel-bomb/src/lib.rs b/modules/fixtures/fuel-bomb/src/lib.rs index 14b55f13..3e6b6b3b 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/modules/fixtures/fuel-bomb/src/lib.rs @@ -14,8 +14,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/memory-bomb/src/lib.rs b/modules/fixtures/memory-bomb/src/lib.rs index cf1dd031..948b65e1 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/modules/fixtures/memory-bomb/src/lib.rs @@ -13,8 +13,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/modules/fixtures/panic-bomb/src/lib.rs index 09fcb89c..56eaa348 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/modules/fixtures/panic-bomb/src/lib.rs @@ -14,8 +14,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/fixtures/slow-host/src/lib.rs b/modules/fixtures/slow-host/src/lib.rs index 5e490752..97b420be 100644 --- a/modules/fixtures/slow-host/src/lib.rs +++ b/modules/fixtures/slow-host/src/lib.rs @@ -27,8 +27,6 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-value-flow", - "../../../wit/nexum-intent", "../../../wit/nexum-host", ], world: "nexum:host/event-module", diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index c23eb237..0483cb52 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -25,8 +25,6 @@ wit_bindgen::generate!({ path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", "../../wit/nexum-host", "../../wit/shepherd-cow", ], diff --git a/wit/nexum-adapter/venue-adapter.wit b/wit/nexum-adapter/venue-adapter.wit deleted file mode 100644 index 9656bc47..00000000 --- a/wit/nexum-adapter/venue-adapter.wit +++ /dev/null @@ -1,30 +0,0 @@ -package nexum:adapter@0.1.0; - -/// A venue adapter: the second component kind. Where an event-module -/// automates strategy over the six core primitives, a venue adapter -/// speaks one venue's protocol and nothing else. It imports only the -/// scoped transport it needs to reach its venue - chain RPC and -/// messaging - and exports the intent adapter face. It has no -/// local-store, remote-store, identity, or logging import: an adapter -/// structurally cannot touch host key material or persistent state, only -/// move bytes to and from its venue. The host links exactly these -/// imports into an adapter store, so an adapter that reaches for anything -/// else fails to instantiate. -world venue-adapter { - use nexum:host/types@0.2.0.{config, fault}; - - // Scoped transport. Outbound HTTP is wasi:http, linked separately and - // gated per-adapter by the `[[adapters]].http_allow` allowlist in - // engine.toml, the same way event-module treats outbound HTTP; the - // per-adapter `[[adapters]].messaging_topics` scopes the messaging - // content topics an adapter may reach. Time and randomness are - // ambient wasi:clocks / wasi:random. - import nexum:host/chain@0.2.0; - import nexum:host/messaging@0.2.0; - - /// Configure the adapter from its `[config]` before any submission. - /// Mirrors the event-module `init` so the supervisor boots both kinds - /// through the same store, fuel, and restart machinery. - export init: func(config: config) -> result<_, fault>; - export nexum:intent/adapter@0.1.0; -} diff --git a/wit/nexum-intent/adapter.wit b/wit/nexum-intent/adapter.wit deleted file mode 100644 index 1c0a28ce..00000000 --- a/wit/nexum-intent/adapter.wit +++ /dev/null @@ -1,31 +0,0 @@ -package nexum:intent@0.1.0; - -/// The venue face of the intent core, the mirror of the strategy-facing -/// `pool` interface. One installed adapter answers for exactly one venue, -/// so none of these functions carries a `venue` argument: the router -/// resolves a venue id to its adapter and calls the adapter directly. -/// Bodies stay opaque bytes at this boundary; the adapter recovers typing -/// against its own venue schema. The package depends only on its own -/// types, never on `nexum:host`, so the adapter contract's freeze cadence -/// stays independent of host versioning. -interface adapter { - use types.{intent-header, intent-status, receipt, submit-outcome, venue-error}; - - /// Project an opaque intent body onto the stable header guard policy - /// runs on. A pure derivation: no transport, no side effects, so the - /// host can derive and inspect a header before deciding to submit. - derive-header: func(body: list) -> result; - - /// Submit an opaque intent body to this adapter's venue. Success is - /// either the venue's receipt or requires-signing: a transaction the - /// host must sign and send before the intent exists. - submit: func(body: list) -> result; - - /// Report where a previously submitted intent is in its life. - status: func(receipt: receipt) -> result; - - /// Ask the venue to withdraw an intent. Success means the venue - /// accepted the cancellation, not that settlement can no longer - /// happen: an already in-flight settlement may still win the race. - cancel: func(receipt: receipt) -> result<_, venue-error>; -} diff --git a/wit/nexum-intent/pool.wit b/wit/nexum-intent/pool.wit deleted file mode 100644 index 7655292a..00000000 --- a/wit/nexum-intent/pool.wit +++ /dev/null @@ -1,26 +0,0 @@ -package nexum:intent@0.1.0; - -/// The strategy-module face of the intent core. The host is a router plus -/// a policy checkpoint: it resolves the venue id to the installed adapter, -/// has the adapter derive the header, runs guard policy on it, and only -/// then forwards the call. Bodies are opaque bytes at this boundary; typing -/// is recovered guest-side by venue SDK crates and host-side by the -/// adapter's header derivation. Bodies carry their own routing: there is no -/// chain parameter, a multichain venue's body schema names the chain and -/// the derived header's settlement field exposes the choice to policy. -interface pool { - use types.{intent-status, receipt, submit-outcome, venue-error}; - - /// Submit an opaque intent body to the named venue. Success is either - /// the venue's receipt or requires-signing: a transaction the host - /// must sign and send before the intent exists. - submit: func(venue: string, body: list) -> result; - - /// Report where a previously submitted intent is in its life. - status: func(venue: string, receipt: receipt) -> result; - - /// Ask the venue to withdraw an intent. Success means the venue - /// accepted the cancellation, not that settlement can no longer - /// happen: an already in-flight settlement may still win the race. - cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; -} diff --git a/wit/nexum-intent/types.wit b/wit/nexum-intent/types.wit deleted file mode 100644 index 32c2e16d..00000000 --- a/wit/nexum-intent/types.wit +++ /dev/null @@ -1,132 +0,0 @@ -package nexum:intent@0.1.0; - -/// The venue-neutral intent ontology: what a deal gives and wants, how the -/// venue authorises it, and how its life at the venue is reported. Built on -/// the nexum:value-flow vocabulary so a submission is described the same -/// way to strategy modules, venue adapters, and guard policy. The package -/// deliberately does not depend on nexum:host: embedding the host fault -/// type in venue-error would pin this contract's freeze cadence to host -/// versioning, so venue-error carries its own transport cases. -/// -/// Identifier hygiene follows the value-flow rule: every id is checked -/// against WIT keywords and the reserved words of the nine binding-target -/// languages, preferring a two-word kebab id wherever a single word is a -/// keyword anywhere (`unsigned` not `none`, a Python keyword; -/// `internal-error` not `internal`, a Swift declaration keyword). -interface types { - use nexum:value-flow/types@0.1.0.{asset-amount, settlement}; - - /// How an intent is authorised at its venue. - variant auth-scheme { - /// An EIP-712 typed-data signature by host-held keys. The only - /// scheme that reaches the identity checkpoint at submit time. - eip712, - /// An EIP-1271 contract signature: consent happened on-chain when - /// the commitment was created, so the host signs nothing here. - eip1271, - /// A pre-signed authorisation recorded at the settlement contract - /// ahead of submission. - presign, - /// A venue-defined off-chain signature scheme. - offchain-sig, - /// No authorisation travels with the body. - unsigned, - } - - /// The adapter-derived description of an intent body: the stable - /// ontology guard policy runs on. Policy has teeth on `gives` (what - /// leaves the user's control); `wants` is display-grade because the - /// host can rarely verify the counterparty's obligation and must not - /// pretend to. - record intent-header { - /// Value leaving the user's control. - gives: list, - /// Value expected in return. Display-grade, not host-verified. - wants: list, - /// Expiry in milliseconds since the Unix epoch, UTC; absent means - /// the venue's own default lifetime applies. - valid-until: option, - /// Where the deal settles. - settlement: settlement, - /// How the venue authorises the intent. - authorisation: auth-scheme, - } - - /// Venue-scoped stable identifier for a submitted intent (for CoW, - /// the 56-byte order UID). Opaque to the host and to policy. - type receipt = list; - - /// Why an intent failed terminally, as reported by the venue. - record fail-reason { - /// Venue-scoped machine-readable code, stable enough for a - /// module to match on. - code: string, - /// Human-readable detail for logs and the consent surface. - detail: string, - } - - /// Where an intent is in its life at the venue. - variant intent-status { - /// Accepted for processing but not yet live at the venue. - pending, - /// Live at the venue and eligible for settlement. - open, - /// Settled. The payload is venue-defined settlement proof (for an - /// EVM venue, typically the settlement transaction hash). - settled(option>), - /// Terminally failed. - failed(fail-reason), - /// Reached its expiry without settling. - expired, - /// Withdrawn before settlement. - cancelled, - } - - /// An EVM call the host must sign and send for the intent to exist - /// on-chain. The adapter only describes the call: the host routes it - /// through the guard's host-signed class, fills the gas and fee - /// fields, and signs, so adapters still structurally cannot move - /// value. Always a call to existing code; adapters cannot deploy. - record unsigned-tx { - /// Chain the transaction must land on. - chain-id: u64, - /// 20-byte address of the contract to call. - to: list, - /// Native value, big-endian unsigned; an empty list is zero. - value: list, - /// ABI-encoded calldata. - input: list, - } - - /// What a successful submit produced. A variant from day one: an - /// on-chain-settlement venue (an ethflow-style order) has no receipt - /// to give until a transaction is signed, and bolting that on later - /// would break every deployed module. - variant submit-outcome { - /// The venue holds the intent; the receipt is its stable id. - accepted(receipt), - /// Settlement requires a host-signed on-chain transaction first. - requires-signing(unsigned-tx), - } - - /// Failure of a pool or adapter call. - variant venue-error { - /// No installed adapter answers to the venue id. - unknown-venue, - /// Body bytes failed to decode against the venue's published - /// schema: malformed bytes or an unknown outer version. - invalid-body(string), - /// The receipt was not issued by this venue or is malformed. - invalid-receipt, - /// The venue refused the intent under its own rules. - rejected(string), - /// Guard policy refused the egress before it reached the venue. - denied(string), - /// The venue or adapter does not support the operation. - unsupported(string), - /// Transport or venue infrastructure failure; retry later. - unavailable(string), - /// Adapter or router failure that is not the caller's fault. - internal-error(string), - } -} diff --git a/wit/nexum-value-flow/types.wit b/wit/nexum-value-flow/types.wit deleted file mode 100644 index d626d409..00000000 --- a/wit/nexum-value-flow/types.wit +++ /dev/null @@ -1,98 +0,0 @@ -package nexum:value-flow@0.1.0; - -/// The egress-neutral vocabulary for value in motion: where a deal settles, -/// what asset moves, and how much of it. Shared by intent headers, -/// simulation balance diffs, and analyser verdict subjects so that a spend -/// is written the same way in all three places. This is the platform's -/// hardest-freezing contract; the package carries no dependency so it can -/// outlive any interface built on it. -/// -/// Identifier hygiene is a freeze gate. Every identifier below is checked -/// against WIT keywords (including in-flight proposals -- the package is -/// `value-flow`, not `value`, because the component model's value-imports -/// feature is circling that word) and against the reserved words of the -/// nine binding-target languages (Rust, Python, JS, Go, C#, Java, Kotlin, -/// Swift, Dart). A two-word kebab id is preferred wherever a single word is -/// a keyword anywhere: `native-token` not `native` (a Java modifier), -/// `offchain` not `external` (a Dart keyword). WIT parses the rejected -/// spellings today; the cost lands later, as escaped identifiers in the -/// generated bindings for exactly the personas the SDK exists to serve. -interface types { - /// Where a deal comes to rest. A variant from day one: the chain-id - /// plumbing assumes every venue settles on an EVM chain, which the - /// off-chain marketplace target breaks. - variant settlement { - /// Settles on an EVM chain, identified by its chain id. - evm-chain(u64), - /// Settles off-chain: the payload is a jurisdiction or a - /// venue-defined domain. Settlement here is a legal or - /// out-of-band process, not a chain state transition. - offchain(string), - } - - /// A non-token service obligation whose worth the host cannot compute, - /// e.g. Swarm postage: storage capacity for a duration. Policy on a - /// service asset is adapter-attested, not host-verified; the consent - /// surface renders `summary` and must say the host verifies nothing. - record service-desc { - /// Namespaced service kind the venue defines, e.g. `swarm:postage`. - /// Stable enough for policy to match on. - kind: string, - /// Adapter-supplied human-readable description for the consent sheet. - summary: string, - } - - /// A real-world asset whose settlement is a legal process rather than a - /// chain state transition: a deed, a chattel, a registry entry. The host - /// verifies nothing about it. The case name mirrors `settlement.offchain` - /// deliberately: the same concept on two axes -- where the asset lives, - /// and where the deal settles. - record offchain-desc { - /// Jurisdiction or registry domain the asset lives in, e.g. an - /// ISO country code or a venue-defined registry name. - domain: string, - /// Adapter-supplied human-readable description for the consent sheet. - summary: string, - } - - /// A kind of value that can move. The ERC cases carry a bare chain id - /// rather than a `settlement` because an ERC token is inherently EVM; - /// `native-token` wraps `settlement` because a chain's own gas token is - /// the one asset that exists on every settlement domain. Each token - /// tuple carries raw big-endian bytes: a 20-byte address, and for the - /// NFT cases a token id of arbitrary width. - variant asset { - /// The settlement domain's own gas token (ETH, BZZ, ...). Only an - /// on-chain settlement carries a gas token, so `native-token` pairs - /// meaningfully with `settlement.evm-chain`; `native-token(offchain)` - /// is representable but intentionally invalid -- an off-chain domain - /// has no gas token -- so consumers MUST reject that pairing rather - /// than ascribe a meaning to it. - native-token(settlement), - /// An ERC-20 token: (chain id, 20-byte contract address). - erc20(tuple>), - /// An ERC-721 NFT: (chain id, 20-byte contract address, token id). - erc721(tuple, list>), - /// An ERC-1155 token: (chain id, 20-byte contract address, token id). - erc1155(tuple, list>), - /// A non-token service, e.g. storage capacity for a duration. - service(service-desc), - /// A real-world asset settled off-chain. - offchain(offchain-desc), - } - - /// An amount of one asset. `amount` is a big-endian unsigned integer, - /// most-significant byte first, with no fixed width; an empty list is - /// zero. Amounts are never negative -- direction lives in whichever - /// field holds the pair (a header's `gives` vs `wants`), not here. - /// - /// The canonical wire form is minimal-length: no leading zero bytes, so - /// zero is the empty list and five is `[0x05]`, never `[0x00, 0x05]`. - /// Encoders MUST emit the minimal form; decoders MUST compare amounts by - /// integer value, not by byte equality, so a non-minimal encoding from a - /// lenient peer still compares equal to its canonical twin. - record asset-amount { - asset: asset, - amount: list, - } -} diff --git a/wit/videre-types/types.wit b/wit/videre-types/types.wit new file mode 100644 index 00000000..e8b69c81 --- /dev/null +++ b/wit/videre-types/types.wit @@ -0,0 +1,83 @@ +package videre:types@0.1.0; + +/// The venue-neutral intent ontology. Depends only on value-flow; never on +/// nexum:host, so the venue-error transport cases are its own. +interface types { + use videre:value-flow/types@0.1.0.{asset-amount}; + + /// How an intent is authorised at its venue. Non-EVM schemes are 0.2+. + variant auth-scheme { + eip1271, + eip712, + } + + /// Where a deal settles. EVM-only in 0.1. + record settlement { + chain: u64, + } + + /// Adapter-derived description of an intent body: the ontology guard policy + /// runs on. Policy has teeth on `gives`; `wants` is display-grade. + record intent-header { + gives: asset-amount, + wants: asset-amount, + settlement: settlement, + authorisation: auth-scheme, + } + + /// Venue-scoped stable id for a submitted intent. Opaque to host and policy. + type receipt = list; + + /// An EVM call the host must sign and send. The adapter only describes it; + /// the host fills gas/fee and signs, so adapters cannot move value. Always + /// a call to existing code. + record unsigned-tx { + chain: u64, + /// 20-byte contract address. + to: list, + /// Native value, big-endian minimal; empty is zero. + value: list, + /// ABI-encoded calldata. + data: list, + } + + /// What a successful submit produced. + variant submit-outcome { + accepted(receipt), + requires-signing(unsigned-tx), + } + + /// Lifecycle state. Coarse and portable; proof and failure reason ride the + /// opaque status body (docs/design/videre-wit-pinned-0.1.0.md). + enum intent-status { + pending, + open, + fulfilled, + cancelled, + expired, + } + + /// Failure of a client or adapter call. `denied` and `rate-limited` are the + /// only guard/transport shapes; `denied` MUST NOT be retried. + variant venue-error { + unknown-venue, + invalid-body(string), + unsupported, + denied(string), + rate-limited(rate-limit), + unavailable(string), + timeout, + } + + record rate-limit { + retry-after-ms: option, + } + + /// An indicative quotation for a body. Firm/RFQ maker-side offers are 0.2+. + record quotation { + gives: asset-amount, + wants: asset-amount, + fee: asset-amount, + valid-until-ms: u64, + } +} diff --git a/wit/videre-value-flow/types.wit b/wit/videre-value-flow/types.wit new file mode 100644 index 00000000..c3aa9de9 --- /dev/null +++ b/wit/videre-value-flow/types.wit @@ -0,0 +1,32 @@ +package videre:value-flow@0.1.0; + +/// Egress-neutral vocabulary for value in motion. Carries no dependency so it +/// outlives any contract built on it. EVM-only in 0.1. +interface types { + /// 20-byte EVM address, big-endian. + type address = list; + + /// Unsigned integer, big-endian, minimal-length: no leading zero bytes, + /// zero is the empty list. Decoders MUST compare by integer value, not by + /// byte equality. + type uint = list; + + /// An ERC-20 token on the intent's settlement chain. + record erc20 { + token: address, + } + + /// A kind of value that can move. erc721/erc1155/service/offchain are 0.2+. + variant asset { + /// The settlement chain's gas token. + native, + erc20(erc20), + } + + /// An amount of one asset. Never negative; direction lives in the field + /// that holds the pair (`gives` vs `wants`). + record asset-amount { + asset: asset, + amount: uint, + } +} diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit new file mode 100644 index 00000000..6516586c --- /dev/null +++ b/wit/videre-venue/venue.wit @@ -0,0 +1,39 @@ +package videre:venue@0.1.0; + +/// Worker (keeper) face. The host holds the venue registry; the keeper names +/// a venue by string. +interface client { + use videre:types/types@0.1.0.{receipt, intent-status, submit-outcome, venue-error}; + + submit: func(venue: string, body: list) -> result; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} + +/// Provider (venue) face. Mirrors `client` without the venue selector: one +/// installed adapter answers for exactly one venue, so the registry resolves +/// a venue id to its adapter and calls it directly. +interface adapter { + use videre:types/types@0.1.0.{intent-header, receipt, intent-status, submit-outcome, venue-error}; + + /// Pure: derive the guard-facing header from a body. No I/O. + derive-header: func(body: list) -> result; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; +} + +/// A venue adapter component: the provider face over scoped transport only. +/// No local-store, remote-store, identity, or logging import, so an adapter +/// structurally cannot touch host key material or persistent state. Outbound +/// HTTP is wasi:http, linked separately and allowlisted per adapter. +world venue-adapter { + use nexum:host/types@0.2.0.{config, fault}; + + import nexum:host/chain@0.2.0; + import nexum:host/messaging@0.2.0; + + /// Configure the adapter from its `[config]` before any submission. + export init: func(config: config) -> result<_, fault>; + export adapter; +} From 29438485299903daf762be13bdb98ac2a0fa8dd1 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 15:22:14 +0000 Subject: [PATCH 03/53] wit: normalize every package to a single 0.1.0 Reset nexum:host and shepherd:cow package and use versions to 0.1.0, the shared baseline every package now semvers from independently. wasi:* keeps its own 0.2.x. Drop the 0.1-to-0.2 migration guide and its references. Closes #371 --- crates/nexum-macros/src/world.rs | 28 +- .../src/manifest/capabilities.rs | 34 +- crates/nexum-runtime/src/manifest/error.rs | 2 +- docs/00-overview.md | 17 +- docs/01-runtime-environment.md | 10 +- docs/02-modules-events-packaging.md | 12 +- docs/04-state-store.md | 5 +- docs/05-sdk-design.md | 2 - docs/07-rpc-namespace-design.md | 8 +- docs/08-platform-generalisation.md | 17 +- ...01-engine-toml-separate-from-nexum-toml.md | 2 +- .../adr/0006-cow-twap-ethflow-host-helpers.md | 2 +- docs/diagrams/diagrams.md | 10 +- docs/migration/0.1-to-0.2.md | 526 ------------------ docs/operations/m3-edge-case-validation.md | 2 +- docs/sdk.md | 8 +- wit/nexum-host/chain.wit | 2 +- wit/nexum-host/event-module.wit | 2 +- wit/nexum-host/identity.wit | 2 +- wit/nexum-host/local-store.wit | 2 +- wit/nexum-host/logging.wit | 2 +- wit/nexum-host/messaging.wit | 2 +- wit/nexum-host/query-module.wit | 2 +- wit/nexum-host/remote-store.wit | 2 +- wit/nexum-host/types.wit | 2 +- wit/shepherd-cow/cow-api.wit | 4 +- wit/shepherd-cow/cow-ext.wit | 2 +- wit/shepherd-cow/shepherd.wit | 4 +- wit/videre-venue/venue.wit | 6 +- 29 files changed, 86 insertions(+), 633 deletions(-) delete mode 100644 docs/migration/0.1-to-0.2.md diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index 3fcf3818..acae4eb1 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -36,37 +36,37 @@ struct Capability { const KNOWN: &[Capability] = &[ Capability { name: "chain", - import: Some("nexum:host/chain@0.2.0"), + import: Some("nexum:host/chain@0.1.0"), packages: &[], adapter: Some("chain"), }, Capability { name: "identity", - import: Some("nexum:host/identity@0.2.0"), + import: Some("nexum:host/identity@0.1.0"), packages: &[], adapter: None, }, Capability { name: "local-store", - import: Some("nexum:host/local-store@0.2.0"), + import: Some("nexum:host/local-store@0.1.0"), packages: &[], adapter: Some("local_store"), }, Capability { name: "remote-store", - import: Some("nexum:host/remote-store@0.2.0"), + import: Some("nexum:host/remote-store@0.1.0"), packages: &[], adapter: None, }, Capability { name: "messaging", - import: Some("nexum:host/messaging@0.2.0"), + import: Some("nexum:host/messaging@0.1.0"), packages: &[], adapter: None, }, Capability { name: "logging", - import: Some("nexum:host/logging@0.2.0"), + import: Some("nexum:host/logging@0.1.0"), packages: &[], adapter: Some("logging"), }, @@ -78,7 +78,7 @@ const KNOWN: &[Capability] = &[ }, Capability { name: "cow-api", - import: Some("shepherd:cow/cow-api@0.2.0"), + import: Some("shepherd:cow/cow-api@0.1.0"), packages: &["shepherd-cow"], adapter: None, }, @@ -198,7 +198,7 @@ pub fn synthesize_venue(declared: &[String]) -> Result { let mut wit = String::from( "package nexum:venue-world;\n\nworld venue-adapter {\n \ - use nexum:host/types@0.2.0.{config, fault};\n\n", + use nexum:host/types@0.1.0.{config, fault};\n\n", ); wit.push_str(&imports); wit.push_str( @@ -259,7 +259,7 @@ pub fn synthesize(declared: &[String]) -> Result { let mut wit = String::from( "package nexum:module-world;\n\nworld module {\n \ - use nexum:host/types@0.2.0.{config, event, fault};\n\n", + use nexum:host/types@0.1.0.{config, event, fault};\n\n", ); wit.push_str(&imports); wit.push_str( @@ -294,7 +294,7 @@ mod tests { #[test] fn logging_only_world_imports_logging_alone() { let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/logging@0.2.0;")); + assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); assert!(!world.wit.contains("import nexum:host/chain")); assert!(!world.wit.contains("shepherd:cow")); assert_eq!(world.packages, MODULE_PACKAGES); @@ -304,7 +304,7 @@ mod tests { #[test] fn cow_api_pulls_the_shepherd_cow_package() { let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); - assert!(world.wit.contains("import shepherd:cow/cow-api@0.2.0;")); + assert!(world.wit.contains("import shepherd:cow/cow-api@0.1.0;")); assert_eq!(world.packages, vec!["nexum-host", "shepherd-cow"]); } @@ -363,12 +363,12 @@ mod tests { #[test] fn venue_world_imports_only_declared_transport() { let world = synthesize_venue(&["chain".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/chain@0.2.0;")); + assert!(world.wit.contains("import nexum:host/chain@0.1.0;")); assert!(!world.wit.contains("import nexum:host/messaging")); let both = synthesize_venue(&["chain".to_string(), "messaging".to_string()]).unwrap(); - assert!(both.wit.contains("import nexum:host/chain@0.2.0;")); - assert!(both.wit.contains("import nexum:host/messaging@0.2.0;")); + assert!(both.wit.contains("import nexum:host/chain@0.1.0;")); + assert!(both.wit.contains("import nexum:host/messaging@0.1.0;")); } #[test] diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index ef00fcef..4264c6e6 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -180,11 +180,11 @@ impl CapabilityRegistry { /// manifest declaration. /// /// Examples: - /// - `"nexum:host/chain@0.2.0"` -> `Some("chain")` - /// - `"shepherd:cow/cow-api@0.2.0"` -> `Some("cow-api")` once the cow + /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` + /// - `"shepherd:cow/cow-api@0.1.0"` -> `Some("cow-api")` once the cow /// namespace is registered /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` - /// - `"nexum:host/types@0.2.0"` -> `None` (type-only, not a capability) + /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) /// - `"wasi:io/streams@0.2.0"` -> `None` pub fn wit_import_to_cap<'a>(&self, import_name: &'a str) -> Option<&'a str> { let without_version = import_name.split('@').next().unwrap_or(import_name); @@ -284,9 +284,9 @@ mod tests { #[test] fn wit_import_to_cap_nexum_host() { let r = CapabilityRegistry::core(); - assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( - r.wit_import_to_cap("nexum:host/local-store@0.2.0"), + r.wit_import_to_cap("nexum:host/local-store@0.1.0"), Some("local-store") ); } @@ -318,11 +318,11 @@ mod tests { fn wit_import_to_cap_shepherd_cow_needs_registration() { // Core registry does not recognise the cow namespace. let core = CapabilityRegistry::core(); - assert_eq!(core.wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), None); + assert_eq!(core.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), None); // Once registered, it resolves. let r = registry_with_cow(); assert_eq!( - r.wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), + r.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), Some("cow-api") ); } @@ -376,7 +376,7 @@ mod tests { fn enforce_passes_when_caps_absent() { // 0.1-fallback: no capabilities section -> all imports allowed let loaded = manifest_no_caps(); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; let r = registry_with_cow(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -385,8 +385,8 @@ mod tests { fn enforce_passes_when_all_imports_declared() { let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); let imports = [ - "nexum:host/chain@0.2.0", - "shepherd:cow/cow-api@0.2.0", + "nexum:host/chain@0.1.0", + "shepherd:cow/cow-api@0.1.0", "wasi:http/outgoing-handler@0.2.12", "wasi:io/streams@0.2.0", // non-http wasi is always skipped ]; @@ -398,7 +398,7 @@ mod tests { fn enforce_rejects_wasi_http_import_without_declaration() { let loaded = manifest_with_caps(&["chain"], &[]); let imports = [ - "nexum:host/chain@0.2.0", + "nexum:host/chain@0.1.0", "wasi:http/outgoing-handler@0.2.12", ]; let r = registry_with_cow(); @@ -428,7 +428,7 @@ mod tests { fn enforce_rejects_undeclared_import() { let loaded = manifest_with_caps(&["chain"], &[]); // module imports remote-store but didn't declare it - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; let r = registry_with_cow(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { @@ -440,7 +440,7 @@ mod tests { #[test] fn enforce_optional_caps_are_also_allowed() { let loaded = manifest_with_caps(&["chain"], &["remote-store"]); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; let r = registry_with_cow(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -463,9 +463,9 @@ mod tests { #[test] fn adapter_registry_maps_transport_imports_but_not_core_only() { let r = CapabilityRegistry::adapter(); - assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( - r.wit_import_to_cap("nexum:host/messaging@0.2.0"), + r.wit_import_to_cap("nexum:host/messaging@0.1.0"), Some("messaging") ); assert_eq!( @@ -473,7 +473,7 @@ mod tests { Some("http") ); // A core-only interface is not a recognised adapter capability. - assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.2.0"), None); + assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.1.0"), None); } #[test] @@ -575,7 +575,7 @@ mod tests { let loaded = manifest_no_caps(); let r = registry_with_cow(); assert!( - enforce_capabilities(&loaded, ["nexum:host/remote-store@0.2.0"].into_iter(), &r) + enforce_capabilities(&loaded, ["nexum:host/remote-store@0.1.0"].into_iter(), &r) .is_ok() ); assert!(enforce_capabilities(&loaded, ["wasi:io/streams@0.2.6"].into_iter(), &r).is_ok()); diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index 73e1ac4f..470cc0bb 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -44,7 +44,7 @@ pub struct CapabilityViolation { /// Capability name (e.g. `"remote-store"`). pub capability: String, /// Full WIT import name as it appeared in the component (e.g. - /// `"nexum:host/remote-store@0.2.0"`). + /// `"nexum:host/remote-store@0.1.0"`). pub wit_import: String, } diff --git a/docs/00-overview.md b/docs/00-overview.md index ee99796e..4deeecd5 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -11,14 +11,12 @@ Two project names look similar but mean different things - keeping them straight | Term | What it is | Where you find it | |---|---|---| | **engine** (`nexum`) | A concrete *implementation* that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. Mobile / browser / embedded engines could exist later - each is a separate engine. | `crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | -| **host** (`nexum:host`) | The WIT *contract* - the set of host-imported interfaces (chain, identity, local-store, etc.), types, and worlds that every engine must implement and every module imports. The contract is one; engines are many. | `wit/nexum-host/`, `package nexum:host@0.2.0`, Rust path `nexum::host::*` | +| **host** (`nexum:host`) | The WIT *contract* - the set of host-imported interfaces (chain, identity, local-store, etc.), types, and worlds that every engine must implement and every module imports. The contract is one; engines are many. | `wit/nexum-host/`, `package nexum:host@0.1.0`, Rust path `nexum::host::*` | The relationship: an engine *implements* `nexum:host` so that modules *built against* `nexum:host` can run on it. The `nexum:host` package itself does not run anything - it's a specification. When this doc says "the host", it means whichever engine the module currently runs on, as seen through the `nexum:host` contract. The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `crates/nexum-cli`, a thin consumer of it. A Rust embedder skips the binary entirely, constructs an `EngineConfig` in code, and calls `nexum_runtime::bootstrap::run_from_config`. See `crates/nexum-runtime/examples/embed.rs` for a minimal end-to-end example. -> **Upgrading from 0.1?** See the [Migration Guide](migration/0.1-to-0.2.md) for the full rename table (`web3:runtime` → `nexum:host`, `csn` → `chain`, `msg` → `messaging`, `headless-module` → `event-module`, etc.), the per-interface typed error model over the shared `fault` vocabulary, and the manifest-driven capability negotiation introduced in 0.2. - ## Architecture ```mermaid @@ -96,7 +94,7 @@ In addition to the six core primitives, 0.2 introduces one optional capability t Time and secure randomness are WASI concerns rather than Nexum capabilities: `wasi:clocks` and `wasi:random` are linked into every module store ambiently. -0.2 also publishes (but does not yet host) the experimental **`query-module`** world for request/response modules (wallet rule evaluators, signature validators, pricing oracles). The WIT is stable enough to target with `MockHost` tests; production host support lands in 0.3. See the migration guide for the full WIT. +0.2 also publishes (but does not yet host) the experimental **`query-module`** world for request/response modules (wallet rule evaluators, signature validators, pricing oracles). The WIT is stable enough to target with `MockHost` tests; production host support lands in 0.3. ## WIT Worlds @@ -121,7 +119,7 @@ graph TB ``` // Universal layer - any platform, any blockchain app -package nexum:host@0.2.0 +package nexum:host@0.1.0 world event-module { import chain - consensus access (JSON-RPC passthrough) @@ -136,7 +134,7 @@ world event-module { } // CoW Protocol extension -package shepherd:cow@0.2.0 +package shepherd:cow@0.1.0 world shepherd { include event-module @@ -158,7 +156,7 @@ The world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked |---------|--------|---------| | Language | Rust | 1.90+ | | WASM runtime | wasmtime (Component Model) | 45.x | -| API contract | WIT (`nexum:host@0.2.0`, `shepherd:cow@0.2.0`) | - | +| API contract | WIT (`nexum:host@0.1.0`, `shepherd:cow@0.1.0`) | - | | Guest bindings | wit-bindgen | 0.57.x | | Async | Tokio | - | | Ethereum RPC | alloy | 1.5.x | @@ -311,7 +309,7 @@ Tower layer stack per chain: timeout -> retry (exponential + jitter) -> rate lim ### Error Model -In 0.2 each interface declares its own typed error and they share one payload-bearing `fault` vocabulary for the cross-domain cases. `fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports); a richer interface embeds `fault` as one case of its own variant and adds the cases only it needs (`chain-error` adds an `rpc` case carrying the node code and decoded revert bytes). Modules match on the typed variant for retry/backoff decisions; the per-protocol error types from 0.1 (`json-rpc-error`, `msg-error`, `store-error`, `api-error`) are gone. See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model and the [migration guide](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping. +In 0.2 each interface declares its own typed error and they share one payload-bearing `fault` vocabulary for the cross-domain cases. `fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports); a richer interface embeds `fault` as one case of its own variant and adds the cases only it needs (`chain-error` adds an `rpc` case carrying the node code and decoded revert bytes). Modules match on the typed variant for retry/backoff decisions; the per-protocol error types from 0.1 (`json-rpc-error`, `msg-error`, `store-error`, `api-error`) are gone. See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model. ### Observability @@ -379,8 +377,7 @@ shepherd/ ├── operations/ Runbooks, E2E reports, load reports, baselines ├── production.md Operator handbook ├── sdk.md Module-author entry point (shipped SDK reference) - ├── tutorial-first-module.md - └── migration/0.1-to-0.2.md + └── tutorial-first-module.md ``` The SDK split is in place: `nexum-sdk` carries the universal surface and `shepherd-sdk` layers the CoW domain on top, with no re-export between them. Shipping a `cargo-nexum` subcommand for module authors remains future direction. diff --git a/docs/01-runtime-environment.md b/docs/01-runtime-environment.md index 572e2898..8ad5f84f 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -101,12 +101,12 @@ let bindings = EventModule::instantiate_pre(&mut store, &pre)?; Nexum uses a two-layer WIT architecture. The **universal** package `nexum:host` defines platform-agnostic interfaces and the `event-module` world. The **CoW-specific** package `shepherd:cow` extends it with CoW Protocol interfaces and the `shepherd` world. -### Universal Package: `nexum:host@0.2.0` +### Universal Package: `nexum:host@0.1.0` The `nexum:host` package is the single source of truth for the universal host-guest contract. It defines a custom world with **no WASI imports**: ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -301,14 +301,14 @@ world event-module { } ``` -In addition to the six core imports, 0.2 publishes one additive optional capability - `http` (allowlisted outbound HTTP) - which modules declare in their `module.toml` `[capabilities]` section. The declaration is a manifest concern only: the capability is serviced by the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. The migration guide carries the details. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. +In addition to the six core imports, 0.2 publishes one additive optional capability - `http` (allowlisted outbound HTTP) - which modules declare in their `module.toml` `[capabilities]` section. The declaration is a manifest concern only: the capability is serviced by the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. -### CoW-Specific Package: `shepherd:cow@0.2.0` +### CoW-Specific Package: `shepherd:cow@0.1.0` The `shepherd:cow` package extends the universal world with CoW Protocol interfaces. In 0.2 the two 0.1 interfaces (`cow` + `order`) merge into a single `cow-api` interface to eliminate the `cow::cow::request` triple-stutter: ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index 2a946860..15c1e0c4 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -54,9 +54,9 @@ enable_alerts = true # boolean stays boolean Key design points: -- **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). (Was `wasm = ...` in 0.1 - see the migration guide.) +- **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). - **`[[subscription]]` blocks are declarative.** The module doesn't set up its own subscriptions imperatively - the runtime reads the manifest and wires up event sources before calling `init`. The 0.1 spelling was `[[subscribe]]` with `type = ...`; 0.2 uses `[[subscription]]` with `kind = ...` because `type` is a reserved word in several binding languages. -- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. See the migration guide for the full schema (including `[capabilities.http]` allowlists). A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `modules/examples/http-probe` for a complete example. +- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `modules/examples/http-probe` for a complete example. - **Chain ids are declared per-subscription**, not in a top-level `[chains]` table - each `[[subscription]]` names its own `chain_id`. If `engine.toml` has no `[chains.]` entry for a chain a subscription names, the engine bails at boot, before any events dispatch (fast, clear error). - **`config`** is opaque to the runtime. 0.2 keeps 0.1's stringly-typed shape (`list>`); the host flattens TOML scalars (numbers, booleans) to their string form on the way through. A typed `config-value` variant is on the 0.3 roadmap, bundled with the manifest-parser work. @@ -277,10 +277,10 @@ The runtime serialises event data via the canonical ABI (handled automatically b The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The architecture uses two packages: `nexum:host` for universal interfaces and `shepherd:cow` for CoW Protocol extensions. -### Universal Package: `nexum:host@0.2.0` +### Universal Package: `nexum:host@0.1.0` ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -409,10 +409,10 @@ world event-module { } ``` -### CoW-Specific Package: `shepherd:cow@0.2.0` +### CoW-Specific Package: `shepherd:cow@0.1.0` ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; diff --git a/docs/04-state-store.md b/docs/04-state-store.md index 59de103a..96d5434b 100755 --- a/docs/04-state-store.md +++ b/docs/04-state-store.md @@ -55,8 +55,7 @@ interface local-store { /// Set a key-value pair. Overwrites existing value. /// Returns fault.invalid-input or fault.internal on failure. - /// Quota exhaustion surfaces as fault.invalid-input (or a future - /// dedicated case) - see the migration guide. + /// Quota exhaustion surfaces as fault.invalid-input. set: func(key: string, value: list) -> result<_, fault>; /// Delete a key. No-op if key doesn't exist. @@ -67,7 +66,7 @@ interface local-store { } ``` -In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the shared `fault` vocabulary (see [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) so modules can match on the `fault` case rather than parsing error strings. The interface is the failure domain, so it reports `fault` directly with no subsystem tag. +In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the shared `fault` vocabulary so modules can match on the `fault` case rather than parsing error strings. The interface is the failure domain, so it reports `fault` directly with no subsystem tag. Keys are UTF-8 strings. Values are opaque bytes - the SDK provides typed wrappers (see doc 05). diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 8b62896f..60594ad3 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -304,8 +304,6 @@ of it, not a requirement. - [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed error model (`Fault`, `ChainError`, `CowApiError`) the host traits return. -- [Migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) - - what changed in the SDK surface between 0.1 and 0.2. - [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough design and why module authors call `host.request` directly rather than through an injected provider. diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index f7e8f542..25246aac 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -75,7 +75,7 @@ flowchart TD Replace the `blockchain` interface with `chain`: ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface chain { use types.{chain-id, fault}; @@ -107,7 +107,7 @@ interface chain { } ``` -Errors are reported via `chain-error`: either a shared `fault` (see doc 00, ADR-0011, and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) 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. +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). @@ -1231,10 +1231,6 @@ The primary trade-off is **type safety at the WIT boundary**: JSON strings vs. s 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. -## Migration Path - -For modules and embedders moving from 0.1 to 0.2, follow the [Migration Guide](migration/0.1-to-0.2.md). In summary: the early 0.1 `blockchain` sketch was replaced by `csn` later in 0.1 and is now `chain` in 0.2; the SDK's `block_on` is now hidden behind the `#[nexum::module]` macro; and each interface returns its own typed error over the shared `fault` vocabulary rather than a per-protocol error type. - ## Summary | Component | What 0.2 ships | diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index 30c1f8b7..110bf3c0 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -374,7 +374,7 @@ Every platform implements this trivially. On server: `tracing` crate. On mobile: ### Universal World Definition ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -581,7 +581,7 @@ The host loads `index.html` into a WebView and injects the bridge JavaScript tha Domain-specific interfaces extend the universal layer for particular use cases. The pattern: ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; @@ -880,7 +880,7 @@ Any platform that wants to run modules must implement the **Host Adapter** - the ### Required Behaviours -In 0.2 each interface returns its own typed error over the shared `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). The fault case is normative - embedders MUST pick the most specific case for each backend failure. See ADR-0011 and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping table. +Each interface returns its own typed error over the shared `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). The fault case is normative - embedders MUST pick the most specific case for each backend failure. See ADR-0011 for the embedder-side mapping table. **`chain::request` / `chain::request-batch`** (Chain) - MUST forward the JSON-RPC request to a provider for the given chain. @@ -993,17 +993,6 @@ A module author building a generic blockchain automation module depends only on For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnecessary - they use `wit-bindgen` directly against the WIT package for their target world. The WIT is the universal contract; the SDK is a Rust ergonomics layer on top. -## Migration from 0.1 - -For the full 0.1 → 0.2 rename and behaviour change list, see the [Migration Guide](migration/0.1-to-0.2.md). The main themes: - -- WIT package `web3:runtime` → `nexum:host`; interfaces `csn` → `chain` and `msg` → `messaging`; worlds `headless-module` → `event-module` and `shepherd-module` → `shepherd`. -- CoW `cow` + `order` interfaces merged into `cow-api`. -- Each interface returns its own typed error over the shared `fault` vocabulary instead of five per-protocol error types. -- The `event-module` world imports the six primitives the docs always claimed (0.1's WIT was missing `identity` from the world definition). -- Manifest: `wasm = ...` → `component = ...`; `[[subscribe]]` → `[[subscription]]` with `kind` instead of `type`; new `[capabilities]` section drives optional/required imports; `[config]` values are now typed. -- Additive: the `http` capability (serviced by wasi:http, no new `nexum:host` WIT), `chain::request-batch`, and the experimental `query-module` world. - ## Summary ### Primitive Taxonomy diff --git a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md index 2c294279..5dee12b9 100644 --- a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md +++ b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md @@ -30,7 +30,7 @@ The engine config carries the path to each module's manifest; the two never coll ## Consequences - A deployment needs both files. A missing `engine.toml` falls back to "no chains, default state_dir" - the example logging module still runs; cow-api / chain backends report `unsupported`. -- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3 per `docs/migration/0.1-to-0.2.md`. +- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3. - Module-bundle redistribution carries `module.toml` with the artifact; engines do not need to ship templates. - Future content-addressed module distribution (0.3) embeds `module.toml` in the bundle hash; `engine.toml` references modules by content address rather than filesystem path. The split survives that migration unchanged. - Implementation impact: `crates/nexum-engine/src/manifest.rs` and `engine_config.rs` need to update the filename lookup from `nexum.toml` to `module.toml`. The 0.1-compat fallback in `manifest::fallback_manifest()` should accept both names during the transition; after 0.3 only `module.toml` is recognised. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index 96f8d02b..a76a9faa 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -39,7 +39,7 @@ An EthFlow module's `on_event(log)` handler decodes the `OrderPlacement` event w ## Consequences -- `shepherd:cow@0.2.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. +- `shepherd:cow@0.1.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. - `KNOWN_CAPABILITIES` in `crates/nexum-engine/src/manifest.rs` does **not** gain `"twap"` or `"ethflow"` entries. Modules declare the universal capabilities they actually use: `chain`, `local-store`, `logging`, `cow-api`. - Modules ship larger (~150 LOC each estimated, up from the ~30 LOC the host-helper design implied), because event decoding, eth_call orchestration, OrderCreation construction, and error-hint interpretation now live in guest code. This is the explicit trade-off: more code per module, less coupling, more freedom for different strategies to coexist. - Different TWAP polling strategies can coexist as different modules. Operators choose which to load via `engine.toml`'s `[[modules]]` array. diff --git a/docs/diagrams/diagrams.md b/docs/diagrams/diagrams.md index a9d6c21a..8e67ba53 100644 --- a/docs/diagrams/diagrams.md +++ b/docs/diagrams/diagrams.md @@ -15,7 +15,7 @@ graph TD ET["engine.toml · module.toml\n(operator config + module manifest)"] SUP["Supervisor::boot"] POOLS["ProviderPool · OrderBookPool · LocalStore"] - HS["HostState (per module)\nnexum:host@0.2.0 + shepherd:cow@0.2.0"] + HS["HostState (per module)\nnexum:host@0.1.0 + shepherd:cow@0.1.0"] EL["EventLoop - futures::stream::select_all\nfan-out block/chain-log streams to subscribers"] MODS["WASM Modules\ntwap.wasm · eth-flow.wasm\n(self-contained protocol logic in guest)"] BC["Blockchain (Sepolia / Mainnet / …)\nComposableCoW · CowEthFlow · RPC Node"] @@ -161,8 +161,8 @@ Two WIT packages: the universal `nexum:host` and the CoW-specific `shepherd:cow` ```mermaid graph TD - NH["nexum:host@0.2.0\n(universal - no CoW knowledge)"] - SC["shepherd:cow@0.2.0\n(CoW Protocol extensions)"] + NH["nexum:host@0.1.0\n(universal - no CoW knowledge)"] + SC["shepherd:cow@0.1.0\n(CoW Protocol extensions)"] NH --> n1["chain ✅ implemented\nrequest(chain-id, method, params)\nrequest-batch(chain-id, requests)\n - \nsubscribe-blocks · subscribe-logs →\n engine-managed via module.toml subscriptions\nregister-address · unregister-address →\n 🕓 deferred to 0.3 (ADR-0008)"] NH --> n2["local-store ✅ implemented\nget(key) · set(key, value)\ndelete(key) · list-keys(prefix)\nnamespacing: 32-byte hash prefix (ADR-0003)"] @@ -182,13 +182,13 @@ graph TD | Interface | What it does | |---|---| -| **nexum:host@0.2.0** | The base WIT package. Any module running in the engine - CoW-aware or not - imports from here. Defines shared types (`chain-id`, `chain-log`, `fault`) used by both packages. | +| **nexum:host@0.1.0** | The base WIT package. Any module running in the engine - CoW-aware or not - imports from here. Defines shared types (`chain-id`, `chain-log`, `fault`) used by both packages. | | **chain** | Reads from the blockchain via JSON-RPC. `request` sends a single call; `request-batch` sends several in one round-trip. **Subscriptions are not callable WIT functions** - they are declared in `module.toml` and opened by the engine at boot. Dynamic `register-address` for factory patterns is deferred to 0.3 (ADR-0008). | | **local-store** | Persistent key-value storage that survives restarts. Operations: `get(key)`, `set(key, value)`, `delete(key)`, `list-keys(prefix)`. The host prefixes every key with a 32-byte deterministic namespace (`keccak256(module_name)` locally, or `ens_namehash(name)` when ENS-loaded) so modules are fully isolated and the namespace cannot be spoofed (ADR-0003). | | **identity · messaging · remote-store** | Capabilities stubbed at 0.2 - they return `Unsupported`. `identity` will provide keystore-backed signing. `messaging` will send Waku messages. `remote-store` will read/write Swarm/IPFS. | | **logging** | Lightweight utility. `logging` emits to the engine's `tracing` subscriber (inherits `RUST_LOG` filters). Time and secure randomness are available ambiently via `wasi:clocks` and `wasi:random`. | | **(outbound HTTP)** | Not a `nexum:host` interface: a module that declares the `http` capability imports the standard `wasi:http/outgoing-handler`, and the host checks every outgoing request against the manifest's `[capabilities.http].allow` list before any connection is made. | -| **shepherd:cow@0.2.0** | The CoW Protocol extension package. Imports `nexum:host/types` for shared types so modules don't re-define `chain-id` or `chain-log`. Only CoW-aware modules need to import this package. Contains exactly **one** interface in 0.2: `cow-api`. | +| **shepherd:cow@0.1.0** | The CoW Protocol extension package. Imports `nexum:host/types` for shared types so modules don't re-define `chain-id` or `chain-log`. Only CoW-aware modules need to import this package. Contains exactly **one** interface in 0.2: `cow-api`. | | **cow-api** | Generic orderbook access. `request` is a raw REST passthrough (returns JSON string). `submit-order` takes raw order bytes and returns a `result` where the string is the order UID. Routes through the engine's `OrderBookPool`. This is the only protocol-level CoW interface in 0.2 - the boundary between "what CoW Protocol *is*" (orderbook submission, order types) and "what's implemented *on top* of CoW" (TWAP polling, EthFlow event handling). | | **(no twap interface)** | Per ADR-0006, no specialised TWAP host interface exists. The TWAP module implements polling, decoding, and submission entirely in guest code, using `chain.request` for `eth_call`, `local-store` for state, `alloy_sol_types` (in-module) for ABI decoding, `cowprotocol` types for `OrderCreation`, and `cow-api.submit-order` for orderbook submission. Multiple TWAP strategies can coexist as separate modules with different polling policies and error tolerances. | | **(no ethflow interface)** | Per ADR-0006, no specialised EthFlow host interface exists. The EthFlow module decodes `OrderPlacement` directly in guest code via `alloy_sol_types`, constructs the `OrderCreation` with the EIP-1271 signing scheme via `cowprotocol` types, and submits via `cow-api`. | diff --git a/docs/migration/0.1-to-0.2.md b/docs/migration/0.1-to-0.2.md deleted file mode 100644 index 3b5889ef..00000000 --- a/docs/migration/0.1-to-0.2.md +++ /dev/null @@ -1,526 +0,0 @@ -# Migrating from Nexum 0.1 to 0.2 - -Nexum 0.2 is a single coordinated breaking-change release. It does the renames, the error-model unification, the missing primitives, and the capability-negotiation work in one window so module authors only pay the migration tax once. There will not be another breaking release of comparable scope before 1.0. - -This guide is written for two audiences: - -- **Module authors** - you write WASM components that import the Nexum WIT. -- **Host embedders** - you build the runtime that loads modules (the server daemon, a mobile wallet, a browser host). - -Each section is tagged `[author]`, `[embedder]`, or `[both]`. - ---- - -## TL;DR - what changed [both] - -| Area | 0.1 | 0.2 | -|---|---|---| -| WIT package | `web3:runtime` | `nexum:host` | -| Consensus interface | `csn` | `chain` | -| Messaging interface | `msg` | `messaging` | -| Default world | `headless-module` | `event-module` | -| CoW world | `shepherd:cow/shepherd-module` | `shepherd:cow/shepherd` | -| CoW interfaces | `cow` + `order` | `cow-api` (merged) | -| Feed methods | `feed-get` / `feed-set` | `read-feed` / `write-feed` | -| Event variants | `block-data` / `log-entry` / `message-data` / `timer(u64)` | `block` / `log` / `message` / `tick { fired-at }` | -| Errors | 5 different shapes + bare `string` | per-interface typed errors over a shared `fault` vocabulary | -| Capabilities | All six imports mandatory | Manifest-negotiated, optional imports trap on call | -| Engine crate | `nxm-engine` | `nexum-engine` | -| Manifest file | `nexum.toml` (some docs said `shepherd.toml`) | `nexum.toml` (canonical) | -| Manifest field | `wasm = "sha256:..."` | `component = "sha256:..."` | -| Manifest section | `[[subscribe]]` | `[[subscription]]` | -| Config type | `list>` (stringified) | unchanged in 0.2; typed variant on the 0.3 roadmap | -| New capabilities | - | `http` (wasi:http, allowlisted); time and randomness are ambient via wasi:clocks / wasi:random | -| New RPC method | - | `chain::request-batch` (additive) | -| New world | - | `query-module` (experimental, no host impl shipped) | - -If you only do four things: update your `nexum.toml`, run the sed cheat-sheet at the bottom, replace your error handling with the new `fault` vocabulary, and declare your capabilities explicitly. Everything else is mechanical. - ---- - -## 1. WIT renames [author] - -### Package rename - -```diff -- use web3:runtime/types.{config, event}; -- use web3:runtime/chain.{chain-id}; -+ use nexum:host/types.{config, event}; -+ use nexum:host/chain.{chain-id}; -``` - -Why: `web3:` precommitted the engine to crypto-only branding. The package is now named after the engine; web3-specific capabilities live inside it as interfaces. - -### Interface renames - -| 0.1 | 0.2 | Rationale | -|---|---|---| -| `csn` | `chain` | `csn` was unreadable; `chain.request(chainId, method, params)` reads itself. | -| `msg` | `messaging` | `msg` collided with its own `message` record; ambiguous in non-Rust bindings. | -| `cow` + `order` | `cow-api` (one interface) | `cow::cow::request` triple-stutter eliminated; `order::submit` merged as `cow-api::submit-order`. | - -### World renames - -```diff -- world headless-module { -+ world event-module { - import chain; - import identity; // NOTE: was missing from 0.1 WIT; now present - import local-store; - import remote-store; - import messaging; - import logging; - export init: func(config: config) -> result<_, string>; - export on-event: func(event: event) -> result<_, string>; - } -``` - -```diff -- world shepherd-module { -- include headless-module; -- import cow; -- import order; -+ world shepherd { -+ include event-module; -+ import cow-api; - } -``` - -### Function renames (verb-first, fully spelled) - -```diff - interface remote-store { -- feed-get: func(owner: list, topic: list) -> result>, store-error>; -- feed-set: func(topic: list, data: list) -> result, store-error>; -+ read-feed: func(owner: list, topic: list) -> result>, fault>; -+ write-feed: func(topic: list, data: list) -> result, fault>; - } -``` - -### Type and field renames - -```diff - interface types { -- record block-data { ... } -- record log-entry { ..., tx-hash: list, ... } -- record message-data { ... } -- variant event { -- block(block-data), -- logs(list), -- timer(u64), -- message(message-data), -- } -+ record block { ... } -+ record log { ..., transaction-hash: list, ... } -+ record message { ... } -+ record tick { fired-at: u64 } // milliseconds since Unix epoch, UTC -+ variant event { -+ block(block), -+ logs(list), -+ tick(tick), -+ message(message), -+ } - } -``` - -Two semantic notes: - -- All `u64` timestamps in 0.2 are **milliseconds since Unix epoch, UTC**. The 0.1 WIT did not specify a unit and several sources used seconds. Audit any timestamp arithmetic you do. -- `tick` (formerly `timer`) is now a record, not a bare `u64`. In bindings it reads `event.tick.firedAt` instead of `event.timer === 1700000000`. - -> **Breaking: the chain-event log is now `chain-log`.** The `log` record and the `logs(list)` event arm shown above have been renamed to `chain-log` and `chain-logs(chain-logs)` so the on-chain event vocabulary no longer collides with the diagnostics logging pipeline (`nexum:host/logging`, `[limits.logs]`), which is untouched. Three consequences: a manifest with `kind = "log"` fails load with an unknown-kind error naming the valid set (`block`, `chain-log`, `cron`); a component built against the old world's `log` record or `logs` arm no longer links against the current world (rebuild against the renamed WIT); and guest strategy handlers rename `on_logs` to `on_chain_logs`. The `block`, `tick`, and `message` arms are unchanged. - -> **Breaking: the chain-log record now carries the full RPC log shape.** The record was reshaped to mirror `alloy_rpc_types_eth::Log` field for field so a guest reconstructs the native alloy log without loss: `block-hash`, `block-timestamp`, `transaction-index`, and `removed` are added; `log-index` and `transaction-index` widen to `u64`; and the block-scoped fields become `option<>` (absent on a pending log). The chain id moves off the per-log record onto a new `chain-logs { chain-id, logs }` batch that the `chain-logs` event arm now wraps, since a delivery always shares one chain and the alloy log type carries no chain id of its own. Guests receive `nexum_sdk::events::Log` (alloy's RPC log) directly and decode `sol!` events against `log.inner`; the SDK bind macro emits the WIT-record-to-alloy conversion, so module glue maps a batch straight to `Vec`. - ---- - -## 2. Error model unification [both] - -The five 0.1 error shapes (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) plus bare `string` errors give way to the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases (see [ADR-0011](../adr/0011-per-interface-typed-errors.md)). - -```wit -interface types { - // The shared cross-domain vocabulary. Each payload-bearing case - // carries a human-readable detail; rate-limited carries backoff. - variant fault { - unsupported(string), // capability declared but not provisioned - unavailable(string), // capability exists, backend is down/offline - denied(string), // user or policy rejected - rate-limited(rate-limit), - timeout, - invalid-input(string), - internal(string), // host bug - } - - record rate-limit { - retry-after-ms: option, - } -} -``` - -Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports). A richer interface embeds `fault` as one case of its own variant and adds the cases only it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. - -```wit -interface chain { - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } -} -``` - -### Author migration - -The stringly `domain`/`code` cross-check is gone; dispatch on the typed variant instead. - -```diff -- match chain::request(1, "eth_call", params) { -- Ok(s) => parse(s), -- Err(JsonRpcError { code, message, .. }) if code == -32000 => retry(), -- Err(e) => bail!("rpc failed: {}", e.message), -- } -+ use nexum_sdk::host::{ChainError, Fault}; -+ match host.request(1, "eth_call", params) { -+ Ok(s) => parse(s), -+ Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), -+ Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), -+ Err(ChainError::Fault(Fault::Denied(_))) => abort("denied"), -+ Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // node code + revert bytes -+ Err(e) => bail!("{e}"), -+ } -``` - -`local-store` errors are no longer bare `string`s: they are a plain `fault`. The interface is the failure domain, so the fault omits any subsystem tag; the case tells you whether you hit a quota (`invalid-input`), the backend is down (`unavailable`), etc. - -Module export signatures also change: - -```diff -- export init: func(config: config) -> result<_, string>; -- export on-event: func(event: event) -> result<_, string>; -+ export init: func(config: config) -> result<_, fault>; -+ export on-event: func(event: event) -> result<_, fault>; -``` - -Module identity is the supervisor's business, so module errors are plain `fault` cases; you no longer restate your module name or prefix your messages. A strategy that aggregates store and chain calls into one `fault` relies on the SDK's `From for Fault` fold for `?`. - -### Embedder migration - -Hosts implementing capability traits now return the interface's typed error. Chain calls return `chain-error` (use the `rpc` case for a structured JSON-RPC error; otherwise a `fault`); the rest return `fault` directly. Map each backend failure to the right case: - -| Backend signal | `fault` case | -|---|---| -| Connection refused / DNS fail / offline | `unavailable` | -| Provider HTTP 4xx (other than 401/403/429) | `invalid-input` | -| Provider HTTP 401/403 | `denied` | -| Provider HTTP 429 | `rate-limited` | -| Provider HTTP 5xx / timeout | `unavailable` or `timeout` (prefer the more specific) | -| Structured JSON-RPC error (node `code`, revert `data`) | `chain-error.rpc` (not a fault) | -| User rejected signing in wallet UI | `denied` | -| Module asked for a capability the host doesn't provide | `unsupported` | -| Bug / panic / internal invariant violated | `internal` | - ---- - -## 3. Manifest changes [both] - -### File rename - -If any code, docs, or scripts reference `shepherd.toml`, change to `nexum.toml`. This was a doc/code inconsistency in 0.1; canonical is `nexum.toml`. - -### Field and section renames - -```diff - [module] - name = "twap-monitor" - version = "0.3.0" -- wasm = "sha256:9f86d081..." -+ component = "sha256:9f86d081..." - -- [[subscribe]] -- type = "block" -- chain_id = 42161 -+ [[subscription]] -+ kind = "block" -+ chain_id = 42161 -``` - -`type` → `kind` because `type` is reserved in several binding languages. - -`[module.resources]` (per-module resource caps) and a top-level `[chains]` table were dropped from this example rather than renamed: neither exists in 0.2's manifest schema - resource limits are global engine defaults today (`docs/02-modules-events-packaging.md`'s "Future direction" note), and chain ids are declared per-`[[subscription]]` (`chain_id`) rather than in a manifest-wide table. - -### Capability declaration (new, required) - -In 0.1 the world declared which interfaces a module imported, and instantiation failed if any were unsatisfied. In 0.2, imports declared `optional` in the manifest install a trap stub on the host side - calling them returns `fault.unsupported` rather than failing instantiation. - -```toml -[capabilities] -required = ["chain", "local-store", "logging"] -optional = ["messaging", "remote-store"] # module continues if host doesn't provide - -[capabilities.http] -allow = ["api.coingecko.com", "discord.com"] -``` - -If you omit `[capabilities]` entirely, 0.2 falls back to "all imports required" - same as 0.1 behaviour - and prints a deprecation warning at load. Add the section in your next module update; the implicit-all fallback will be removed in 0.3. - -### Config: unchanged in 0.2 - -`[config]` values continue to flow through to the guest as `list>` - the host flattens TOML scalars (numbers, booleans) to their string form on the way through, same as 0.1. If you currently parse `"50"` into `u64`, that code continues to work unchanged: - -```rust -let bps: u64 = config.iter() - .find(|(k, _)| k == "slippage_bps") - .map(|(_, v)| v.parse()) - .transpose()? - .unwrap_or(50); -``` - -**Deferred to 0.3.** A typed `config-value` variant (string / integer / boolean / list) and a `#[derive(NexumConfig)]` helper are on the 0.3 roadmap, bundled with the manifest-parser work (see §3) so the typing story lands as one coherent feature. - ---- - -## 4. New capabilities (additive) [author] - -These didn't exist in 0.1 and don't break anything. Adopt them to remove workarounds. - -> **Breaking if you tracked the 0.2 drafts.** Earlier 0.2 drafts published `nexum:host/clock` and `nexum:host/http` WIT interfaces. Both are gone from the package: a component importing either no longer links against the 0.2 world, and a manifest declaring `clock` under `[capabilities]` fails load with an unknown-capability error. Time is ambient `wasi:clocks` with no declaration, and outbound HTTP is a `wasi:http` import (the SDK's `http::fetch` wraps it) plus the `http` capability declaration with a `[capabilities.http].allow` list, as described below. Components built against the final 0.1 surface are unaffected beyond the renames in this guide. - -### Time (ambient wasi:clocks) - -Wall-clock and monotonic time are WASI concerns, not `nexum:host` interfaces: the host links `wasi:clocks` into every module store, so a module reads time through any wasi:clocks binding with no capability declaration. - -Replaces the 0.1 workaround of "only know the time inside `on_block` via `block.timestamp`." - -### Randomness (ambient wasi:random) - -Secure randomness is a WASI concern, not a `nexum:host` interface: the host links `wasi:random` into every module store, so a module draws CSPRNG bytes through any wasi:random binding with no capability declaration. - -Replaces the 0.1 workaround of "you can't, period." - -### `http` (allowlisted) - -Outbound HTTP is the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. The host links `wasi:http/{outgoing-handler, types}` into every module store; a module imports it with any wasi:http client binding and declares the `http` capability in its manifest. - -Requires a domain allowlist in `nexum.toml`: - -```toml -[capabilities] -optional = ["http"] - -[capabilities.http] -allow = ["api.coingecko.com", "*.discord.com"] -``` - -Hosts MUST enforce the allowlist on every outgoing request (exact host match or `*.domain` suffix, case-insensitive, ports ignored); off-list hosts fail with the wasi:http `HTTP-request-denied` error code. The host does not follow redirects, so each hop is a fresh request checked against the same list. The operator sees the union of granted domains at module load. This replaces the 0.1 anti-pattern of tunnelling alerts through Waku. - -The host also bounds every request: guest-set request-options timeouts are clamped to the engine's `[limits.http]` maxima (unset ones inherit them), the whole exchange runs under a total deadline, and a response body beyond the configured cap fails with `HTTP-response-body-size`. The knobs and defaults live in `engine.example.toml`. - -### `chain::request-batch` - -```wit -interface chain { - use types.{chain-id, fault}; - - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } - - /// A single JSON-RPC request to be executed as part of a batch. - record rpc-request { - method: string, - params: string, - } - - /// Result of a single request inside a batch. Each entry is independent; - /// one failing call does not abort the others. - variant rpc-result { - ok(string), - err(chain-error), - } - - request: func(chain-id: chain-id, method: string, params: string) - -> result; - - /// Hosts that cannot batch natively MUST fall back to sequential - /// `request` calls; the returned list is the same length as `requests` - /// and in the same order. - request-batch: func(chain-id: chain-id, requests: list) - -> result, chain-error>; -} -``` - -Additive. The alloy-backed `HostTransport` now routes `RequestPacket::Batch` through `request-batch` - your existing `provider.multicall(...).await` actually batches on the wire in 0.2 (it didn't in 0.1, despite the docs). - ---- - -## 5. New world: `query-module` (experimental) [author] - -A request/response world for modules that aren't event-driven (wallet rule evaluators, signature validators, pricing oracles). - -```wit -world query-module { - import local-store; - import logging; - // chain, identity, http, etc. are optional via manifest - - export init: func(config: config) -> result<_, fault>; - export evaluate: func(input: list) -> result, fault>; -} -``` - -**Status: WIT is published, no host implementation ships in 0.2.** The 0.2 server runtime only supports `event-module` and `shepherd`. The world is published so module authors can target it experimentally and so embedders building mobile/wallet hosts have a stable contract to implement against. Production support lands in 0.3. - -If you're writing a module that fits this shape, target it now and stub the host with `MockHost` for testing. - ---- - -## 6. Engine crate rename [embedder] - -```diff - [dependencies] -- nxm-engine = "0.1" -+ nexum-engine = "0.2" -``` - -The 0.1 release renamed `nexum-host` → `nxm-engine`. 0.2 reverses that to `nexum-engine` for consistency with `shepherd-sdk` / `shepherd-sdk-test` (and the future `nexum-sdk` / `cargo-nexum` direction described in doc 05). - -```diff -- use nxm_engine::{Engine, Module}; -+ use nexum_engine::{Engine, Module}; -``` - -The Rust API surface is otherwise unchanged in 0.2. The C ABI and `nexum-host` embedder facade (for non-Rust hosts) are explicitly **deferred to a later release** pending mobile validation; do not assume they exist in 0.2. - ---- - -## 7. SDK changes [author] - -### Rust SDK - -0.1 had no Rust SDK crate: modules called the wit-bindgen-generated -imports directly and hand-wrote the per-cdylib `Guest` / `export!` -glue. 0.2 ships `nexum-sdk` (host-neutral helpers) with -`shepherd-sdk` layering the CoW Protocol surface on top. What that -means for a module you are porting: - -- **Host traits.** Strategy code is written against the - `ChainHost` / `LocalStoreHost` / `LoggingHost` traits (plus - `CowApiHost` in `shepherd-sdk`) instead of the raw wit-bindgen - import shims; the `bind_host_via_wit_bindgen!` / - `bind_cow_host_via_wit_bindgen!` macros generate the adapter at - the cdylib boundary, and the `nexum-sdk-test` / - `shepherd-sdk-test` mocks slot in for native unit tests. -- **Typed errors.** Handlers and host traits use the `Fault` / - `ChainError` / `CowApiError` vocabulary from §2 in place of 0.1's - bare strings. -- **One attribute macro.** `#[nexum_sdk::module]` (a re-export of - `nexum_macros::module`) replaces the hand-written glue: applied to - an inherent `impl` block of named handlers (`init`, `on_block`, - `on_chain_logs`, `on_tick`, `on_message`), it emits the - `wit_bindgen::generate!` call, the host adapter, the `Guest` impl - dispatching to the handlers present, and `export!`. Handlers are - synchronous `fn`s; there is no `async` support, no `block_on`, and - no separate `#[shepherd::module]`. - -Earlier drafts of this section described a richer 0.2 surface -(`provider()`, `Signer`, `Messaging`, `RemoteStore`, a hidden -`HostTransport`); none of that shipped. See -[doc 05](../05-sdk-design.md) for the SDK that exists. - -### Non-Rust SDKs - -The WIT renames propagate mechanically through `wit-bindgen`. Regenerate your bindings against the 0.2 WIT and your existing call sites - adjusted for the renames in §1 - will type-check. - ---- - -## 8. Mechanical rename cheat sheet [both] - -For mechanical search/replace in your codebase. Apply in order; some replacements depend on earlier ones. - -```bash -# WIT package -rg -l 'web3:runtime' | xargs sed -i 's/web3:runtime/nexum:host/g' - -# Interface names (do these before function names - some functions reference the old interface in paths) -rg -l '\bcsn\b' | xargs sed -i 's/\bcsn\b/chain/g' -rg -l '\bmsg\b' | xargs sed -i 's/\bmsg\b/messaging/g' - -# Worlds -rg -l 'headless-module' | xargs sed -i 's/headless-module/event-module/g' -rg -l 'headless_module' | xargs sed -i 's/headless_module/event_module/g' - -# CoW interface stutter -rg -l '\bcow::cow::' | xargs sed -i 's/\bcow::cow::/cow_api::/g' -# (manual: merge `order` imports into `cow-api`; rename `order::submit` to `cow-api::submit-order`) - -# Feed methods -rg -l '\bfeed-get\b' | xargs sed -i 's/\bfeed-get\b/read-feed/g' -rg -l '\bfeed-set\b' | xargs sed -i 's/\bfeed-set\b/write-feed/g' -rg -l '\bfeed_get\b' | xargs sed -i 's/\bfeed_get\b/read_feed/g' -rg -l '\bfeed_set\b' | xargs sed -i 's/\bfeed_set\b/write_feed/g' - -# Type renames -rg -l '\bblock-data\b' | xargs sed -i 's/\bblock-data\b/block/g' -rg -l '\blog-entry\b' | xargs sed -i 's/\blog-entry\b/log/g' -rg -l '\bmessage-data\b' | xargs sed -i 's/\bmessage-data\b/message/g' -rg -l '\btx-hash\b' | xargs sed -i 's/\btx-hash\b/transaction-hash/g' -rg -l '\btx_hash\b' | xargs sed -i 's/\btx_hash\b/transaction_hash/g' - -# Crate rename (Cargo.toml + use statements) -rg -l '\bnxm-engine\b' | xargs sed -i 's/\bnxm-engine\b/nexum-engine/g' -rg -l '\bnxm_engine\b' | xargs sed -i 's/\bnxm_engine\b/nexum_engine/g' - -# Manifest section -rg -l '\[\[subscribe\]\]' | xargs sed -i 's/\[\[subscribe\]\]/[[subscription]]/g' - -# Manifest field -rg -l '^wasm = ' | xargs sed -i 's/^wasm = /component = /' -``` - -Things that **cannot** be sedded - do these by hand: - -- `timer(u64)` → `tick(tick)` with the new `tick { fired-at: u64 }` record. Call sites that pattern-match `Event::Timer(ts)` become `Event::Tick(tick) => tick.fired_at`. -- Error handling. The five old error types are gone; you can't mechanically rewrite a `match` against `JsonRpcError { code, .. }` into the new typed variants (`ChainError::Rpc`, the `fault` cases). Do these per-call-site. -- Splitting `cow` + `order` into a single `cow-api`. Rewrite the imports and adjust function paths. -- Adding `[capabilities]` to `nexum.toml`. Declare what your module actually uses; this is a meaningful audit. - ---- - -## 9. Verification checklist [both] - -After running the renames: - -- [ ] `cargo check --workspace --all-targets` is clean (Rust + bindings). -- [ ] `cargo check --target wasm32-wasip2 -p ` is clean. -- [ ] `cargo test --workspace --no-fail-fast` passes. -- [ ] Your bindgen invocations point at the package's own WIT dir (`wit/nexum-host/`) - or, when consuming both `nexum:host` and a domain-extension package, list both paths explicitly. The 0.1 vendored `deps/` pattern is no longer used in the reference repo. -- [ ] `nexum.toml` has a `[capabilities]` section listing what the module uses. -- [ ] `nexum.toml` references `component = "sha256:..."` not `wasm = ...`. -- [ ] All `[[subscribe]]` sections renamed to `[[subscription]]` with `kind` (not `type`). -- [ ] No remaining references to `web3:runtime`, `csn`, `msg`, `headless-module`, `nxm-engine`, `shepherd.toml`, `feed-get`/`feed-set`, `block-data`/`log-entry`/`message-data`, `tx-hash`. -- [ ] All `Result<_, String>` from module exports replaced with `Result<_, fault>`. -- [ ] Error matching code dispatches on the typed variant (`fault` cases, `ChainError::Rpc`), not protocol-specific error codes. -- [ ] If you used `chrono`/timestamp arithmetic, audited for the seconds-vs-ms change (0.2 is always ms UTC). -- [ ] If you used `provider.multicall(...).await`, confirmed it now actually batches on the wire (`chain::request-batch` shows in tracing). - -> **No `cargo nexum` toolchain in 0.2.** A `cargo-nexum` cargo subcommand (with `new`, `check`, `package`, `run --mock`, `migrate`) is on the 0.3 roadmap. Until then, use `cargo` directly and the `just` recipes in the reference repo. - ---- - -## 10. Deprecation policy going forward [both] - -0.2 is the breaking-change window. The contracts below are stable starting at 0.2.0: - -- WIT package name `nexum:host` and interface names within it. -- The per-interface typed errors over the shared `fault` vocabulary. -- The `nexum.toml` manifest schema. -- The `#[nexum::module]` macro surface. - -Additive changes (new interfaces, new manifest fields, new SDK helpers) may land in any 0.2.x release. Existing identifiers will not be removed or repurposed before 1.0 without a deprecation cycle of at least one minor release. - -The mobile/wallet host story (`query-module` production support, C ABI, `nexum-host` embedder crate) is on the 0.3 roadmap, conditional on a named design partner. The 0.2 `query-module` WIT is an experimental option, not a stable contract; expect changes to its error variants and request/response payload conventions before the 0.3 host ships. - ---- - -## 11. Getting help - -- Open an issue at the repo with the `migration-0.2` label. -- The full 0.2 WIT lives in `wit/nexum-host/` (formerly `wit/web3-runtime/`). -- The §8 cheat sheet has the mechanical sed commands; a `cargo nexum migrate --from 0.1` codemod that wraps them safely is planned for 0.3 alongside the rest of the `cargo-nexum` toolchain. diff --git a/docs/operations/m3-edge-case-validation.md b/docs/operations/m3-edge-case-validation.md index c31cda38..62b681ab 100644 --- a/docs/operations/m3-edge-case-validation.md +++ b/docs/operations/m3-edge-case-validation.md @@ -79,7 +79,7 @@ Error: load module target/wasm32-wasip2/release/stop_loss.wasm Caused by: 0: capability violation in target/wasm32-wasip2/release/stop_loss.wasm - 1: component imports `cow-api` (shepherd:cow/cow-api@0.2.0) but it + 1: component imports `cow-api` (shepherd:cow/cow-api@0.1.0) but it is not listed in [capabilities].required or [capabilities].optional ``` diff --git a/docs/sdk.md b/docs/sdk.md index 14d21da7..b27045af 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -42,11 +42,11 @@ macros generate that adapter). The traits in | Trait | Mirrors | What it does | |---|---|---| -| `ChainHost` | `nexum:host/chain@0.2.0` | JSON-RPC dispatch (`eth_call`, `eth_getLogs`, …) | -| `LocalStoreHost` | `nexum:host/local-store@0.2.0` | Per-module key-value store | -| `LoggingHost` | `nexum:host/logging@0.2.0` | Structured log lines tagged by module | +| `ChainHost` | `nexum:host/chain@0.1.0` | JSON-RPC dispatch (`eth_call`, `eth_getLogs`, …) | +| `LocalStoreHost` | `nexum:host/local-store@0.1.0` | Per-module key-value store | +| `LoggingHost` | `nexum:host/logging@0.1.0` | Structured log lines tagged by module | | `Host` | supertrait | Bundles the three core traits; blanket impl | -| `CowApiHost` (shepherd-sdk) | `shepherd:cow/cow-api@0.2.0` | Orderbook submission (`POST /api/v1/orders`) | +| `CowApiHost` (shepherd-sdk) | `shepherd:cow/cow-api@0.1.0` | Orderbook submission (`POST /api/v1/orders`) | | `CowHost` (shepherd-sdk) | supertrait | `Host` + `CowApiHost` for orderbook strategies | A module declaring `[capabilities].required = ["chain", "local-store", diff --git a/wit/nexum-host/chain.wit b/wit/nexum-host/chain.wit index 97055e5a..1d812dc6 100644 --- a/wit/nexum-host/chain.wit +++ b/wit/nexum-host/chain.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface chain { use types.{chain-id, fault}; diff --git a/wit/nexum-host/event-module.wit b/wit/nexum-host/event-module.wit index db8d7f5a..277134a9 100644 --- a/wit/nexum-host/event-module.wit +++ b/wit/nexum-host/event-module.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Event-driven module — automation, background processing. /// No UI capabilities. Runs on any conforming host. diff --git a/wit/nexum-host/identity.wit b/wit/nexum-host/identity.wit index 09dac970..2e82c9fe 100644 --- a/wit/nexum-host/identity.wit +++ b/wit/nexum-host/identity.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Identity / signing capability. /// diff --git a/wit/nexum-host/local-store.wit b/wit/nexum-host/local-store.wit index 6c5a22b3..d0b54921 100644 --- a/wit/nexum-host/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface local-store { use types.{fault}; diff --git a/wit/nexum-host/logging.wit b/wit/nexum-host/logging.wit index 37e9193f..8cd98140 100644 --- a/wit/nexum-host/logging.wit +++ b/wit/nexum-host/logging.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface logging { enum level { diff --git a/wit/nexum-host/messaging.wit b/wit/nexum-host/messaging.wit index 7f8e4bf6..30777ca7 100644 --- a/wit/nexum-host/messaging.wit +++ b/wit/nexum-host/messaging.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface messaging { use types.{fault, message}; diff --git a/wit/nexum-host/query-module.wit b/wit/nexum-host/query-module.wit index 7dd52600..ffb29b87 100644 --- a/wit/nexum-host/query-module.wit +++ b/wit/nexum-host/query-module.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Query module — synchronous, side-effect-free evaluation. /// diff --git a/wit/nexum-host/remote-store.wit b/wit/nexum-host/remote-store.wit index 731ae370..39b36bae 100644 --- a/wit/nexum-host/remote-store.wit +++ b/wit/nexum-host/remote-store.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface remote-store { use types.{fault}; diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index b4349717..8e834263 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Common types shared across all runtime interfaces. /// diff --git a/wit/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit index 5c7e379d..4d0df3ae 100644 --- a/wit/shepherd-cow/cow-api.wit +++ b/wit/shepherd-cow/cow-api.wit @@ -1,7 +1,7 @@ -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { - use nexum:host/types@0.2.0.{chain-id, fault}; + use nexum:host/types@0.1.0.{chain-id, fault}; /// A non-2xx reply from the orderbook that carries no typed /// rejection envelope. `body` is the raw response text, foreign diff --git a/wit/shepherd-cow/cow-ext.wit b/wit/shepherd-cow/cow-ext.wit index ed71da3b..d78889c8 100644 --- a/wit/shepherd-cow/cow-ext.wit +++ b/wit/shepherd-cow/cow-ext.wit @@ -1,4 +1,4 @@ -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; /// Extension world: the cow-api interface alone, wired into a module /// linker by the cow extension. Kept separate from `shepherd` so the diff --git a/wit/shepherd-cow/shepherd.wit b/wit/shepherd-cow/shepherd.wit index 88aff143..729bcd0f 100644 --- a/wit/shepherd-cow/shepherd.wit +++ b/wit/shepherd-cow/shepherd.wit @@ -1,7 +1,7 @@ -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; /// Shepherd module — event-driven Nexum module with CoW Protocol extensions. world shepherd { - include nexum:host/event-module@0.2.0; + include nexum:host/event-module@0.1.0; import cow-api; } diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 6516586c..695da46a 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -28,10 +28,10 @@ interface adapter { /// structurally cannot touch host key material or persistent state. Outbound /// HTTP is wasi:http, linked separately and allowlisted per adapter. world venue-adapter { - use nexum:host/types@0.2.0.{config, fault}; + use nexum:host/types@0.1.0.{config, fault}; - import nexum:host/chain@0.2.0; - import nexum:host/messaging@0.2.0; + import nexum:host/chain@0.1.0; + import nexum:host/messaging@0.1.0; /// Configure the adapter from its `[config]` before any submission. export init: func(config: config) -> result<_, fault>; From 81e7a96ecbddcb8d95eef7be339c5e5aad5a153d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 15:50:26 +0000 Subject: [PATCH 04/53] wit: add quote to the videre venue faces and the client typestate --- crates/cow-venue/src/client.rs | 8 ++ crates/nexum-macros/src/lib.rs | 21 ++- crates/nexum-runtime/src/bindings.rs | 23 ++- .../src/host/impls/venue_client.rs | 8 +- .../nexum-runtime/src/host/venue_registry.rs | 135 +++++++++++++++++- crates/nexum-runtime/src/supervisor/tests.rs | 36 ++++- crates/nexum-venue-sdk/src/adapter.rs | 14 +- crates/nexum-venue-sdk/src/client.rs | 47 +++++- crates/nexum-venue-sdk/src/lib.rs | 12 +- crates/nexum-venue-sdk/tests/adapter.rs | 47 +++++- crates/nexum-venue-test/tests/conformance.rs | 16 ++- modules/examples/echo-client/src/lib.rs | 20 ++- modules/examples/echo-venue/src/lib.rs | 29 +++- wit/videre-venue/venue.wit | 6 +- 14 files changed, 384 insertions(+), 38 deletions(-) diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 174bb4e8..7ac5c610 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -73,6 +73,14 @@ mod tests { } impl VenueClient for SpyClient { + fn quote( + &self, + _venue: &str, + _body: Vec, + ) -> Result { + unreachable!("quote not exercised") + } + fn submit(&self, venue: &str, body: Vec) -> Result { self.submitted .borrow_mut() diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index d9566925..328f3090 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -272,15 +272,15 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { } /// The associated functions the `videre:venue/adapter` face mandates. A -/// venue adapter must define all four; `init` is separate (a no-op when +/// venue adapter must define all five; `init` is separate (a no-op when /// absent, exactly as in a module). -const VENUE_EXPORTS: [&str; 4] = ["derive_header", "submit", "status", "cancel"]; +const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", "cancel"]; /// Generate the per-cdylib glue for a venue adapter. /// /// Apply to an inherent `impl` block whose associated functions are the -/// adapter face: `derive_header`, `submit`, `status`, `cancel` (all -/// required, from `videre:venue/adapter`), plus an optional `init` +/// adapter face: `derive_header`, `quote`, `submit`, `status`, `cancel` +/// (all required, from `videre:venue/adapter`), plus an optional `init` /// (absent means a no-op). Each takes and returns the per-cdylib /// wit-bindgen payloads for its signature. The macro reads the crate's /// `module.toml`, synthesizes a per-component world exporting the @@ -355,8 +355,8 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { self_ty, format!( "#[nexum_venue_sdk::venue] requires the adapter face; this impl is missing {:?}. \ - Define all of `derive_header`, `submit`, `status`, `cancel` (plus an optional \ - `init`)", + Define all of `derive_header`, `quote`, `submit`, `status`, `cancel` (plus an \ + optional `init`)", missing ), ) @@ -433,6 +433,15 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { <#self_ty>::derive_header(body) } + fn quote( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + videre::types::types::Quotation, + videre::types::types::VenueError, + > { + <#self_ty>::quote(body) + } + fn submit( body: ::std::vec::Vec, ) -> ::core::result::Result< diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index f587536a..022fcdf1 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -96,8 +96,8 @@ pub use nexum::host::types::IntentStatusUpdate; /// The shared intent ontology, re-exported at the plain spellings the /// registry and the `client::Host` impl name. pub use venue_adapter::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, - VenueError, + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, }; /// The value-flow vocabulary the header is expressed in. pub use venue_adapter::videre::value_flow::types as value_flow; @@ -143,7 +143,7 @@ mod value_flow_smoke { /// client interface and, transitively, the types interface and its /// value-flow dependency. The test names every generated type, case, and /// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the three function signatures, so a keyword collision or an accidental +/// the four function signatures, so a keyword collision or an accidental /// signature change fails this build rather than a downstream binding. #[cfg(test)] mod client_smoke { @@ -163,14 +163,18 @@ mod client_smoke { }); use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, - VenueError, + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, }; use videre::value_flow::types::{Asset, AssetAmount}; struct DummyClient; impl videre::venue::client::Host for DummyClient { + fn quote(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + fn submit(&mut self, _venue: String, _body: Vec) -> Result { Err(VenueError::UnknownVenue) } @@ -225,6 +229,14 @@ mod client_smoke { let _ = SubmitOutcome::Accepted(Vec::new()); let _ = SubmitOutcome::RequiresSigning(tx); + let quotation = Quotation { + gives: amount(vec![1]), + wants: amount(Vec::new()), + fee: amount(Vec::new()), + valid_until_ms: 0, + }; + assert!(quotation.fee.amount.is_empty()); + let _ = VenueError::UnknownVenue; let _ = VenueError::InvalidBody(String::new()); let _ = VenueError::Unsupported; @@ -236,6 +248,7 @@ mod client_smoke { let _ = VenueError::Timeout; let mut client = DummyClient; + assert!(client.quote(String::new(), Vec::new()).is_err()); assert!(client.submit(String::new(), Vec::new()).is_err()); assert!(client.status(String::new(), Vec::new()).is_err()); assert!(client.cancel(String::new(), Vec::new()).is_err()); diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/nexum-runtime/src/host/impls/venue_client.rs index 26e76f59..fddc8cfb 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/nexum-runtime/src/host/impls/venue_client.rs @@ -6,12 +6,18 @@ //! store's module namespace. use crate::bindings::client::Host; -use crate::bindings::{IntentStatus, SubmitOutcome, VenueError}; +use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; use crate::host::venue_registry::VenueId; impl Host for HostState { + async fn quote(&mut self, venue: String, body: Vec) -> Result { + self.venue_registry + .quote(&self.run.module, &VenueId::from(venue), body) + .await + } + async fn submit(&mut self, venue: String, body: Vec) -> Result { self.venue_registry .submit(&self.run.module, &VenueId::from(venue), body) diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index dd4637b7..9cb303c0 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -17,7 +17,7 @@ //! //! Fuel cannot cross stores, so a module that spams undecodable bodies would //! otherwise burn an adapter's budget for free. Two mechanisms close that: -//! a per-caller submission quota gates every submit before the adapter is +//! a per-caller quota gates every quote and submit before the adapter is //! touched, and a decode failure (the adapter's `invalid-body`) is charged //! to the calling module's quota, so a caller feeding garbage exhausts its //! own budget rather than the adapter's. @@ -34,8 +34,8 @@ use tracing::warn; use wasmtime::Store; use crate::bindings::{ - IntentHeader, IntentStatus, IntentStatusUpdate, RateLimit, SubmitOutcome, VenueAdapter, - VenueError, + IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, + VenueAdapter, VenueError, }; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; @@ -155,6 +155,9 @@ pub trait VenueInvoker: Send { body: &'a [u8], ) -> BoxFuture<'a, Result>; + /// Price the opaque body at this adapter's venue. + fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result>; + /// Submit the opaque body to this adapter's venue. fn submit<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result>; @@ -223,6 +226,21 @@ impl VenueInvoker for VenueActor { }) } + fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .videre_venue_adapter() + .call_quote(&mut self.store, body) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + fn submit<'a>( &'a mut self, body: &'a [u8], @@ -433,6 +451,28 @@ impl VenueRegistry { Ok(outcome) } + /// Price an opaque body at `venue` on behalf of `caller`. Not a + /// submission, so the header and guard are skipped (a quotation moves + /// no value), but it is adapter work on a caller-supplied body: the + /// caller's quota gates it and every quote spends one unit, so a + /// quote spammer exhausts its own budget, not the adapter's. + pub async fn quote( + &self, + caller: &str, + venue: &VenueId, + body: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + if !self.quota_admits(caller) { + return Err(VenueError::RateLimited(RateLimit { + retry_after_ms: Some(window_ms(self.inner.quota.window)), + })); + } + self.charge(caller); + let mut adapter = slot.lock().await; + adapter.quote(&body).await + } + /// Put a `(venue, receipt)` pair under status watch. Idempotent: a /// re-submitted receipt keeps its existing watch entry. fn watch(&self, venue: &VenueId, receipt: Vec) { @@ -698,6 +738,7 @@ mod tests { #[derive(Default)] struct StubCalls { derive: AtomicUsize, + quote: AtomicUsize, submit: AtomicUsize, status: AtomicUsize, cancel: AtomicUsize, @@ -766,6 +807,17 @@ mod tests { }) } + fn quote<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.quote.fetch_add(1, Ordering::SeqCst); + self.enter().await; + Ok(quotation()) + }) + } + fn submit<'a>( &'a mut self, _body: &'a [u8], @@ -802,6 +854,24 @@ mod tests { } } + fn quotation() -> Quotation { + Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: vec![1], + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + fee: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: 1_700_000_000_000, + } + } + fn header() -> IntentHeader { IntentHeader { gives: AssetAmount { @@ -889,6 +959,65 @@ mod tests { assert_eq!(calls.submit.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn quote_reaches_the_adapter_without_header_or_guard() { + let calls = Arc::new(StubCalls::default()); + // A denying guard proves quotes skip the seam: no value moves. + let registry = registry_with( + SubmitQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let quoted = registry + .quote("mod-a", &cow(), b"body".to_vec()) + .await + .expect("quote succeeds"); + + assert_eq!(quoted, quotation()); + assert_eq!(calls.quote.load(Ordering::SeqCst), 1); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn quote_spends_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(registry.quote("mod-a", &cow(), b"b".to_vec()).await.is_ok()); + // The quote spent the only unit: both a further quote and a + // submit are stopped at the gate. + assert!(matches!( + registry.quote("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.quote.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn quote_to_an_unknown_venue_is_rejected() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + + assert!(matches!( + registry + .quote("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + assert_eq!(calls.quote.load(Ordering::SeqCst), 0); + } + #[tokio::test] async fn submission_quota_rate_limits_once_the_budget_is_spent() { let calls = Arc::new(StubCalls::default()); diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index c71f43db..0de3d908 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -581,6 +581,32 @@ impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { }) } + fn quote<'a>( + &'a mut self, + _body: &'a [u8], + ) -> futures::future::BoxFuture< + 'a, + Result, + > { + Box::pin(async move { + Ok(crate::bindings::Quotation { + gives: crate::bindings::value_flow::AssetAmount { + asset: crate::bindings::value_flow::Asset::Native, + amount: vec![1], + }, + wants: crate::bindings::value_flow::AssetAmount { + asset: crate::bindings::value_flow::Asset::Native, + amount: Vec::new(), + }, + fee: crate::bindings::value_flow::AssetAmount { + asset: crate::bindings::value_flow::Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: 0, + }) + }) + } + fn submit<'a>( &'a mut self, _body: &'a [u8], @@ -892,12 +918,18 @@ async fn e2e_echo_module_registry_adapter_round_trip() { ); assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - // The module observably completed the round trip: it submitted, and it - // received the settled status from the echo venue. + // The module observably completed the round trip: it quoted, it + // submitted, and it received the settled status from the echo venue. let runs = logs.list_runs("echo-client"); assert_eq!(runs.len(), 1, "one run recorded for echo-client"); let page = logs.read(&runs[0].run, 0); let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("quoted") && m.contains("echo-venue")), + "module quoted through the client face; records were: {messages:?}", + ); assert!( messages .iter() diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index f711ea96..744c77e7 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -2,12 +2,12 @@ //! it into the component's `venue-adapter` world surface. //! //! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the four intent functions from `videre:venue/adapter`. +//! world itself, the five intent functions from `videre:venue/adapter`. //! Functions are associated (no `self`): the component model instantiates //! one adapter per venue and calls exports statically, so adapter state //! lives in the adapter's own statics, exactly as in event modules. -use crate::{Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; +use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; /// One venue's protocol speaker: the guest-side face of the /// `venue-adapter` world. Implement it on a unit struct and hand that to @@ -27,6 +27,10 @@ pub trait VenueAdapter { /// submit. fn derive_header(body: Vec) -> Result; + /// Price an opaque intent body: an indicative quotation, not an + /// offer the venue is bound to fill. + fn quote(body: Vec) -> Result; + /// Submit an opaque intent body to this adapter's venue. Success is /// either the venue's receipt or `requires-signing`: a transaction /// the host must sign and send before the intent exists. @@ -71,6 +75,12 @@ macro_rules! export_venue_adapter { <$adapter as $crate::VenueAdapter>::derive_header(body) } + fn quote( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::Quotation, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::quote(body) + } + fn submit( body: ::std::vec::Vec, ) -> ::core::result::Result<$crate::SubmitOutcome, $crate::VenueError> { diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/nexum-venue-sdk/src/client.rs index 5048eda1..aa92da34 100644 --- a/crates/nexum-venue-sdk/src/client.rs +++ b/crates/nexum-venue-sdk/src/client.rs @@ -12,11 +12,14 @@ use strum::IntoStaticStr; -use crate::{BodyError, IntentBody, IntentStatus, SubmitOutcome, VenueError}; +use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueError}; /// Byte-level access to the keeper-facing `videre:venue/client` /// interface, venue named per call as on the wire. pub trait VenueClient { + /// Price an opaque intent body at the named venue. + fn quote(&self, venue: &str, body: Vec) -> Result; + /// Submit an opaque intent body to the named venue. fn submit(&self, venue: &str, body: Vec) -> Result; @@ -51,6 +54,22 @@ impl IntentClient

{ &self.venue } + /// Encode a typed body and price it at the bound venue. The returned + /// [`Quoted`] carries the encoded bytes, so `submit` sends exactly + /// the body the venue priced. + pub fn quote(&self, body: &B) -> Result, ClientError> { + let bytes = body.to_bytes()?; + let quotation = self + .venues + .quote(&self.venue, bytes.clone()) + .map_err(ClientError::Venue)?; + Ok(Quoted { + client: self, + bytes, + quotation, + }) + } + /// Encode a typed body and submit it to the bound venue. pub fn submit(&self, body: &B) -> Result { let bytes = body.to_bytes()?; @@ -74,6 +93,32 @@ impl IntentClient

{ } } +/// A priced intent: the quotation plus the exact bytes it prices, bound +/// to the client that fetched it. Consuming it with [`submit`](Self::submit) +/// is the only way from a quote to a submission, so a keeper cannot +/// submit a body other than the one quoted. +#[derive(Debug)] +pub struct Quoted<'a, P> { + client: &'a IntentClient

, + bytes: Vec, + quotation: Quotation, +} + +impl Quoted<'_, P> { + /// The venue's indicative quotation for the body. + pub fn quotation(&self) -> &Quotation { + &self.quotation + } + + /// Submit the quoted body to the venue that priced it. + pub fn submit(self) -> Result { + self.client + .venues + .submit(&self.client.venue, self.bytes) + .map_err(ClientError::Venue) + } +} + /// Why a typed intent call failed: before the wire (the body failed to /// encode) or beyond it (the registry or venue refused). /// diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs index 1c0559e3..5159ef05 100644 --- a/crates/nexum-venue-sdk/src/lib.rs +++ b/crates/nexum-venue-sdk/src/lib.rs @@ -8,7 +8,7 @@ //! ## What lives here //! //! - [`VenueAdapter`] - the trait mirroring the world's export face -//! (`init` plus the four intent functions), and +//! (`init` plus the five intent functions), and //! [`export_venue_adapter!`] which turns an impl into the component's //! export glue. //! @@ -57,14 +57,14 @@ pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, VenueClient}; +pub use client::{ClientError, IntentClient, Quoted, VenueClient}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`nexum_macros::IntentBody`]. pub use nexum_macros::IntentBody; /// Emit the per-cdylib export glue and per-component world for a venue /// adapter. Apply to an inherent `impl` of the adapter face -/// (`derive_header`, `submit`, `status`, `cancel`, plus an optional -/// `init`); the built component imports exactly the manifest's declared +/// (`derive_header`, `quote`, `submit`, `status`, `cancel`, plus an +/// optional `init`); the built component imports exactly the manifest's declared /// scoped transport. See [`nexum_macros::venue`]. /// /// The self-contained per-cdylib alternative to @@ -77,8 +77,8 @@ pub use nexum_macros::venue; /// The intent ontology at its plain spellings: the types the /// [`VenueAdapter`] face and the client core speak. pub use bindings::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, - VenueError, + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, }; /// The value-flow vocabulary intent headers are expressed in. pub use bindings::videre::value_flow::types as value_flow; diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/nexum-venue-sdk/tests/adapter.rs index 699915f0..21ab472a 100644 --- a/crates/nexum-venue-sdk/tests/adapter.rs +++ b/crates/nexum-venue-sdk/tests/adapter.rs @@ -9,7 +9,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, - IntentStatus, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, + IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, }; /// First published body version: a fixed-price quote. @@ -75,6 +75,23 @@ impl VenueAdapter for DemoAdapter { }) } + fn quote(body: Vec) -> Result { + let (amount_wei, valid_until_ms) = Self::decode(&body)?; + let zero = AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }; + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: amount_wei.to_be_bytes().to_vec(), + }, + wants: zero.clone(), + fee: zero, + valid_until_ms: valid_until_ms.unwrap_or(u64::MAX), + }) + } + fn submit(body: Vec) -> Result { Self::decode(&body)?; Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) @@ -106,6 +123,13 @@ nexum_venue_sdk::export_venue_adapter!(DemoAdapter); struct InProcessClient; impl VenueClient for InProcessClient { + fn quote(&self, venue: &str, body: Vec) -> Result { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::quote(body) + } + fn submit(&self, venue: &str, body: Vec) -> Result { if venue != "demo" { return Err(VenueError::UnknownVenue); @@ -234,6 +258,27 @@ fn typed_client_round_trips_through_the_client_seam() { )); } +#[test] +fn quote_typestate_prices_then_submits_the_quoted_body() { + fn drive(client: &IntentClient) -> Result { + // The typestate chain under test: a quotation is the only path + // from a priced body to its submission. + client.quote(&v2_body())?.submit() + } + + let client = IntentClient::new(InProcessClient, "demo"); + + let quoted = client.quote(&v2_body()).unwrap(); + assert_eq!( + quoted.quotation().gives.amount, + 1_000_000u64.to_be_bytes().to_vec() + ); + assert_eq!(quoted.quotation().valid_until_ms, 1_700_000_000_000); + + let outcome = drive(&client).unwrap(); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == RECEIPT.to_vec())); +} + #[test] fn unbound_venue_is_unknown_at_the_client() { let client = IntentClient::new(InProcessClient, "nowhere"); diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs index d6c37040..8f0a6b3f 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -5,7 +5,8 @@ use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ - AuthScheme, Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, + AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, + VenueError, }; use nexum_venue_test::reference::{ CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, @@ -26,6 +27,19 @@ impl VenueAdapter for ReferenceAdapter { derive_reference_header(body) } + fn quote(body: Vec) -> Result { + let header = derive_reference_header(body)?; + Ok(Quotation { + gives: header.gives, + wants: header.wants, + fee: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: u64::MAX, + }) + } + fn submit(body: Vec) -> Result { Ok(SubmitOutcome::Accepted(body)) } diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 6a769517..185e22da 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -1,8 +1,8 @@ //! # echo-client (reference Shepherd intent module) //! -//! The keeper half of the echo pair. On every chain-1 block it submits an -//! opaque body through `videre:venue/client` to the `echo-venue` adapter and -//! logs the receipt, and it logs each `intent-status` transition the +//! The keeper half of the echo pair. On every chain-1 block it quotes and +//! submits an opaque body through `videre:venue/client` to the `echo-venue` +//! adapter and logs the receipt, and it logs each `intent-status` transition the //! registry fans back from that venue. Paired with the echo-venue adapter it //! is the smallest end-to-end demonstration of the intent core: module -> //! host registry -> venue adapter, and the status event back. @@ -33,6 +33,20 @@ impl EchoClient { // receipt, so the body content is immaterial; the block number keeps // it non-empty and legible in the logs. let body = block.number.to_be_bytes().to_vec(); + match client::quote(ECHO_VENUE, &body) { + Ok(quotation) => logging::log( + logging::Level::Info, + &format!( + "quoted {} bytes at {ECHO_VENUE}: gives {} amount bytes", + body.len(), + quotation.gives.amount.len(), + ), + ), + Err(_) => logging::log( + logging::Level::Warn, + &format!("quote at {ECHO_VENUE} was refused"), + ), + } match client::submit(ECHO_VENUE, &body) { Ok(SubmitOutcome::Accepted(receipt)) => logging::log( logging::Level::Info, diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index d99726ad..c3e675c7 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -20,7 +20,7 @@ use nexum::host::chain; use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Settlement, SubmitOutcome, VenueError, + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, }; use videre::value_flow::types::{Asset, AssetAmount}; @@ -42,15 +42,26 @@ impl EchoVenue { asset: Asset::Native, amount: minimal_be(body.len() as u64), }, - wants: AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, + wants: zero_native(), settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip1271, }) } + fn quote(body: Vec) -> Result { + // Echo pricing mirrors the header: gives the body length, wants + // nothing, charges no fee, and the quote never expires. + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(body.len() as u64), + }, + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, + }) + } + fn submit(body: Vec) -> Result { // Reading chain state on submit is what justifies the declared // `chain` capability; the block height is discarded, the point is @@ -71,6 +82,14 @@ impl EchoVenue { } } +/// A zero native amount: the venue's spelling of "nothing". +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + } +} + /// Big-endian bytes with leading zeros trimmed: the minimal `uint` /// spelling, where an empty list is zero. fn minimal_be(value: u64) -> Vec { diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 695da46a..1f01b9cc 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -3,8 +3,9 @@ package videre:venue@0.1.0; /// Worker (keeper) face. The host holds the venue registry; the keeper names /// a venue by string. interface client { - use videre:types/types@0.1.0.{receipt, intent-status, submit-outcome, venue-error}; + use videre:types/types@0.1.0.{quotation, receipt, intent-status, submit-outcome, venue-error}; + quote: func(venue: string, body: list) -> result; submit: func(venue: string, body: list) -> result; status: func(venue: string, receipt: receipt) -> result; cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; @@ -14,10 +15,11 @@ interface client { /// installed adapter answers for exactly one venue, so the registry resolves /// a venue id to its adapter and calls it directly. interface adapter { - use videre:types/types@0.1.0.{intent-header, receipt, intent-status, submit-outcome, venue-error}; + use videre:types/types@0.1.0.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; /// Pure: derive the guard-facing header from a body. No I/O. derive-header: func(body: list) -> result; + quote: func(body: list) -> result; submit: func(body: list) -> result; status: func(receipt: receipt) -> result; cancel: func(receipt: receipt) -> result<_, venue-error>; From 8c879b4e0f2ab2c60d7e37a0b9957c2ae7d763d2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 16:12:08 +0000 Subject: [PATCH 05/53] ci: add the advisory venue-agnostic check for nexum-runtime A local command (scripts/check-venue-agnostic.sh, just check-venue-agnostic) and a non-blocking CI job assert nexum-runtime is venue-agnostic: its crate graph reaches no videre/intent/venue/cow crate, its sources carry no venue symbol, and nexum:host resolves as a leaf WIT package. The symbol scan is red by design until the physical host cut lands; the flip to a blocking gate is tracked for M2. --- .github/workflows/ci.yml | 16 ++++++++++ justfile | 5 +++ scripts/check-venue-agnostic.sh | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100755 scripts/check-venue-agnostic.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9246fdc..186bce4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,3 +143,19 @@ jobs: CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu + + # Advisory guard that nexum-runtime stays venue-agnostic: crate graph, symbol + # scan, and nexum:host WIT leaf-ness (scripts/check-venue-agnostic.sh). + # `continue-on-error` keeps this a signal, not a gate, until the physical + # host cut lands; the flip to a blocking gate is tracked for M2. + venue-agnostic: + name: venue-agnostic (advisory) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/rust-setup + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: wasm-tools,ripgrep + - run: ./scripts/check-venue-agnostic.sh diff --git a/justfile b/justfile index a89e8061..964bba79 100644 --- a/justfile +++ b/justfile @@ -71,6 +71,11 @@ build-e2e: build-m2 build-m3 run-e2e: build-e2e build-engine cargo run -p nexum-cli -- --engine-config engine.e2e.toml +# Assert nexum-runtime is venue-agnostic: crate graph, symbol scan, and +# the nexum:host WIT leaf. Advisory in CI until the physical cut lands. +check-venue-agnostic: + ./scripts/check-venue-agnostic.sh + # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh new file mode 100755 index 00000000..37c96ca8 --- /dev/null +++ b/scripts/check-venue-agnostic.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Venue-agnosticism check for nexum-runtime: the crate graph reaches no +# videre/intent/venue/cow crate, the sources carry no venue symbol, and +# nexum:host resolves as a leaf WIT package. Advisory in CI until the +# physical cut lands; run locally via `just check-venue-agnostic`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[l1 PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[l1 FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +# 1. Crate graph: nothing venue-shaped reachable from nexum-runtime +# (normal + build edges; dev-deps stay local to the crate). +reached="$(cargo tree -p nexum-runtime -e normal,build --prefix none --locked | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" +if [[ -n $reached ]]; then + fail "crate graph reaches: $(tr '\n' ' ' <<<"$reached")" +else + pass "crate graph clean" +fi + +# 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes +# skip std::borrow::Cow, ProviderError, and "intentional". +symbols='\b[Vv]idere|\b[Ii]ntent([_A-Z-]|s?\b)|\b[Vv]enue|\bcow|CoW|\bCow[A-Z]' +if rg -n --no-heading -e "$symbols" crates/nexum-runtime; then + fail "venue symbols leak into nexum-runtime" +else + pass "symbol scan empty" +fi + +# 3. WIT DAG: nexum:host is a leaf. No cross-package use/import, and the +# package resolves standalone. +if rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host; then + fail "nexum:host references another WIT package" +else + pass "nexum:host has no cross-package reference" +fi +if command -v wasm-tools >/dev/null; then + if wasm-tools component wit wit/nexum-host >/dev/null; then + pass "nexum:host resolves standalone" + else + fail "nexum:host does not resolve standalone" + fi +else + printf '\033[1;33m[l1 WARN]\033[0m wasm-tools not found; WIT resolve skipped\n' >&2 +fi + +exit "$status" From c7f8ab2f59d3091f41447d92a527bc8455f7d70f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 16:25:23 +0000 Subject: [PATCH 06/53] ci: fail the crate-graph check when cargo tree errors --- scripts/check-venue-agnostic.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index 37c96ca8..eb66a88d 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -18,12 +18,16 @@ status=0 # 1. Crate graph: nothing venue-shaped reachable from nexum-runtime # (normal + build edges; dev-deps stay local to the crate). -reached="$(cargo tree -p nexum-runtime -e normal,build --prefix none --locked | - awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" -if [[ -n $reached ]]; then - fail "crate graph reaches: $(tr '\n' ' ' <<<"$reached")" +if tree="$(cargo tree -p nexum-runtime -e normal,build --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "crate graph clean" + fi else - pass "crate graph clean" + fail "cargo tree failed" fi # 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes From 91d673e611ea21266504c0fad7d145cd7f4a7091 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 16:35:06 +0000 Subject: [PATCH 07/53] ci: fail the scan steps when rg errors instead of finding nothing --- scripts/check-venue-agnostic.sh | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index eb66a88d..e53e40c3 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -33,19 +33,21 @@ fi # 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes # skip std::borrow::Cow, ProviderError, and "intentional". symbols='\b[Vv]idere|\b[Ii]ntent([_A-Z-]|s?\b)|\b[Vv]enue|\bcow|CoW|\bCow[A-Z]' -if rg -n --no-heading -e "$symbols" crates/nexum-runtime; then - fail "venue symbols leak into nexum-runtime" -else - pass "symbol scan empty" -fi +rg -n --no-heading -e "$symbols" crates/nexum-runtime +case $? in + 0) fail "venue symbols leak into nexum-runtime" ;; + 1) pass "symbol scan empty" ;; + *) fail "symbol scan errored (crates/nexum-runtime missing?)" ;; +esac # 3. WIT DAG: nexum:host is a leaf. No cross-package use/import, and the # package resolves standalone. -if rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host; then - fail "nexum:host references another WIT package" -else - pass "nexum:host has no cross-package reference" -fi +rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host +case $? in + 0) fail "nexum:host references another WIT package" ;; + 1) pass "nexum:host has no cross-package reference" ;; + *) fail "WIT scan errored (wit/nexum-host missing?)" ;; +esac if command -v wasm-tools >/dev/null; then if wasm-tools component wit wit/nexum-host >/dev/null; then pass "nexum:host resolves standalone" From 227ec9cf79d3d12611ed548a30c77bf058bafcef Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 16:44:08 +0000 Subject: [PATCH 08/53] ci: scan crate graph with --all-features so feature-gated venue deps cannot slip the guard --- scripts/check-venue-agnostic.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index e53e40c3..e3feef76 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -18,7 +18,7 @@ status=0 # 1. Crate graph: nothing venue-shaped reachable from nexum-runtime # (normal + build edges; dev-deps stay local to the crate). -if tree="$(cargo tree -p nexum-runtime -e normal,build --prefix none --locked)"; then +if tree="$(cargo tree -p nexum-runtime -e normal,build --all-features --prefix none --locked)"; then reached="$(printf '%s\n' "$tree" | awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" if [[ -n $reached ]]; then From 26fee09caeec441ec93474291ee8719886c76696 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 16:59:41 +0000 Subject: [PATCH 09/53] runtime: make the egress-guard checkpoint advisory-only A deny verdict at the venue-registry guard seam now logs a would-deny and lets the submission proceed. The checkpoint does not enforce until the egress-guard epic installs the real policy pipeline; the seam docs and the venue-adapter docs say so. --- .../src/host/impls/venue_client.rs | 4 +- .../nexum-runtime/src/host/venue_registry.rs | 45 ++++++++++++------- crates/nexum-venue-sdk/src/adapter.rs | 3 +- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/nexum-runtime/src/host/impls/venue_client.rs index fddc8cfb..0fac7d94 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/nexum-runtime/src/host/impls/venue_client.rs @@ -2,8 +2,8 @@ //! thin delegation to the shared //! [`VenueRegistry`](crate::host::venue_registry) carried in the store; the //! registry owns the venue resolution, per-adapter serialisation, guard -//! seam, and quota. The caller identity the registry meters against is this -//! store's module namespace. +//! seam (advisory-only for now), and quota. The caller identity the registry +//! meters against is this store's module namespace. use crate::bindings::client::Host; use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 9cb303c0..57e029e3 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -4,7 +4,8 @@ //! A module's `client::submit(venue, body)` reaches the host here. The //! registry resolves the venue id to the one installed adapter that answers //! for it, then drives a fixed sequence against that adapter: derive the -//! header, run the guard interposition seam on it, and only then submit. +//! header, run the guard interposition seam on it (advisory-only for now: +//! see [`EgressGuard`]), and only then submit. //! Status and cancel are pass-throughs; they are not submissions, so they //! skip the header, the guard, and the quota. //! @@ -103,10 +104,13 @@ impl Default for SubmitQuota { } /// The guard interposition seam. The registry runs this on the -/// adapter-derived header after `derive-header` and before `submit`. The -/// shipped policy is the unit guard, which allows every egress; the -/// egress-guard epic replaces the installed policy with the real -/// facts-plus-analysers pipeline without the registry changing shape. +/// adapter-derived header after `derive-header` and before `submit`. +/// +/// Advisory-only: the checkpoint is not yet enforcing. A `Deny` verdict is +/// logged as a would-deny and the submission proceeds. The shipped policy is +/// the unit guard, which allows every egress; the egress-guard epic installs +/// the real facts-plus-analysers pipeline and turns the verdict enforcing, +/// without the registry changing shape. pub trait EgressGuard: Send + Sync { /// Decide whether the derived header may proceed to the adapter's submit. fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; @@ -135,7 +139,8 @@ pub struct GuardContext<'a> { pub enum GuardVerdict { /// Forward the submission to the adapter. Allow, - /// Refuse the egress with an operator-facing reason. + /// Refuse the egress with an operator-facing reason. Logged, not + /// enforced, while the seam is advisory-only. Deny(String), } @@ -393,7 +398,8 @@ impl VenueRegistry { /// Submit an opaque body to `venue` on behalf of `caller`: resolve the /// adapter, gate on the caller's quota, derive the header, run the guard - /// seam, then forward to the adapter. A decode failure is charged to the + /// seam (advisory-only: a deny logs and the submission proceeds), then + /// forward to the adapter. A decode failure is charged to the /// caller before returning, so a caller feeding garbage exhausts its own /// budget and is stopped at the gate on the next call rather than /// re-invoking the adapter. @@ -437,8 +443,14 @@ impl VenueRegistry { venue, header: &header, }; + // Advisory-only checkpoint: a deny is logged, never enforced. if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { - return Err(VenueError::Denied(reason)); + warn!( + caller, + venue = %venue, + reason, + "egress guard would deny - advisory-only, submission proceeds", + ); } // A forwarded submission consumes one unit of the caller's budget. self.charge(caller); @@ -651,7 +663,8 @@ impl VenueRegistryBuilder { } /// Override the guard policy. The egress-guard epic wires the real - /// pipeline through here; tests inject a denying policy to prove the seam. + /// pipeline through here; tests inject a denying policy to prove the + /// advisory seam. pub fn with_guard(mut self, guard: Arc) -> Self { self.guard = guard; self @@ -939,7 +952,7 @@ mod tests { } #[tokio::test] - async fn guard_deny_blocks_submit_after_deriving_the_header() { + async fn guard_deny_is_advisory_and_does_not_block_submit() { let calls = Arc::new(StubCalls::default()); let registry = registry_with( SubmitQuota::default(), @@ -947,16 +960,16 @@ mod tests { StubAdapter::new(calls.clone()), ); - let err = registry + let outcome = registry .submit("mod-a", &cow(), b"body".to_vec()) .await - .expect_err("guard denies"); + .expect("advisory deny does not block"); - assert!(matches!(err, VenueError::Denied(reason) if reason.contains("test policy"))); - // The seam runs on the derived header, then blocks: derive ran, submit - // did not. + // The seam runs on the derived header but only logs: derive ran and + // the submission still reached the adapter. + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); assert_eq!(calls.derive.load(Ordering::SeqCst), 1); - assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); } #[tokio::test] diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index 744c77e7..1a7195b9 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -24,7 +24,8 @@ pub trait VenueAdapter { /// Project an opaque intent body onto the stable header guard /// policy runs on. Must be a pure derivation: no transport, no side /// effects, so the host can inspect a header before deciding to - /// submit. + /// submit. The host's guard checkpoint is advisory-only until the + /// egress-guard epic lands: a would-deny is logged, not enforced. fn derive_header(body: Vec) -> Result; /// Price an opaque intent body: an indicative quotation, not an From 5803be5f0589330c5fcdf499e366ed9209991dcb Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 17:19:04 +0000 Subject: [PATCH 10/53] runtime: charge the caller's quota on a guard-deny The submission charge moves ahead of the guard verdict at the venue registry, so a denied egress spends one unit exactly as an accepted submit does: a module spamming denied egress exhausts its own budget instead of looping free once the guard turns enforcing. A regression test pins the rate limit on a repeated-deny loop. --- .../nexum-runtime/src/host/venue_registry.rs | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 57e029e3..d150d206 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -405,12 +405,15 @@ impl VenueRegistry { /// re-invoking the adapter. /// /// Charging is deliberately asymmetric across the two stages. Once the - /// guard admits the header the submission is charged before the adapter - /// call, so a forwarded submission spends one unit regardless of the - /// venue's outcome (the adapter did the work, and a transient venue - /// outage must not become a free retry loop). A derive-stage venue error - /// that is not a decode failure is the venue's fault, not the caller's, - /// so it is left uncharged and the caller may retry. + /// header derives, the submission is charged before the guard verdict + /// and the adapter call, so a denied egress spends one unit exactly as + /// an accepted submit does (a guard that turns enforcing must not hand + /// the caller a free retry loop) and a forwarded submission spends that + /// same unit regardless of the venue's outcome (the adapter did the + /// work, and a transient venue outage must not become a free retry + /// loop). A derive-stage venue error that is not a decode failure is the + /// venue's fault, not the caller's, so it is left uncharged and the + /// caller may retry. pub async fn submit( &self, caller: &str, @@ -443,6 +446,10 @@ impl VenueRegistry { venue, header: &header, }; + // Charge ahead of the verdict: a denied egress consumes one unit of + // the caller's budget exactly as an accepted submit does, so any + // deny return path is already charged. + self.charge(caller); // Advisory-only checkpoint: a deny is logged, never enforced. if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { warn!( @@ -452,8 +459,6 @@ impl VenueRegistry { "egress guard would deny - advisory-only, submission proceeds", ); } - // A forwarded submission consumes one unit of the caller's budget. - self.charge(caller); let outcome = adapter.submit(&body).await?; // An accepted receipt goes under status watch so subscribers see // its transitions; requires-signing has no receipt to watch yet. @@ -972,6 +977,39 @@ mod tests { assert_eq!(calls.submit.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn repeated_guard_denies_exhaust_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with( + quota, + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + // Each denied submit spends exactly one unit: the second is still + // admitted, so a deny is never double-charged. + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + // The deny loop is rate-limited at the gate, not free. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn quote_reaches_the_adapter_without_header_or_guard() { let calls = Arc::new(StubCalls::default()); From 31e0a864ec4a9737e0a6163dba241ef00af63fdc Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 17:25:51 +0000 Subject: [PATCH 11/53] runtime: tersen the submit charge docs --- .../nexum-runtime/src/host/venue_registry.rs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index d150d206..cfb24ec6 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -404,16 +404,10 @@ impl VenueRegistry { /// budget and is stopped at the gate on the next call rather than /// re-invoking the adapter. /// - /// Charging is deliberately asymmetric across the two stages. Once the - /// header derives, the submission is charged before the guard verdict - /// and the adapter call, so a denied egress spends one unit exactly as - /// an accepted submit does (a guard that turns enforcing must not hand - /// the caller a free retry loop) and a forwarded submission spends that - /// same unit regardless of the venue's outcome (the adapter did the - /// work, and a transient venue outage must not become a free retry - /// loop). A derive-stage venue error that is not a decode failure is the - /// venue's fault, not the caller's, so it is left uncharged and the - /// caller may retry. + /// Charged once the header derives, ahead of the guard and adapter, so + /// a deny (when enforcing) or a venue outage is never a free retry. + /// Derive-stage venue errors other than a decode failure are left + /// uncharged and retryable. pub async fn submit( &self, caller: &str, @@ -446,9 +440,7 @@ impl VenueRegistry { venue, header: &header, }; - // Charge ahead of the verdict: a denied egress consumes one unit of - // the caller's budget exactly as an accepted submit does, so any - // deny return path is already charged. + // Charge before the guard so an enforcing deny stays non-free. self.charge(caller); // Advisory-only checkpoint: a deny is logged, never enforced. if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { From 0271e914583ae98bc23a021359af941053022142 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 17:51:07 +0000 Subject: [PATCH 12/53] venue-test: version the fixture file format Fixture files lead with a format version; a reader fails closed on an unknown tag and refuses an empty vector or golden set, which would conform vacuously. Published reference fixtures regenerated. --- .../goldens/reference-header.json | 1 + crates/nexum-venue-test/src/codec.rs | 43 +++++++++++++-- crates/nexum-venue-test/src/fixture.rs | 53 +++++++++++++++++-- crates/nexum-venue-test/src/header.rs | 42 +++++++++++++-- crates/nexum-venue-test/src/lib.rs | 2 +- .../vectors/reference-body.json | 1 + modules/examples/echo-venue/src/lib.rs | 6 ++- 7 files changed, 131 insertions(+), 17 deletions(-) diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/nexum-venue-test/goldens/reference-header.json index ded49d81..a90bc3c4 100644 --- a/crates/nexum-venue-test/goldens/reference-header.json +++ b/crates/nexum-venue-test/goldens/reference-header.json @@ -1,4 +1,5 @@ { + "version": 1, "venue": "nexum-venue-test/reference", "goldens": [ { diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index 3041326e..ecda15f0 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -2,7 +2,8 @@ //! `IntentBody` wire bytes, and the check that holds a codec to them. //! //! A vector file is the venue's codec contract in portable form: JSON, -//! bytes as lowercase hex, one entry per published body. A non-Rust +//! a leading format version (unknown versions fail closed), bytes as +//! lowercase hex, one entry per published body, never zero. A non-Rust //! adapter author proves byte-exactness by decoding and re-encoding //! each `round-trip` vector in their own language and comparing bytes; //! a Rust author runs [`CodecVectors::assert_conforms`] against the @@ -15,17 +16,21 @@ use std::path::Path; use nexum_venue_sdk::{BodyError, IntentBody}; use serde::{Deserialize, Serialize}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of codec vectors for one venue body schema. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CodecVectors { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// Name of the body schema the vectors bind, e.g. /// `acme-dex/order-body`. Informational: the check never reads it. pub schema: String, - /// The vectors, in publication order. + /// The vectors, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub vectors: Vec, } @@ -80,9 +85,11 @@ impl std::fmt::Display for Expectation { } impl CodecVectors { - /// An empty vector set for `schema`. + /// An empty vector set for `schema`. Push at least one vector + /// before publishing: a parsed file is never empty. pub fn new(schema: impl Into) -> Self { Self { + version: FormatVersion, schema: schema.into(), vectors: Vec::new(), } @@ -317,6 +324,7 @@ mod tests { let json = vectors.to_json(); assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors); // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); assert!(json.contains("\"round-trip\"")); assert!(json.contains("\"unknown-version\"")); assert!(json.contains("\"notes\": \"first published body\"")); @@ -343,4 +351,31 @@ mod tests { Err(FixtureError::Read { .. }), )); } + + #[test] + fn unknown_format_version_fails_closed() { + let json = published().to_json().replace("\"version\": 1", "\"version\": 2"); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("version 2 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 2")); + } + + #[test] + fn missing_format_version_fails() { + let json = published().to_json().replace(" \"version\": 1,\n", ""); + assert!(matches!( + CodecVectors::from_json(&json), + Err(FixtureError::Format(_)), + )); + } + + #[test] + fn empty_vector_set_fails_to_parse() { + let json = CodecVectors::new("test/body").to_json(); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } } diff --git a/crates/nexum-venue-test/src/fixture.rs b/crates/nexum-venue-test/src/fixture.rs index 7174573c..624ec1c9 100644 --- a/crates/nexum-venue-test/src/fixture.rs +++ b/crates/nexum-venue-test/src/fixture.rs @@ -1,11 +1,54 @@ -//! The shared fixture-file plumbing: JSON on disk, byte fields as -//! lowercase hex, and the typed [`FixtureError`] both file formats -//! load and save through. +//! The shared fixture-file plumbing: JSON on disk, a leading +//! [`FormatVersion`] (unknown versions fail closed), byte fields as +//! lowercase hex, non-empty entry lists, and the typed +//! [`FixtureError`] both file formats load and save through. use std::path::Path; -use serde::Serialize; -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, Error as _}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The one published fixture file-format version. +const FORMAT_VERSION: u32 = 1; + +/// Fixture file-format discriminator: serializes as the current +/// version, refuses any other on parse (fail-closed), so a reader +/// never guesses at a future layout. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FormatVersion; + +impl Serialize for FormatVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u32(FORMAT_VERSION) + } +} + +impl<'de> Deserialize<'de> for FormatVersion { + fn deserialize>(deserializer: D) -> Result { + let version = u32::deserialize(deserializer)?; + if version == FORMAT_VERSION { + Ok(Self) + } else { + Err(D::Error::custom(format!( + "unknown fixture format version {version}; this reader speaks {FORMAT_VERSION}", + ))) + } + } +} + +/// Deserialize a fixture's entry list, refusing an empty one: an empty +/// set would conform vacuously. +pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let entries = Vec::::deserialize(deserializer)?; + if entries.is_empty() { + return Err(D::Error::custom("a published fixture set is never empty")); + } + Ok(entries) +} /// Why a fixture file failed to load or save. The JSON case carries /// serde's rendered detail rather than the error value so the type diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index f2908622..b0cb826f 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -4,8 +4,10 @@ //! //! A golden file pairs wire bodies with the intent header a conforming //! adapter derives from them, spelled in the golden mirror types below -//! (JSON, kebab-case case names matching the WIT, bytes as lowercase -//! hex). The mirrors exist because wit-bindgen types carry no serde; +//! (JSON, a leading format version that fails closed on an unknown tag, +//! kebab-case case names matching the WIT, bytes as lowercase hex, +//! never zero goldens). The mirrors exist because wit-bindgen types +//! carry no serde; //! [`GoldenHeader`] converts from the venue SDK's `IntentHeader`, and a //! macro-built adapter whose bindgen mints its own header type bridges //! with a field-for-field `From` impl on its crate boundary, the same @@ -18,17 +20,21 @@ use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement}; use serde::{Deserialize, Serialize}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of header goldens for one venue. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HeaderGoldens { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// The venue the goldens bind. Informational: the check never /// reads it. pub venue: String, - /// The goldens, in publication order. + /// The goldens, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub goldens: Vec, } @@ -157,9 +163,11 @@ impl From for GoldenAuthScheme { } impl HeaderGoldens { - /// An empty golden set for `venue`. + /// An empty golden set for `venue`. Record at least one golden + /// before publishing: a parsed file is never empty. pub fn new(venue: impl Into) -> Self { Self { + version: FormatVersion, venue: venue.into(), goldens: Vec::new(), } @@ -288,6 +296,7 @@ mod tests { fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { let golden: GoldenHeader = wire_header().into(); let goldens = HeaderGoldens { + version: FormatVersion, venue: "acme".to_owned(), goldens: vec![HeaderGolden { name: "kitchen-sink".to_owned(), @@ -299,6 +308,7 @@ mod tests { let json = goldens.to_json(); assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); assert!(json.contains("\"native\"")); assert!(json.contains("\"erc20\"")); assert!(json.contains("\"token\"")); @@ -359,4 +369,26 @@ mod tests { .unwrap(); goldens.assert_conforms(|_| Err::(VenueError::Timeout)); } + + #[test] + fn unknown_format_version_fails_closed() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + let json = goldens.to_json().replace("\"version\": 1", "\"version\": 7"); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("version 7 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 7")); + } + + #[test] + fn empty_golden_set_fails_to_parse() { + let json = HeaderGoldens::new("acme").to_json(); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } } diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/nexum-venue-test/src/lib.rs index e8c372e3..a4c4fd83 100644 --- a/crates/nexum-venue-test/src/lib.rs +++ b/crates/nexum-venue-test/src/lib.rs @@ -69,7 +69,7 @@ pub mod report; pub mod transport; pub use codec::{CodecVector, CodecVectors, Expectation}; -pub use fixture::FixtureError; +pub use fixture::{FixtureError, FormatVersion}; pub use header::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, diff --git a/crates/nexum-venue-test/vectors/reference-body.json b/crates/nexum-venue-test/vectors/reference-body.json index 297d32b3..a2ffd1db 100644 --- a/crates/nexum-venue-test/vectors/reference-body.json +++ b/crates/nexum-venue-test/vectors/reference-body.json @@ -1,4 +1,5 @@ { + "version": 1, "schema": "nexum-venue-test/reference-body", "vectors": [ { diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index c3e675c7..92b0e62c 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -108,8 +108,8 @@ fn minimal_be(value: u64) -> Vec { mod conformance { use super::*; use nexum_venue_test::{ - GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, - HeaderGolden, HeaderGoldens, + FormatVersion, GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, + GoldenSettlement, HeaderGolden, HeaderGoldens, }; fn asset_to_golden(asset: Asset) -> GoldenAsset { @@ -177,6 +177,7 @@ mod conformance { notes: Some("amount is the minimal big-endian body length".to_owned()), }; let goldens = HeaderGoldens { + version: FormatVersion, venue: "echo-venue".to_owned(), goldens: vec![golden], }; @@ -188,6 +189,7 @@ mod conformance { // A non-minimal amount is the classic uint bug; the golden must // reject it, proving the check has teeth on echo-venue. let goldens = HeaderGoldens { + version: FormatVersion, venue: "echo-venue".to_owned(), goldens: vec![HeaderGolden { name: "four-byte-body".to_owned(), From df3477d4f0256afd2dc2b55e4473ca473321e394 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 18:00:05 +0000 Subject: [PATCH 13/53] venue-test: fail conformance checks on an empty fixture set --- crates/nexum-venue-test/src/codec.rs | 23 ++++++++++++++++++++++- crates/nexum-venue-test/src/header.rs | 25 ++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index ecda15f0..f31fc5c6 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -165,9 +165,16 @@ impl CodecVectors { /// /// A `round-trip` vector must decode and re-encode to the exact /// published bytes; a failure vector must produce the matching - /// [`BodyError`] case (the free-text detail is not compared). + /// [`BodyError`] case (the free-text detail is not compared). An + /// empty set is itself a violation: it would conform vacuously. pub fn check(&self) -> Result<(), ConformanceReport> { let mut violations = Vec::new(); + if self.vectors.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published vector set is empty".to_owned(), + }); + } for vector in &self.vectors { if let Err(detail) = vector.check::() { violations.push(Violation { @@ -370,6 +377,20 @@ mod tests { )); } + #[test] + fn empty_vector_set_fails_the_check() { + let report = CodecVectors::new("test/body").check::().unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_rejects_an_empty_set() { + CodecVectors::new("test/body").assert_conforms::(); + } + #[test] fn empty_vector_set_fails_to_parse() { let json = CodecVectors::new("test/body").to_json(); diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index b0cb826f..088674f1 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -219,7 +219,8 @@ impl HeaderGoldens { /// collecting all violations rather than stopping at the first. /// /// `derive` is the adapter's derivation; a trait-based adapter - /// passes `MyAdapter::derive_header` directly. + /// passes `MyAdapter::derive_header` directly. An empty set is + /// itself a violation: it would conform vacuously. pub fn check( &self, mut derive: impl FnMut(Vec) -> Result, @@ -229,6 +230,12 @@ impl HeaderGoldens { E: fmt::Debug, { let mut violations = Vec::new(); + if self.goldens.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published golden set is empty".to_owned(), + }); + } for golden in &self.goldens { match derive(golden.body.clone()) { Ok(header) => { @@ -383,6 +390,22 @@ mod tests { assert!(detail.contains("unknown fixture format version 7")); } + #[test] + fn empty_golden_set_fails_the_check() { + let report = HeaderGoldens::new("acme") + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_rejects_an_empty_set() { + HeaderGoldens::new("acme").assert_conforms(|_| Ok::<_, VenueError>(wire_header())); + } + #[test] fn empty_golden_set_fails_to_parse() { let json = HeaderGoldens::new("acme").to_json(); From d6f4a3feb4a5e1987eb2e20901f183fef871ccd5 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 18:09:04 +0000 Subject: [PATCH 14/53] style: rustfmt the venue-test version-rejection tests --- crates/nexum-venue-test/src/codec.rs | 4 +++- crates/nexum-venue-test/src/header.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index f31fc5c6..52d46226 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -361,7 +361,9 @@ mod tests { #[test] fn unknown_format_version_fails_closed() { - let json = published().to_json().replace("\"version\": 1", "\"version\": 2"); + let json = published() + .to_json() + .replace("\"version\": 1", "\"version\": 2"); let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { panic!("version 2 must not parse"); }; diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index 088674f1..6c32af65 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -383,7 +383,9 @@ mod tests { goldens .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) .unwrap(); - let json = goldens.to_json().replace("\"version\": 1", "\"version\": 7"); + let json = goldens + .to_json() + .replace("\"version\": 1", "\"version\": 7"); let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { panic!("version 7 must not parse"); }; From 399f363052a3a1a283c2beac3c3074dbb58c0231 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 21:41:18 +0000 Subject: [PATCH 15/53] runtime: bound the status-watch set with a capped, expiring policy The registry's watch list grew without bound and was polled O(N) per cadence. Each watch now carries an eviction deadline and the set carries a cap, both operator-configurable via [limits.watch]; expired entries evict unpolled, at the cap a new watch is refused and logged rather than a live watch dropped, and every successful non-terminal poll pushes the deadline a full window out, so only a venue silent for the whole window expires. --- crates/nexum-runtime/src/engine_config.rs | 80 ++++- .../nexum-runtime/src/host/venue_registry.rs | 284 ++++++++++++++++-- crates/nexum-runtime/src/supervisor.rs | 3 +- 3 files changed, 341 insertions(+), 26 deletions(-) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 06cbc08a..7c75f301 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,7 +26,10 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; -use crate::host::venue_registry::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, SubmitQuota}; +use crate::host::venue_registry::{ + DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, DEFAULT_WATCH_EXPIRY, + DEFAULT_WATCH_MAX_ENTRIES, SubmitQuota, WatchLimit, +}; use crate::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; @@ -357,6 +360,9 @@ pub struct ModuleLimits { /// Router-driven intent status polling cadence. #[serde(default)] pub status_poll: StatusPollSection, + /// Status-watch set bounds. + #[serde(default)] + pub watch: WatchLimitsSection, /// Per-module dispatch rate-limit thresholds. #[serde(default)] pub dispatch: DispatchLimitsSection, @@ -500,6 +506,23 @@ impl ModuleLimits { .unwrap_or(DEFAULT_QUOTA_WINDOW), ) } + + /// Resolved status-watch bounds (overrides or defaults). A zero + /// `max_entries` saturates up to 1 and a zero `expiry_secs` up to 1 s, + /// so a misconfigured bound still watches one receipt briefly rather + /// than nothing at all. + pub fn watch(&self) -> WatchLimit { + WatchLimit::new( + self.watch + .max_entries + .map(|n| n.max(1)) + .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES), + self.watch + .expiry_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_WATCH_EXPIRY), + ) + } } /// `[limits.http]` outbound wasi:http limits. Every field is optional; @@ -616,6 +639,22 @@ pub struct StatusPollSection { pub interval_ms: Option, } +/// `[limits.watch]` status-watch set bounds. Both optional; omitted +/// values resolve to the registry defaults via [`ModuleLimits::watch`] +/// and degenerate zeroes saturate up to a usable minimum. +/// +/// The registry watches each accepted receipt until a terminal status: +/// the cap bounds the per-cadence poll fan-out, and the expiry evicts a +/// watch whose venue never reports one. At the cap a new watch is +/// refused and logged; live watches are never dropped. +#[derive(Debug, Default, Deserialize)] +pub struct WatchLimitsSection { + /// Maximum receipts under status watch at once. + pub max_entries: Option, + /// Seconds one watch stays live before it is evicted unreported. + pub expiry_secs: Option, +} + /// `[limits.dispatch]` per-module dispatch rate-limit knobs. Both /// optional; omitted values resolve to the production defaults, and a /// degenerate zero saturates up to 1 via [`ModuleLimits::dispatch_rate`]. @@ -1110,6 +1149,45 @@ refill_per_sec = 0 assert_eq!(policy.refill_per_sec, 1); } + #[test] + fn watch_limits_default_when_absent() { + let watch = ModuleLimits::default().watch(); + assert_eq!(watch.max_entries, DEFAULT_WATCH_MAX_ENTRIES); + assert_eq!(watch.expiry, DEFAULT_WATCH_EXPIRY); + } + + #[test] + fn watch_limits_parse_with_overrides() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 32 +expiry_secs = 900 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 32); + assert_eq!(watch.expiry, Duration::from_secs(900)); + } + + #[test] + fn watch_limits_saturate_zero_up_to_one() { + // A zero cap would refuse every watch; a zero expiry would evict + // each watch before its first poll. Both saturate. + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 0 +expiry_secs = 0 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 1); + assert_eq!(watch.expiry, Duration::from_secs(1)); + } + #[test] fn extensions_tables_parse_opaquely() { let cfg: EngineConfig = toml::from_str( diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index cfb24ec6..f13d87aa 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -45,6 +45,10 @@ use crate::host::state::HostState; pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; /// Default sliding window the per-caller submission budget is counted over. pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Default cap on receipts under status watch at once. +pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; +/// Default lifetime of one status watch before it is evicted unreported. +pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); /// Venue identifier: the id an adapter registers under and a submission /// names. Opaque beyond equality. @@ -103,6 +107,34 @@ impl Default for SubmitQuota { } } +/// Bounds on the status-watch set. The cap bounds the per-cadence poll +/// fan-out; the expiry evicts a watch whose venue has gone silent for a +/// whole window. +#[derive(Debug, Clone, Copy)] +pub struct WatchLimit { + /// Maximum receipts under status watch at once. + pub max_entries: usize, + /// How long a watch survives without a successful poll before it is + /// evicted unreported. + pub expiry: Duration, +} + +impl WatchLimit { + /// Pair a cap with the per-entry expiry. + pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self { + max_entries, + expiry, + } + } +} + +impl Default for WatchLimit { + fn default() -> Self { + Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) + } +} + /// The guard interposition seam. The registry runs this on the /// adapter-derived header after `derive-header` and before `submit`. /// @@ -307,10 +339,14 @@ struct QuotaLedger { /// One receipt the registry polls for status transitions. `last` starts /// `None` so the first successful poll always reports, giving a /// subscriber the intent's current state without waiting for a change. +/// `expires_at` is the eviction deadline, pushed a full window out on +/// every successful non-terminal poll; `None` (deadline arithmetic +/// overflowed) never expires. struct WatchedIntent { venue: VenueId, receipt: Vec, last: Option, + expires_at: Option, } /// A polled status is terminal when the intent can never change again: @@ -350,8 +386,10 @@ struct VenueRegistryInner { guard: Arc, quota: SubmitQuota, ledger: Mutex, + watch_limit: WatchLimit, /// Receipts under status watch, appended by accepted submissions and - /// pruned as they reach a terminal status. + /// pruned as they reach a terminal status, expire, or overflow + /// [`WatchLimit`]. watched: Mutex>, } @@ -483,20 +521,39 @@ impl VenueRegistry { } /// Put a `(venue, receipt)` pair under status watch. Idempotent: a - /// re-submitted receipt keeps its existing watch entry. + /// re-submitted receipt keeps its existing watch entry. Bounded: + /// expired entries evict first, and at the cap the new watch is + /// refused and logged rather than an existing live watch dropped. fn watch(&self, venue: &VenueId, receipt: Vec) { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - if watched - .iter() - .any(|w| w.venue == *venue && w.receipt == receipt) - { - return; + let (evicted, admitted) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + if watched + .iter() + .any(|w| w.venue == *venue && w.receipt == receipt) + { + (evicted, true) + } else if watched.len() < self.inner.watch_limit.max_entries { + watched.push(WatchedIntent { + venue: venue.clone(), + receipt, + last: None, + expires_at: Instant::now().checked_add(self.inner.watch_limit.expiry), + }); + (evicted, true) + } else { + (evicted, false) + } + }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } + if !admitted { + warn!( + venue = %venue, + "status watch set full - transitions for this receipt will not be reported", + ); } - watched.push(WatchedIntent { - venue: venue.clone(), - receipt, - last: None, - }); } /// Number of receipts currently under status watch. @@ -513,16 +570,22 @@ impl VenueRegistry { /// reported for that receipt (the first successful poll always /// reports). A terminal status is reported once and the receipt is /// dropped from the watch; a failure leaves the entry untouched for - /// the next cadence. + /// the next cadence. Expired entries are evicted unpolled and + /// unreported. pub async fn poll_status_transitions(&self) -> Vec { // Snapshot so the std mutex is never held across the guest await. - let snapshot: Vec<(VenueId, Vec)> = { - let watched = self.inner.watched.lock().expect("watch list poisoned"); - watched + let (evicted, snapshot): (usize, Vec<(VenueId, Vec)>) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + let snapshot = watched .iter() .map(|w| (w.venue.clone(), w.receipt.clone())) - .collect() + .collect(); + (evicted, snapshot) }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } let mut updates = Vec::new(); for (venue, receipt) in snapshot { // Installed adapters never leave the registry, so a resolve @@ -554,10 +617,11 @@ impl VenueRegistry { /// Fold one polled status into the watch entry: `Some(update)` when it /// differs from the last reported status, pruning the entry when the - /// status is terminal. `None` also covers an entry that disappeared - /// while the poll was in flight, and an update whose status body - /// failed to encode (the entry is left untouched for the next - /// cadence). + /// status is terminal and refreshing its eviction deadline otherwise, + /// so expiry only fires on a venue that has gone silent. `None` also + /// covers an entry that disappeared while the poll was in flight, and + /// an update whose status body failed to encode (the entry is left + /// untouched for the next cadence). fn record_polled_status( &self, venue: &VenueId, @@ -592,6 +656,7 @@ impl VenueRegistry { watched.remove(pos); } else { watched[pos].last = Some(status); + watched[pos].expires_at = Instant::now().checked_add(self.inner.watch_limit.expiry); } update } @@ -627,6 +692,15 @@ fn window_ms(window: Duration) -> u64 { u64::try_from(window.as_millis()).unwrap_or(u64::MAX) } +/// Drop watch entries whose eviction deadline has passed, returning how +/// many were evicted. +fn prune_expired(watched: &mut Vec) -> usize { + let now = Instant::now(); + let before = watched.len(); + watched.retain(|w| w.expires_at.is_none_or(|at| now < at)); + before - watched.len() +} + /// Drop charge timestamps that have aged out of the window. fn prune(history: &mut VecDeque, window: Duration) { let now = Instant::now(); @@ -647,15 +721,18 @@ pub struct VenueRegistryBuilder { adapters: HashMap, guard: Arc, quota: SubmitQuota, + watch_limit: WatchLimit, } impl VenueRegistryBuilder { - /// Start an empty builder with the given quota and the unit guard. + /// Start an empty builder with the given quota, the unit guard, and + /// the default watch limit. pub fn new(quota: SubmitQuota) -> Self { Self { adapters: HashMap::new(), guard: Arc::new(()), quota, + watch_limit: WatchLimit::default(), } } @@ -667,6 +744,12 @@ impl VenueRegistryBuilder { self } + /// Override the status-watch bounds. + pub fn with_watch_limit(mut self, watch_limit: WatchLimit) -> Self { + self.watch_limit = watch_limit; + self + } + /// Install an adapter under its venue id. Rejects a duplicate id: two /// adapters answering the same venue would silently shadow one another, /// which is a config error worth failing boot over. @@ -692,11 +775,19 @@ impl VenueRegistryBuilder { warn!("submission quota max_charges is 0; clamping to 1"); } let quota = SubmitQuota::new(self.quota.max_charges.max(1), self.quota.window); + if self.watch_limit.max_entries == 0 { + // A zero cap would refuse every watch; saturate up to one so a + // misconfigured bound still tracks a single receipt. + warn!("watch limit max_entries is 0; clamping to 1"); + } + let watch_limit = + WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); VenueRegistry { inner: Arc::new(VenueRegistryInner { adapters: self.adapters, guard: self.guard, quota, + watch_limit, ledger: Mutex::new(QuotaLedger::default()), watched: Mutex::new(Vec::new()), }), @@ -762,6 +853,9 @@ mod tests { calls: Arc, derive: Result, submit: Result, + /// Accept each submission with its body as the receipt, so one + /// stub can mint distinct receipts. + echo_receipt: bool, /// Statuses served front-first by consecutive `status` calls; /// once drained, every further call reports `open`. status_script: VecDeque>, @@ -773,10 +867,16 @@ mod tests { calls, derive: Ok(header()), submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + echo_receipt: false, status_script: VecDeque::new(), } } + fn with_receipt_echo(mut self) -> Self { + self.echo_receipt = true; + self + } + fn with_derive(mut self, derive: Result) -> Self { self.derive = derive; self @@ -830,11 +930,14 @@ mod tests { fn submit<'a>( &'a mut self, - _body: &'a [u8], + body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { self.calls.submit.fetch_add(1, Ordering::SeqCst); self.enter().await; + if self.echo_receipt { + return Ok(SubmitOutcome::Accepted(body.to_vec())); + } self.submit.clone() }) } @@ -1226,6 +1329,14 @@ mod tests { assert_eq!(registry.inner.quota.max_charges, 1); } + #[test] + fn zero_watch_cap_saturates_to_one() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(WatchLimit::new(0, DEFAULT_WATCH_EXPIRY)) + .build(); + assert_eq!(registry.inner.watch_limit.max_entries, 1); + } + // ── status watch + polling ──────────────────────────────────────── #[tokio::test] @@ -1353,6 +1464,131 @@ mod tests { assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); } + /// A registry with the given watch bounds and one echo-receipt-capable + /// stub adapter under `cow`. + fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { + let mut builder = + VenueRegistryBuilder::new(SubmitQuota::default()).with_watch_limit(watch_limit); + builder.install(cow(), adapter).expect("install adapter"); + builder.build() + } + + #[tokio::test] + async fn watch_cap_refuses_the_overflow_and_never_drops_live_watches() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_receipt_echo() + .with_status_script([Ok(IntentStatus::Pending), Ok(IntentStatus::Pending)]); + let limit = WatchLimit::new(2, Duration::from_secs(3600)); + let registry = watch_bounded_registry(limit, adapter); + + for body in [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] { + registry + .submit("mod-a", &cow(), body) + .await + .expect("submit succeeds"); + } + assert_eq!(registry.watched_count(), 2, "the cap bounds the set"); + + // The live pending watches kept their tracking; only the overflow + // watch was refused. + let updates = registry.poll_status_transitions().await; + let receipts: Vec<&[u8]> = updates.iter().map(|u| u.receipt.as_slice()).collect(); + assert_eq!(receipts, vec![b"a".as_slice(), b"b".as_slice()]); + assert!( + updates + .iter() + .all(|u| decoded(u) == plain(Lifecycle::Pending)) + ); + } + + #[tokio::test] + async fn pending_polls_keep_a_live_watch_across_expiry_windows() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Fulfilled), + ]); + let expiry = Duration::from_secs(1); + let registry = watch_bounded_registry(WatchLimit::new(8, expiry), adapter); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let deadline_at = |registry: &VenueRegistry| { + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + watched[0].expires_at + }; + let inserted = deadline_at(®istry); + + // Two pending polls, each pushing the deadline a full window out. + let mut reported = Vec::new(); + for _ in 0..2 { + reported.extend(registry.poll_status_transitions().await); + assert_eq!( + registry.watched_count(), + 1, + "a reporting venue stays watched" + ); + assert!( + deadline_at(®istry) > inserted, + "the poll refreshed the deadline" + ); + tokio::time::sleep(expiry * 7 / 10).await; + } + + // Well past the insert-time window, the terminal transition still + // reports and prunes the watch. + reported.extend(registry.poll_status_transitions().await); + let statuses: Vec = reported.iter().map(decoded).collect(); + assert_eq!( + statuses, + vec![plain(Lifecycle::Pending), plain(Lifecycle::Fulfilled)], + ); + assert_eq!(registry.watched_count(), 0); + } + + #[tokio::test] + async fn expired_watches_are_evicted_unpolled() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(8, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls.clone())); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + + // The entry expired before the cadence: evicted without a venue call. + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!(registry.watched_count(), 0); + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn expiry_frees_room_at_the_cap() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(1, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls).with_receipt_echo()); + + registry + .submit("mod-a", &cow(), b"a".to_vec()) + .await + .expect("submit succeeds"); + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .expect("submit succeeds"); + + // The expired first watch was evicted at insert, admitting the second. + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + assert_eq!(watched.len(), 1); + assert_eq!(watched[0].receipt, b"b"); + } + #[test] fn every_lifecycle_state_lowers_onto_the_status_body() { for (wire, lowered) in [ diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 363b0cc9..87f1fac7 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -289,7 +289,8 @@ impl Supervisor { // client face. let adapter_linker = build_adapter_linker::(engine)?; let adapter_registry = CapabilityRegistry::adapter(); - let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()); + let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()) + .with_watch_limit(engine_cfg.limits.watch()); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; for entry in &engine_cfg.adapters { From e2dc469229b64d4a09fd60fc58ee677514cb6e7c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 21:26:48 +0000 Subject: [PATCH 16/53] feat: grow the extension seam to carry worker and provider roles The Extension seam becomes a trait contributing a namespace, a capability namespace, a linker hook, an optional HostService, and an optional ProviderKind. HostService is a sync, type-erased service held on a typed per-namespace HostState.services map built once at boot; ProviderKind carries a provider linker hook plus the one cold dyn async_trait install path. The worker boot path is unchanged. --- Cargo.lock | 1 + Cargo.toml | 3 + crates/nexum-runtime/Cargo.toml | 1 + crates/nexum-runtime/src/bootstrap.rs | 3 +- crates/nexum-runtime/src/builder.rs | 26 ++- crates/nexum-runtime/src/host/extension.rs | 209 +++++++++++++++--- crates/nexum-runtime/src/host/state.rs | 4 + crates/nexum-runtime/src/supervisor.rs | 37 +++- crates/nexum-runtime/src/supervisor/tests.rs | 2 +- .../nexum-runtime/src/test_utils/harness.rs | 44 ++-- crates/shepherd-cow-host/src/ext_cow.rs | 53 +++-- crates/shepherd-cow-host/tests/cow_boot.rs | 3 +- 12 files changed, 306 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47718667..8f3c72a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3597,6 +3597,7 @@ dependencies = [ "alloy-transport", "alloy-transport-ws", "anyhow", + "async-trait", "bytes", "futures", "http", diff --git a/Cargo.toml b/Cargo.toml index 43f68852..2519fd97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ anyhow = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } futures = "0.3" +# Cold `dyn` boot paths only (`ProviderKind::install`); hot guest traits +# use native async-fn-in-trait. +async-trait = "0.1" # Serde + config. serde = { version = "1", features = ["derive"] } diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 7abab005..7b66aa90 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -22,6 +22,7 @@ wasmtime-wasi-http.workspace = true # Async + error plumbing. anyhow.workspace = true thiserror.workspace = true +async-trait.workspace = true # `strum::IntoStaticStr` on error enums gives metric labels (`error_kind`) # free via a snake_case `&'static str` for every variant. Used at # `tracing::warn!(error_kind = .into(), ...)` sites and diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 532e5582..4e0c3c18 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -10,6 +10,7 @@ //! [`LaunchRuntime`] directly. use std::path::Path; +use std::sync::Arc; use crate::addons::RuntimeAddOn; use crate::builder::{AssembledRuntime, LaunchContext, LaunchRuntime}; @@ -34,7 +35,7 @@ pub async fn run( wasm: Option<&Path>, manifest: Option<&Path>, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], add_ons: &[&dyn RuntimeAddOn], ) -> anyhow::Result<()> { let runtime = AssembledRuntime { diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 34ad617b..412c4d8f 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -18,6 +18,7 @@ use std::future::{Future, IntoFuture}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Duration; use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet}; @@ -127,8 +128,9 @@ fn finish_wait(joined: Option) -> anyhow::Result<()> { pub struct AssembledRuntime<'a, T: RuntimeTypes> { /// Shared backends threaded into every module store. pub components: Components, - /// Linker hooks and capability namespaces. - pub extensions: Vec>, + /// Extensions: namespaces, capabilities, linker hooks, services, and + /// provider kinds. + pub extensions: Vec>>, /// Cross-cutting facilities installed before the engine boots. pub add_ons: &'a [&'a dyn RuntimeAddOn], /// Single-module source override; `None` runs `[[modules]]`. @@ -386,7 +388,7 @@ impl<'a> RuntimeBuilder<'a> { /// optional extension hooks and module source before [`launch`](Self::launch). pub struct PresetBuilder<'a, R: Runtime> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -394,11 +396,10 @@ pub struct PresetBuilder<'a, R: Runtime> { } impl<'a, R: Runtime> PresetBuilder<'a, R> { - /// Add extension linker hooks and capability namespaces on top of the - /// preset. The default preset carries none. + /// Add extensions on top of the preset. The default preset carries none. pub fn with_extensions( mut self, - extensions: impl IntoIterator>, + extensions: impl IntoIterator>>, ) -> Self { self.extensions.extend(extensions); self @@ -459,7 +460,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { /// may be added before the component builders. pub struct TypedBuilder<'a, T: RuntimeTypes> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -467,8 +468,11 @@ pub struct TypedBuilder<'a, T: RuntimeTypes> { } impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { - /// Add the extension linker hooks and capability namespaces. - pub fn with_extensions(mut self, extensions: impl IntoIterator>) -> Self { + /// Add the extensions. + pub fn with_extensions( + mut self, + extensions: impl IntoIterator>>, + ) -> Self { self.extensions.extend(extensions); self } @@ -509,7 +513,7 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { /// The component builders are bound; the add-on set remains. pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -536,7 +540,7 @@ impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { /// runs. pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index f5b1b806..6f57e6a8 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,39 +1,196 @@ -//! The extension seam: a linker hook plus the capability namespace an -//! extension contributes, assembled at the composition root and threaded -//! into every module linker. +//! The extension seam: what one extension contributes to the host - a +//! namespace, a capability namespace, a linker hook, an optional host +//! service, and an optional provider kind. Assembled at the composition +//! root and threaded into every module linker. +use std::any::Any; +use std::collections::BTreeMap; use std::sync::Arc; -use wasmtime::component::Linker; +use async_trait::async_trait; +use wasmtime::Store; +use wasmtime::component::{Component, Linker}; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; use crate::manifest::NamespaceCaps; -/// Adds an extension's WIT interfaces to a module linker. Runs after the -/// core interfaces and before instantiation. Takes only `&mut Linker`, so -/// the seam stays compatible with a future per-extension router that -/// serialises access to the non-`Sync` wasmtime `Store`. -pub type LinkerHook = Arc>) -> anyhow::Result<()> + Send + Sync>; - -/// One runtime extension: how to wire its interfaces into a module linker, -/// and the capability namespace enforcement must recognise for it. The two -/// travel together: a module that imports an extension interface boots only -/// if the linker entry AND the capability namespace are both registered -/// before instantiation. -pub struct Extension { - /// Linker contribution: adds the extension's imports to a module linker. - pub link: LinkerHook, - /// Capability namespace this extension owns, merged into enforcement so - /// a module importing the extension's interfaces still validates. - pub capabilities: NamespaceCaps, +/// One runtime extension. A module that imports an extension interface +/// boots only if the linker entry AND the capability namespace are both +/// registered before instantiation. +pub trait Extension: Send + Sync + 'static { + /// Namespace this extension owns; keys its service in [`HostServices`]. + fn namespace(&self) -> &'static str; + + /// Capability namespace merged into enforcement so a module importing + /// the extension's interfaces still validates. + fn capabilities(&self) -> NamespaceCaps; + + /// Adds the extension's imports to a worker linker. Runs after the + /// core interfaces and before instantiation. Takes only `&mut Linker`, + /// so the seam stays compatible with a future per-extension router + /// that serializes access to the non-`Sync` wasmtime `Store`. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Host service this extension owns, published under its namespace on + /// [`HostServices`]. + fn service(&self) -> Option> { + None + } + + /// Provider kind this extension installs. + fn provider(&self) -> Option>> { + None + } +} + +/// A type-erased host service an extension owns. Held per namespace on +/// `HostState::services` and downcast at the call site. Kept synchronous +/// so it stays `dyn`-compatible. +pub trait HostService: Any + Send + Sync + 'static {} + +/// A provider component kind: the host holds an instance behind the owning +/// extension's serialized service; others call it. `async_trait` carries +/// the one cold `dyn` boot path until `async_fn_in_dyn_trait` stabilizes. +#[async_trait] +pub trait ProviderKind: Send + Sync + 'static { + /// Manifest kind this provider answers for. + fn kind(&self) -> &'static str; + + /// Adds the provider's imports to a provider linker. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Install one instantiated provider behind the extension's service. + async fn install( + &self, + component: &Component, + store: Store>, + service: &Arc, + ) -> anyhow::Result<()>; +} + +/// Immutable per-namespace service map: each extension's [`HostService`] +/// under its [`Extension::namespace`], built once at boot and shared by +/// every module store. +#[derive(Clone, Default)] +pub struct HostServices(Arc>>); + +impl std::fmt::Debug for HostServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_set().entries(self.0.keys()).finish() + } +} + +impl HostServices { + /// Collect each extension's service under its namespace. Refuses a + /// duplicate namespace. + pub fn from_extensions( + extensions: &[Arc>], + ) -> anyhow::Result { + let mut map = BTreeMap::new(); + for ext in extensions { + let Some(service) = ext.service() else { + continue; + }; + let namespace = ext.namespace(); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + } + Ok(Self(Arc::new(map))) + } + + /// The service under `namespace`, downcast to its concrete type. + /// `None` when the namespace is absent or the type does not match. + pub fn get(&self, namespace: &str) -> Option> { + let service = Arc::clone(self.0.get(namespace)?); + let erased: Arc = service; + erased.downcast().ok() + } + + /// The raw type-erased service under `namespace`. + pub fn raw(&self, namespace: &str) -> Option<&Arc> { + self.0.get(namespace) + } } -impl Clone for Extension { - fn clone(&self) -> Self { - Self { - link: Arc::clone(&self.link), - capabilities: self.capabilities, +#[cfg(test)] +mod tests { + use super::*; + use crate::supervisor::TestTypes; + + struct Registry(u64); + impl HostService for Registry {} + + struct Clockwork; + impl HostService for Clockwork {} + + struct ServiceExt { + namespace: &'static str, + service: Option>, + } + + impl Extension for ServiceExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) } + fn service(&self) -> Option> { + self.service.as_ref().map(Arc::clone) + } + } + + fn ext( + namespace: &'static str, + service: Arc, + ) -> Arc> { + Arc::new(ServiceExt { + namespace, + service: Some(service), + }) + } + + /// A registered service comes back under its namespace, downcast to + /// its concrete type; a wrong type or an absent namespace is `None`. + #[test] + fn get_downcasts_by_namespace() { + let services = + HostServices::from_extensions(&[ext("videre", Arc::new(Registry(7)))]).expect("build"); + + let registry = services.get::("videre").expect("registered"); + assert_eq!(registry.0, 7); + assert!(services.get::("videre").is_none()); + assert!(services.get::("absent").is_none()); + assert!(services.raw("videre").is_some()); + } + + /// A serviceless extension contributes nothing to the map. + #[test] + fn serviceless_extension_is_absent() { + let serviceless: Arc> = Arc::new(ServiceExt { + namespace: "quiet", + service: None, + }); + let services = HostServices::from_extensions(&[serviceless]).expect("build"); + assert!(services.raw("quiet").is_none()); + } + + /// Two services under one namespace refuse to build. + #[test] + fn duplicate_namespace_is_refused() { + let err = HostServices::from_extensions(&[ + ext("videre", Arc::new(Registry(1))), + ext("videre", Arc::new(Clockwork)), + ]) + .expect_err("duplicate namespace"); + assert!(err.to_string().contains("videre"), "{err}"); } } diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 5dd07d85..3d85117d 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -11,6 +11,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; +use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; use super::venue_registry::VenueRegistry; @@ -55,6 +56,9 @@ pub struct HostState { /// Every module store carries the same shared handle; an adapter store, /// which cannot call the client face, carries an empty one. pub venue_registry: VenueRegistry, + /// Extension-owned host services, keyed by extension namespace and + /// downcast at the call site. One shared map across every store. + pub services: HostServices, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 87f1fac7..f31a9a52 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -43,7 +43,7 @@ use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::Extension; +use crate::host::extension::{Extension, HostServices}; use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; @@ -81,7 +81,10 @@ pub struct Supervisor { /// Extensions wired at boot. Cached so the module-restart path can /// rebuild an identical linker (core interfaces plus every extension /// hook) without re-consulting the composition root. - extensions: Vec>, + extensions: Vec>>, + /// Extension-owned host services, built once at boot from the same + /// extension set and carried by every store. + services: HostServices, /// Poison-pill thresholds resolved from `[limits.poison]` at boot /// (production defaults: 5 failures / 10 min). poison_policy: crate::runtime::poison_policy::PoisonPolicy, @@ -277,10 +280,11 @@ impl Supervisor { linker: &Linker>, engine_cfg: &EngineConfig, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], clocks: Option, ) -> Result { let registry = capability_registry(extensions); + let services = HostServices::from_extensions(extensions)?; // Adapters instantiate first: the venue registry must contain them // before any module store (which carries the built registry) is // built. Adapters link only their scoped transport, against a @@ -302,6 +306,7 @@ impl Supervisor { &engine_cfg.limits, &adapter_registry, clocks.as_ref(), + services.clone(), ) .await .with_context(|| format!("load adapter {}", entry.path.display()))?; @@ -330,6 +335,7 @@ impl Supervisor { ®istry, clocks.as_ref(), venue_registry.clone(), + services.clone(), ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -351,6 +357,7 @@ impl Supervisor { engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: engine_cfg.limits.poison(), clocks, }) @@ -370,10 +377,11 @@ impl Supervisor { manifest: Option<&Path>, components: &Components, limits: &ModuleLimits, - extensions: &[Extension], + extensions: &[Arc>], clocks: Option, ) -> Result { let registry = capability_registry(extensions); + let services = HostServices::from_extensions(extensions)?; let entry = ModuleEntry { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), @@ -391,6 +399,7 @@ impl Supervisor { ®istry, clocks.as_ref(), venue_registry.clone(), + services.clone(), ) .await?; Ok(Self { @@ -401,6 +410,7 @@ impl Supervisor { engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: limits.poison(), clocks, }) @@ -426,6 +436,7 @@ impl Supervisor { state_quota: u64, clocks: Option<&WasiClockOverride>, venue_registry: VenueRegistry, + services: HostServices, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -484,6 +495,7 @@ impl Supervisor { chain_response_max_bytes, store: module_store, venue_registry, + services, }, ); store.limiter(|state| &mut state.limits); @@ -503,6 +515,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, venue_registry: VenueRegistry, + services: HostServices, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -568,6 +581,7 @@ impl Supervisor { state_bytes, clocks, venue_registry, + services, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -664,6 +678,9 @@ impl Supervisor { /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings /// against the adapter linker, and run `init`. Nothing dispatches to /// the result yet; it boots so the registry can later reach it. + // One flat argument per shared input threaded onto the store, matching + // the module load path. + #[allow(clippy::too_many_arguments)] async fn load_adapter( engine: &Engine, linker: &Linker>, @@ -672,6 +689,7 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, + services: HostServices, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -751,6 +769,7 @@ impl Supervisor { limits_cfg.state_bytes(), clocks, VenueRegistry::empty(), + services, )?; let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) .await @@ -921,6 +940,7 @@ impl Supervisor { // as the initial boot. let clocks = self.clocks.clone(); let venue_registry = self.venue_registry.clone(); + let services = self.services.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key // apart from the dead run's, which stays readable until evicted. @@ -938,6 +958,7 @@ impl Supervisor { module.local_store_bytes, clocks.as_ref(), venue_registry, + services, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -1422,7 +1443,7 @@ impl Supervisor { /// and capability enforcement via the crate-internal `capability_registry`. pub fn build_linker( engine: &Engine, - extensions: &[Extension], + extensions: &[Arc>], ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; @@ -1438,7 +1459,7 @@ pub fn build_linker( // wasi:io/wasi:clocks interfaces. wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; for ext in extensions { - (ext.link)(&mut linker)?; + ext.link(&mut linker)?; } Ok(linker) } @@ -1502,11 +1523,11 @@ fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option( - extensions: &[Extension], + extensions: &[Arc>], ) -> CapabilityRegistry { let mut registry = CapabilityRegistry::core(); for ext in extensions { - registry.register(ext.capabilities); + registry.register(ext.capabilities()); } registry } diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 0de3d908..6d814a16 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -328,7 +328,7 @@ fn make_wasmtime_engine() -> wasmtime::Engine { /// The core-only extension set: no domain extensions. Domain-extension /// boot coverage lives in the extension crate that owns the backend. -fn core_extensions() -> Vec> { +fn core_extensions() -> Vec>> { Vec::new() } diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 31f2a8d7..10d808d6 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -22,6 +22,7 @@ //! crate's backend through the same harness. use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use alloy_rpc_types_eth::{Header, Log}; @@ -54,7 +55,7 @@ where { wasm: PathBuf, manifest: ManifestSource, - extensions: Vec>>, + extensions: Vec>>>, ext: E, limits: ModuleLimits, chain: MockChainProvider, @@ -100,8 +101,8 @@ impl TestRuntimeBuilder { self } - /// Register an extension's linker hook and capability namespace. - pub fn extension(mut self, extension: Extension>) -> Self { + /// Register an extension. + pub fn extension(mut self, extension: Arc>>) -> Self { self.extensions.push(extension); self } @@ -109,7 +110,7 @@ impl TestRuntimeBuilder { /// Register several extensions at once. pub fn extensions( mut self, - extensions: impl IntoIterator>>, + extensions: impl IntoIterator>>>, ) -> Self { self.extensions.extend(extensions); self @@ -425,18 +426,31 @@ chain_id = {chain_id} return; }; - let calls = Arc::new(AtomicUsize::new(0)); - let hooked = calls.clone(); - let extension = Extension::>> { - link: Arc::new(move |_linker| { - hooked.fetch_add(1, Ordering::SeqCst); + struct CountingExtension(Arc); + + impl Extension>> for CountingExtension { + fn namespace(&self) -> &'static str { + "test" + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link( + &self, + _linker: &mut wasmtime::component::Linker< + crate::host::state::HostState>>, + >, + ) -> anyhow::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); Ok(()) - }), - capabilities: NamespaceCaps { - prefix: "test:ext/", - ifaces: &[], - }, - }; + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let extension = Arc::new(CountingExtension(calls.clone())); let mut rt = TestRuntime::builder_with_ext(wasm, calls.clone()) .extension(extension) diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs index c98acb31..b7c93271 100644 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ b/crates/shepherd-cow-host/src/ext_cow.rs @@ -4,12 +4,14 @@ //! Shape: a local `bindgen!` for the extension world, a `Host` impl for //! the foreign `HostState` reached through [`ExtState`], a payload //! trait ([`CowBackend`]) the lattice `Ext` member satisfies, and an -//! [`Extension`] bundling the linker hook with the capability namespace. +//! [`Extension`] impl carrying the linker hook and capability namespace. //! //! The bindgen shares `nexum:host/types` with the core bindings via //! `with`, so the `fault` the extension's `cow-api-error` embeds is the //! same type the core host constructs. +use std::marker::PhantomData; +use std::sync::Arc; use std::time::Instant; use alloy_chains::Chain; @@ -18,7 +20,7 @@ use nexum_runtime::host::component::{BuilderContext, ComponentBuilder, RuntimeTy use nexum_runtime::host::extension::Extension; use nexum_runtime::host::state::{ExtState, HostState}; use nexum_runtime::manifest::NamespaceCaps; -use wasmtime::component::HasSelf; +use wasmtime::component::{HasSelf, Linker}; use crate::cow::CowApi; use crate::cow_orderbook::{CowApiError, OrderBookPool}; @@ -84,28 +86,45 @@ impl ComponentBuilder for ReferenceExtBuilder { } } +/// The cow-api extension over a lattice whose `Ext` payload carries a cow +/// backend. +struct CowExtension(PhantomData T>); + +impl Extension for CowExtension +where + T: RuntimeTypes, + T::Ext: CowBackend, +{ + fn namespace(&self) -> &'static str { + "cow" + } + + fn capabilities(&self) -> NamespaceCaps { + COW_CAPABILITIES + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + // Link only the cow-api interface. The whole-world + // `CowExt::add_to_linker` would also re-add the shared + // `nexum:host/types` instance, which the core event-module + // linker already provides, tripping a "defined twice" error. + bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } +} + /// Build the cow extension for a lattice whose `Ext` payload carries a cow /// backend. Wired at the composition root into `build_linker` and /// capability enforcement. -pub fn extension() -> Extension +pub fn extension() -> Arc> where T: RuntimeTypes, T::Ext: CowBackend, { - Extension { - link: std::sync::Arc::new(|linker| { - // Link only the cow-api interface. The whole-world - // `CowExt::add_to_linker` would also re-add the shared - // `nexum:host/types` instance, which the core event-module - // linker already provides, tripping a "defined twice" error. - bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( - linker, - |s| s, - )?; - Ok(()) - }), - capabilities: COW_CAPABILITIES, - } + Arc::new(CowExtension(PhantomData)) } /// Project the backend [`CowApiError`] into the WIT `cow-api-error`. diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index f3a75d9a..559cf28e 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -7,6 +7,7 @@ //! wasm artefacts and skip gracefully when the artefact is absent. use std::path::{Path, PathBuf}; +use std::sync::Arc; use alloy_chains::Chain; use nexum_runtime::bindings::nexum; @@ -33,7 +34,7 @@ impl RuntimeTypes for CowTestTypes { type Ext = ReferenceExt; } -fn cow_extensions() -> Vec> { +fn cow_extensions() -> Vec>> { vec![extension::()] } From ca81a602bda021199a0f6d15e02f58b36b4b1d19 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 21:22:48 +0000 Subject: [PATCH 17/53] refactor: extract world synthesis into nexum-world and de-hardcode the known table The capability table and per-module world synthesis move into a new plain nexum-world library carrying only the core nexum:host rows. Per-namespace rows come from the composition root's extensions.toml registry, parsed and passed in by the macro layer, so no host crate carries a downstream name; synthesis rejects a row that shadows a core capability or another registration. WIT packages resolve crate-locally (wit/deps, then wit/) with an ancestor fallback for the transitional monorepo layout. --- Cargo.lock | 10 +- Cargo.toml | 1 + crates/nexum-macros/Cargo.toml | 2 +- crates/nexum-macros/src/lib.rs | 98 +++--- crates/nexum-macros/src/world.rs | 334 ++---------------- crates/nexum-world/Cargo.toml | 16 + crates/nexum-world/src/lib.rs | 579 +++++++++++++++++++++++++++++++ extensions.toml | 13 + 8 files changed, 687 insertions(+), 366 deletions(-) create mode 100644 crates/nexum-world/Cargo.toml create mode 100644 crates/nexum-world/src/lib.rs create mode 100644 extensions.toml diff --git a/Cargo.lock b/Cargo.lock index 8f3c72a1..0b867ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3579,10 +3579,10 @@ dependencies = [ name = "nexum-macros" version = "0.1.0" dependencies = [ + "nexum-world", "proc-macro2", "quote", "syn 2.0.118", - "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -3699,6 +3699,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nexum-world" +version = "0.1.0" +dependencies = [ + "tempfile", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 2519fd97..d4ee5eb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/nexum-tasks", "crates/nexum-venue-sdk", "crates/nexum-venue-test", + "crates/nexum-world", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml index ad74099f..ec7785bd 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-macros/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] +nexum-world = { path = "../nexum-world" } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } -toml.workspace = true diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 328f3090..72e243a0 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -22,8 +22,6 @@ mod intent_body; mod world; -use std::path::Path; - use proc_macro::TokenStream; use quote::quote; use syn::{DeriveInput, ImplItem, ItemImpl, Type}; @@ -87,8 +85,9 @@ const HANDLERS: [&str; 6] = [ /// The other non-obvious invariant: the wit-bindgen output (`Guest`, /// `Fault`, the `nexum::host::*` modules) lands at the module crate /// root, so the emitted glue and the handler bodies resolve those names -/// there; the WIT package directories are located by walking up from -/// `CARGO_MANIFEST_DIR`. Two corollaries: the consuming crate must +/// there; the WIT package directories resolve against the crate's own +/// `wit/` and `wit/deps/`, then the nearest ancestor carrying the +/// package. Two corollaries: the consuming crate must /// declare `wit-bindgen` as a direct dependency (the emitted /// `wit_bindgen::generate!` call resolves against the consumer's /// namespace), and the crate root must not shadow std prelude names @@ -175,7 +174,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { } let has = |name: &str| present.contains(&name); - let (manifest_path, module_world) = match derive_module_world() { + let (anchors, module_world) = match derive_module_world() { Ok(parts) => parts, Err(msg) => { return syn::Error::new(proc_macro2::Span::call_site(), msg) @@ -234,9 +233,10 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let intent_status_arm = arm("on_intent_status", "IntentStatus"); quote! { - // Anchor a rebuild on the manifest: the emitted world is derived - // from it, so an edited [capabilities] must recompile the module. - const _: &[u8] = ::core::include_bytes!(#manifest_path); + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them, so an edit to either + // must recompile the module. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* wit_bindgen::generate!({ inline: #inline_world, @@ -483,9 +483,7 @@ fn is_plain_type(ty: &Type) -> bool { /// anchor). Shared by the module and venue worlds, which differ only in /// how they turn the declarations into a world. fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), String> { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let manifest_path = Path::new(&manifest_dir).join("module.toml"); + let manifest_path = manifest_dir()?.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( "could not read {} ({e}); {attribute} derives the component's WIT world from the \ @@ -498,13 +496,36 @@ fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), Ok((manifest_path.to_string_lossy().into_owned(), declared)) } +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + /// Read the consuming crate's `module.toml` and synthesize the -/// per-module world from its `[capabilities]` declarations. Returns the -/// manifest path (for the rebuild anchor) alongside the world. -fn derive_module_world() -> Result<(String, world::ModuleWorld), String> { +/// per-module world from its `[capabilities]` declarations plus the +/// extension rows registered in the nearest ancestor `extensions.toml`. +/// Returns the rebuild anchor paths (the manifest, then the registry +/// when one exists) alongside the world. +fn derive_module_world() -> Result<(Vec, world::ModuleWorld), String> { let (manifest_path, declared) = read_manifest_capabilities("#[nexum_sdk::module]")?; - let module_world = world::synthesize(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((manifest_path, module_world)) + let mut anchors = vec![manifest_path.clone()]; + let extensions = match world::find_extensions_manifest(&manifest_dir()?) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + let module_world = + world::synthesize(&declared, &extensions).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) } /// Read the consuming crate's `module.toml` and synthesize the @@ -518,39 +539,14 @@ fn derive_venue_world() -> Result<(String, world::ModuleWorld), String> { Ok((manifest_path, venue_world)) } -/// Locate the workspace `wit/` root (the ancestor directory whose `wit/` -/// contains the `nexum-host` package) and resolve each needed package -/// directory under it. -fn resolve_wit_packages(packages: &[&str]) -> Result, String> { - let manifest = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let mut dir: Option<&Path> = Some(Path::new(&manifest)); - let root = loop { - let Some(cur) = dir else { - return Err(format!( - "could not find a `wit/` directory containing `nexum-host` in any ancestor \ - of {manifest}" - )); - }; - let wit = cur.join("wit"); - if wit.join("nexum-host").is_dir() { - break wit; - } - dir = cur.parent(); - }; - packages - .iter() - .map(|package| { - let path = root.join(package); - if path.is_dir() { - Ok(path.to_string_lossy().into_owned()) - } else { - Err(format!( - "declared capabilities need the `{package}` WIT package, but {} is not \ - a directory", - path.display() - )) - } - }) - .collect() +/// Resolve each needed WIT package directory crate-locally (vendored +/// `wit/deps/`, then own `wit/`), falling back through +/// ancestors for the transitional monorepo layout. +fn resolve_wit_packages(packages: &[String]) -> Result, String> { + Ok( + nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ) } diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index acae4eb1..82de0717 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -1,143 +1,11 @@ -//! Per-module world synthesis: turn the manifest's `[capabilities]` -//! declarations into an inline WIT world whose imports are exactly the -//! declared capability interfaces. -//! -//! The one non-obvious invariant: the capability table here must agree -//! with the runtime's capability registry (`nexum-runtime`'s manifest -//! enforcement) on both the capability names and the WIT interfaces they -//! map to. The runtime cross-checks a component's imports against the -//! manifest at load time; because this module derives the imports from -//! the same manifest, a component built through `#[nexum_sdk::module]` -//! passes that check by construction rather than by relying on the -//! toolchain eliding unused imports. +//! World wiring for the macros: the venue-adapter world synthesis. The +//! module world synthesis, the core capability table, and the extension +//! registry parsing (`extensions.toml`, the composition root's data) +//! live in `nexum-world`, so no crate here carries a downstream name. -use std::fmt::Write as _; - -/// One manifest capability and its world wiring. -struct Capability { - /// The name declared under `[capabilities].required` / `optional`. - name: &'static str, - /// The WIT import the declaration turns into, or `None` for - /// capabilities with no world import (`http` is granted through the - /// SDK's wasi:http client and the host allowlist, not the world). - import: Option<&'static str>, - /// WIT package directories (under the workspace `wit/` root) the - /// import needs on the resolve path, beyond `nexum-host`. - packages: &'static [&'static str], - /// The `bind_host_via_wit_bindgen!` capability ident carrying this - /// capability's host-adapter pieces, if the SDK has a trait seam - /// for it. - adapter: Option<&'static str>, -} - -/// Every capability the macro recognises, in emission order. Mirrors -/// the runtime's core registry plus the extension namespaces the -/// workspace ships (`videre:venue/client`, `shepherd:cow/cow-api`). -const KNOWN: &[Capability] = &[ - Capability { - name: "chain", - import: Some("nexum:host/chain@0.1.0"), - packages: &[], - adapter: Some("chain"), - }, - Capability { - name: "identity", - import: Some("nexum:host/identity@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "local-store", - import: Some("nexum:host/local-store@0.1.0"), - packages: &[], - adapter: Some("local_store"), - }, - Capability { - name: "remote-store", - import: Some("nexum:host/remote-store@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "messaging", - import: Some("nexum:host/messaging@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "logging", - import: Some("nexum:host/logging@0.1.0"), - packages: &[], - adapter: Some("logging"), - }, - Capability { - name: "client", - import: Some("videre:venue/client@0.1.0"), - packages: &["videre-value-flow", "videre-types", "videre-venue"], - adapter: None, - }, - Capability { - name: "cow-api", - import: Some("shepherd:cow/cow-api@0.1.0"), - packages: &["shepherd-cow"], - adapter: None, - }, - Capability { - name: "http", - import: None, - packages: &[], - adapter: None, - }, -]; - -/// The synthesized world plus what the `generate!` call and the host -/// adapter need to go with it. -#[derive(Debug)] -pub struct ModuleWorld { - /// Inline WIT text defining `nexum:module-world/module`. - pub wit: String, - /// WIT package directories (relative to the workspace `wit/` root) - /// the resolve path must carry, in dependency order (a package - /// precedes its dependants). Always starts with the base set the - /// host `event` variant needs. - pub packages: Vec<&'static str>, - /// Capability idents to pass to `bind_host_via_wit_bindgen!`. - pub adapters: Vec<&'static str>, -} - -/// Extract the declared capability names (`required` then `optional`) -/// from the manifest text. A missing or malformed `[capabilities]` -/// section is an error: the emitted world is derived from it, so the -/// macro has nothing to build from without one. -pub fn manifest_capabilities(text: &str) -> Result, String> { - let value: toml::Table = text - .parse() - .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; - let caps = value.get("capabilities").ok_or_else(|| { - "module.toml has no [capabilities] section; the module/adapter macro derives the \ - component's WIT world from [capabilities].required/optional, so declare it (an empty \ - `required = []` is valid)" - .to_string() - })?; - let list = |key: &str| -> Result, String> { - match caps.get(key) { - None => Ok(Vec::new()), - Some(v) => v - .as_array() - .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? - .iter() - .map(|item| { - item.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) - }) - .collect(), - } - }; - let mut names = list("required")?; - names.extend(list("optional")?); - Ok(names) -} +pub use nexum_world::{ + ModuleWorld, find_extensions_manifest, manifest_capabilities, manifest_extensions, synthesize, +}; /// Capabilities a venue adapter may import. A venue speaks one venue's /// protocol over scoped transport and nothing else: chain RPC, @@ -171,27 +39,30 @@ pub fn synthesize_venue(declared: &[String]) -> Result { // value-flow vocabulary they are expressed in) needs the videre // packages on the resolve path beyond the leaf host package, in // dependency order: a package precedes its dependants. - let mut packages = vec![ + let mut packages: Vec = [ "videre-value-flow", "videre-types", "nexum-host", "videre-venue", - ]; - for cap in KNOWN { + ] + .map(str::to_owned) + .into(); + for cap in nexum_world::CORE { if !declared.iter().any(|d| d == cap.name) { continue; } if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); + imports.push_str(&format!(" import {import};\n")); } - // Accumulate any extra WIT packages a venue capability needs, exactly - // as `synthesize` does. All venue-permitted capabilities are - // packageless today, so this leaves the base set untouched; mirroring - // the loop keeps a future venue capability from silently failing to - // reach its package onto the resolve path. + // Accumulate any extra WIT packages a venue capability needs, + // exactly as the module synthesis does. All venue-permitted + // capabilities are packageless today, so this leaves the base set + // untouched; mirroring the loop keeps a future venue capability + // from silently failing to reach its package onto the resolve + // path. for package in cap.packages { - if !packages.contains(package) { - packages.push(package); + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); } } } @@ -216,72 +87,10 @@ pub fn synthesize_venue(declared: &[String]) -> Result { }) } -/// Build the per-module world from the declared capability names -/// (required and optional alike: an optional capability must still be -/// importable, the host decides at load time whether to back or stub -/// it). Unknown names are a compile error so a typo cannot silently -/// drop an import. -pub fn synthesize(declared: &[String]) -> Result { - for name in declared { - if !KNOWN.iter().any(|c| c.name == name.as_str()) { - let known = KNOWN.iter().map(|c| c.name).collect::>().join(", "); - return Err(format!( - "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ - {known}" - )); - } - } - - let mut imports = String::new(); - // `nexum:host` is a leaf package (the `event` variant carries an - // intent-status transition as opaque bytes), so the base resolve set - // is the host package alone; capability declarations append their - // own packages. Dependency order: each directory is parsed against - // the packages before it, so a package precedes its dependants. - let mut packages = vec!["nexum-host"]; - let mut adapters = Vec::new(); - for cap in KNOWN { - if !declared.iter().any(|d| d == cap.name) { - continue; - } - if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); - } - for package in cap.packages { - if !packages.contains(package) { - packages.push(package); - } - } - if let Some(adapter) = cap.adapter { - adapters.push(adapter); - } - } - - let mut wit = String::from( - "package nexum:module-world;\n\nworld module {\n \ - use nexum:host/types@0.1.0.{config, event, fault};\n\n", - ); - wit.push_str(&imports); - wit.push_str( - "\n export init: func(config: config) -> result<_, fault>;\n \ - export on-event: func(event: event) -> result<_, fault>;\n}\n", - ); - - Ok(ModuleWorld { - wit, - packages, - adapters, - }) -} - #[cfg(test)] mod tests { use super::*; - /// The base package set every module world resolves against: - /// `nexum:host` is a leaf package, so it stands alone. - const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; - /// The package set every venue world resolves against: the exported /// adapter face pulls the videre vocabulary, in dependency order. const VENUE_PACKAGES: [&str; 4] = [ @@ -291,60 +100,6 @@ mod tests { "videre-venue", ]; - #[test] - fn logging_only_world_imports_logging_alone() { - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); - assert!(!world.wit.contains("import nexum:host/chain")); - assert!(!world.wit.contains("shepherd:cow")); - assert_eq!(world.packages, MODULE_PACKAGES); - assert_eq!(world.adapters, vec!["logging"]); - } - - #[test] - fn cow_api_pulls_the_shepherd_cow_package() { - let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); - assert!(world.wit.contains("import shepherd:cow/cow-api@0.1.0;")); - assert_eq!(world.packages, vec!["nexum-host", "shepherd-cow"]); - } - - #[test] - fn client_pulls_the_videre_packages() { - let world = synthesize(&["client".to_string()]).unwrap(); - assert!(world.wit.contains("import videre:venue/client@0.1.0;")); - assert_eq!( - world.packages, - vec![ - "nexum-host", - "videre-value-flow", - "videre-types", - "videre-venue" - ] - ); - assert!(world.adapters.is_empty()); - } - - #[test] - fn http_declares_no_world_import() { - let world = synthesize(&["logging".to_string(), "http".to_string()]).unwrap(); - assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, MODULE_PACKAGES); - } - - #[test] - fn duplicate_declarations_emit_one_import() { - let world = synthesize(&["chain".to_string(), "chain".to_string()]).unwrap(); - assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); - assert_eq!(world.adapters, vec!["chain"]); - } - - #[test] - fn unknown_capability_is_rejected_with_the_known_list() { - let err = synthesize(&["telepathy".to_string()]).unwrap_err(); - assert!(err.contains("unknown capability `telepathy`")); - assert!(err.contains("logging")); - } - #[test] fn venue_world_exports_the_adapter_face() { let world = synthesize_venue(&["chain".to_string()]).unwrap(); @@ -400,51 +155,4 @@ mod tests { assert!(err.contains("venue adapter"), "message was: {err}"); } } - - #[test] - fn manifest_capabilities_reads_required_and_optional() { - let caps = manifest_capabilities( - r#" -[capabilities] -required = ["logging", "chain"] -optional = ["remote-store"] - -[capabilities.http] -allow = [] -"#, - ) - .unwrap(); - assert_eq!(caps, vec!["logging", "chain", "remote-store"]); - } - - #[test] - fn manifest_without_capabilities_section_is_an_error() { - let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); - assert!(err.contains("[capabilities]")); - } - - #[test] - fn manifest_with_non_string_capability_is_an_error() { - let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); - assert!(err.contains("only strings")); - } - - #[test] - fn world_is_valid_wit_shape() { - // Not a full WIT parse (that is the module build's job); pin the - // structural pieces the runtime contract depends on. - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.starts_with("package nexum:module-world;")); - assert!(world.wit.contains("world module {")); - assert!( - world - .wit - .contains("export init: func(config: config) -> result<_, fault>;") - ); - assert!( - world - .wit - .contains("export on-event: func(event: event) -> result<_, fault>;") - ); - } } diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml new file mode 100644 index 00000000..088d377a --- /dev/null +++ b/crates/nexum-world/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "nexum-world" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Per-module WIT world synthesis: the core capability table, registry-driven extension rows, manifest parsing, and crate-local WIT package resolution." + +[lints] +workspace = true + +[dependencies] +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs new file mode 100644 index 00000000..2b39f860 --- /dev/null +++ b/crates/nexum-world/src/lib.rs @@ -0,0 +1,579 @@ +//! Per-module world synthesis: turn a manifest's `[capabilities]` +//! declarations into an inline WIT world whose imports are exactly the +//! declared capability interfaces. +//! +//! The one non-obvious invariant: the capability rows here must agree +//! with the runtime's capability registry (`nexum-runtime`'s manifest +//! enforcement) on both the capability names and the WIT interfaces they +//! map to. The runtime cross-checks a component's imports against the +//! manifest at load time; because the imports are derived from the same +//! manifest, a macro-built component passes that check by construction +//! rather than by relying on the toolchain eliding unused imports. +//! +//! The table here carries only the core `nexum:host` rows. Per-namespace +//! rows come from the composition root's `extensions.toml` registry +//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], +//! so this crate carries no downstream name. + +use std::path::{Path, PathBuf}; + +/// One manifest capability and its world wiring. +pub struct Capability { + /// The name declared under `[capabilities].required` / `optional`. + pub name: &'static str, + /// The WIT import the declaration turns into, or `None` for + /// capabilities with no world import (`http` is granted through the + /// SDK's wasi:http client and the host allowlist, not the world). + pub import: Option<&'static str>, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`. + pub packages: &'static [&'static str], + /// The `bind_host_via_wit_bindgen!` capability ident carrying this + /// capability's host-adapter pieces, if the SDK has a trait seam + /// for it. + pub adapter: Option<&'static str>, +} + +/// The core capability rows, in emission order. Mirrors the runtime's +/// core registry and nothing else; extension rows are the caller's. +pub const CORE: &[Capability] = &[ + Capability { + name: "chain", + import: Some("nexum:host/chain@0.1.0"), + packages: &[], + adapter: Some("chain"), + }, + Capability { + name: "identity", + import: Some("nexum:host/identity@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "local-store", + import: Some("nexum:host/local-store@0.1.0"), + packages: &[], + adapter: Some("local_store"), + }, + Capability { + name: "remote-store", + import: Some("nexum:host/remote-store@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "messaging", + import: Some("nexum:host/messaging@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "logging", + import: Some("nexum:host/logging@0.1.0"), + packages: &[], + adapter: Some("logging"), + }, + Capability { + name: "http", + import: None, + packages: &[], + adapter: None, + }, +]; + +/// One registered extension row: a per-namespace capability a +/// composition root declares in its `extensions.toml`. An extension +/// always has a WIT import and never a host-adapter ident (adapter +/// seams are core-only). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionRow { + /// The name modules declare under `[capabilities]`. + pub name: String, + /// The WIT import the declaration turns into. + pub import: String, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`, in dependency order. + pub packages: Vec, +} + +/// The synthesized world plus what the `generate!` call and the host +/// adapter need to go with it. +#[derive(Debug)] +pub struct ModuleWorld { + /// Inline WIT text defining `nexum:module-world/module`. + pub wit: String, + /// WIT package directories the resolve path must carry, in + /// dependency order (a package precedes its dependants). Always + /// starts with the base set the host `event` variant needs. + pub packages: Vec, + /// Capability idents to pass to `bind_host_via_wit_bindgen!`. + pub adapters: Vec<&'static str>, +} + +/// Extract the declared capability names (`required` then `optional`) +/// from the manifest text. A missing or malformed `[capabilities]` +/// section is an error: the emitted world is derived from it, so the +/// synthesis has nothing to build from without one. +pub fn manifest_capabilities(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + let caps = value.get("capabilities").ok_or_else(|| { + "module.toml has no [capabilities] section; the module/adapter macro derives the \ + component's WIT world from [capabilities].required/optional, so declare it (an empty \ + `required = []` is valid)" + .to_string() + })?; + let list = |key: &str| -> Result, String> { + match caps.get(key) { + None => Ok(Vec::new()), + Some(v) => v + .as_array() + .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) + }) + .collect(), + } + }; + let mut names = list("required")?; + names.extend(list("optional")?); + Ok(names) +} + +/// Parse the registered extension rows from an `extensions.toml`. Each +/// `[extensions.]` table carries the WIT `import` the declaration +/// turns into and the extra `packages` its resolve path needs. A file +/// without an `[extensions]` section registers nothing. +pub fn manifest_extensions(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("extensions.toml is not valid TOML: {e}"))?; + let Some(extensions) = value.get("extensions") else { + return Ok(Vec::new()); + }; + let extensions = extensions + .as_table() + .ok_or_else(|| "[extensions] must be a table of `[extensions.]` rows".to_string())?; + extensions + .iter() + .map(|(name, row)| { + let row = row + .as_table() + .ok_or_else(|| format!("[extensions.{name}] must be a table"))?; + let import = row + .get("import") + .and_then(toml::Value::as_str) + .ok_or_else(|| format!("[extensions.{name}] must carry a string `import`"))? + .to_owned(); + let packages = match row.get("packages") { + None => Vec::new(), + Some(value) => value + .as_array() + .ok_or_else(|| { + format!("[extensions.{name}].packages must be an array of strings") + })? + .iter() + .map(|item| { + item.as_str().map(str::to_owned).ok_or_else(|| { + format!("[extensions.{name}].packages must contain only strings") + }) + }) + .collect::>()?, + }; + Ok(ExtensionRow { + name: name.clone(), + import, + packages, + }) + }) + .collect() +} + +/// Find the extension registry for a build rooted at `start`: the +/// nearest ancestor `extensions.toml`. `None` means no registered +/// extensions. +pub fn find_extensions_manifest(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let candidate = cur.join("extensions.toml"); + if candidate.is_file() { + return Some(candidate); + } + dir = cur.parent(); + } + None +} + +/// Build the per-module world from the declared capability names +/// (required and optional alike: an optional capability must still be +/// importable, the host decides at load time whether to back or stub +/// it). `extensions` carries the per-namespace rows of the registered +/// extensions, emitted after the core rows. Unknown names are an error +/// so a typo cannot silently drop an import; a registered name that +/// shadows a core row or another registration is an error so a +/// colliding registry cannot emit a duplicate import. +pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { + for (idx, ext) in extensions.iter().enumerate() { + if CORE.iter().any(|c| c.name == ext.name) + || extensions[..idx].iter().any(|prior| prior.name == ext.name) + { + return Err(format!( + "extension capability `{}` collides with an already-registered capability; \ + names must be unique across the core table and the registered extensions", + ext.name + )); + } + } + + let known = || { + CORE.iter() + .map(|c| c.name) + .chain(extensions.iter().map(|e| e.name.as_str())) + }; + for name in declared { + if !known().any(|k| k == name.as_str()) { + let names = known().collect::>().join(", "); + return Err(format!( + "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ + {names}" + )); + } + } + + let mut imports = String::new(); + // `nexum:host` is a leaf package (the `event` variant carries status + // transitions as opaque bytes), so the base resolve set + // is the host package alone; capability declarations append their + // own packages. Dependency order: each directory is parsed against + // the packages before it, so a package precedes its dependants. + let mut packages = vec!["nexum-host".to_owned()]; + let mut adapters = Vec::new(); + for cap in CORE { + if !declared.iter().any(|d| d == cap.name) { + continue; + } + if let Some(import) = cap.import { + imports.push_str(&format!(" import {import};\n")); + } + for package in cap.packages { + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); + } + } + if let Some(adapter) = cap.adapter { + adapters.push(adapter); + } + } + for ext in extensions { + if !declared.contains(&ext.name) { + continue; + } + imports.push_str(&format!(" import {};\n", ext.import)); + for package in &ext.packages { + if !packages.contains(package) { + packages.push(package.clone()); + } + } + } + + let mut wit = String::from( + "package nexum:module-world;\n\nworld module {\n \ + use nexum:host/types@0.1.0.{config, event, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export on-event: func(event: event) -> result<_, fault>;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + adapters, + }) +} + +/// Resolve each WIT package directory for a component build rooted at +/// `start` (the consuming crate's manifest directory). A package +/// resolves crate-locally, vendored `wit/deps/` before own +/// `wit/`; a crate not carrying it falls back to the nearest +/// ancestor `wit/` that does (the transitional monorepo layout). +pub fn resolve_wit_packages>( + start: &Path, + packages: &[S], +) -> Result, String> { + packages + .iter() + .map(|package| { + let package = package.as_ref(); + resolve_wit_package(start, package).ok_or_else(|| { + format!( + "declared capabilities need the `{package}` WIT package, but neither \ + `wit/deps/{package}` nor `wit/{package}` exists under {} or any ancestor", + start.display() + ) + }) + }) + .collect() +} + +/// Find one package directory: crate-local `wit/deps/` then +/// `wit/`, walking up on a miss. +fn resolve_wit_package(start: &Path, package: &str) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let wit = cur.join("wit"); + for candidate in [wit.join("deps").join(package), wit.join(package)] { + if candidate.is_dir() { + return Some(candidate); + } + } + dir = cur.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The base package set every module world resolves against: + /// `nexum:host` is a leaf package, so it stands alone. + const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; + + /// A stand-in extension row, as a registered extension would pass. + fn ext() -> Vec { + vec![ExtensionRow { + name: "acme".to_owned(), + import: "acme:ext/api@0.1.0".to_owned(), + packages: vec!["acme-ext".to_owned()], + }] + } + + #[test] + fn logging_only_world_imports_logging_alone() { + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); + assert!(!world.wit.contains("import nexum:host/chain")); + assert_eq!(world.packages, MODULE_PACKAGES); + assert_eq!(world.adapters, vec!["logging"]); + } + + #[test] + fn extension_row_emits_its_import_and_packages() { + let world = synthesize(&["logging".to_string(), "acme".to_string()], &ext()).unwrap(); + assert!(world.wit.contains("import acme:ext/api@0.1.0;")); + assert_eq!(world.packages, vec!["nexum-host", "acme-ext"]); + } + + #[test] + fn undeclared_extension_row_stays_out_of_the_world() { + let world = synthesize(&["logging".to_string()], &ext()).unwrap(); + assert!(!world.wit.contains("acme")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn extension_shadowing_a_core_name_is_rejected() { + let rows = vec![ExtensionRow { + name: "chain".to_owned(), + import: "acme:ext/chain@0.1.0".to_owned(), + packages: Vec::new(), + }]; + let err = synthesize(&["chain".to_string()], &rows).unwrap_err(); + assert!(err.contains("extension capability `chain` collides")); + } + + #[test] + fn duplicate_extension_registration_is_rejected() { + let mut rows = ext(); + rows.extend(ext()); + let err = synthesize(&[], &rows).unwrap_err(); + assert!(err.contains("extension capability `acme` collides")); + } + + #[test] + fn core_table_carries_no_extension_row() { + assert!( + CORE.iter() + .all(|c| c.import.is_none_or(|i| i.starts_with("nexum:host/"))) + ); + assert!(CORE.iter().all(|c| c.packages.is_empty())); + } + + #[test] + fn http_declares_no_world_import() { + let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn duplicate_declarations_emit_one_import() { + let world = synthesize(&["chain".to_string(), "chain".to_string()], &[]).unwrap(); + assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); + assert_eq!(world.adapters, vec!["chain"]); + } + + #[test] + fn unknown_capability_is_rejected_with_the_known_list() { + let err = synthesize(&["telepathy".to_string()], &ext()).unwrap_err(); + assert!(err.contains("unknown capability `telepathy`")); + assert!(err.contains("logging")); + assert!(err.contains("acme")); + } + + #[test] + fn manifest_extensions_reads_rows() { + let rows = manifest_extensions( + r#" +[extensions.acme] +import = "acme:ext/api@0.1.0" +packages = ["acme-base", "acme-ext"] + +[extensions.beta] +import = "beta:ext/api@0.1.0" +"#, + ) + .unwrap(); + assert_eq!(rows, { + let mut expected = ext(); + expected[0].packages = vec!["acme-base".to_owned(), "acme-ext".to_owned()]; + expected.push(ExtensionRow { + name: "beta".to_owned(), + import: "beta:ext/api@0.1.0".to_owned(), + packages: Vec::new(), + }); + expected + }); + } + + #[test] + fn manifest_without_extensions_section_registers_nothing() { + assert_eq!(manifest_extensions("").unwrap(), Vec::new()); + } + + #[test] + fn extension_row_without_an_import_is_an_error() { + let err = manifest_extensions("[extensions.acme]\npackages = []\n").unwrap_err(); + assert!(err.contains("[extensions.acme] must carry a string `import`")); + } + + #[test] + fn extension_row_with_non_string_package_is_an_error() { + let err = + manifest_extensions("[extensions.acme]\nimport = \"a:b/c@0.1.0\"\npackages = [1]\n") + .unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn manifest_capabilities_reads_required_and_optional() { + let caps = manifest_capabilities( + r#" +[capabilities] +required = ["logging", "chain"] +optional = ["remote-store"] + +[capabilities.http] +allow = [] +"#, + ) + .unwrap(); + assert_eq!(caps, vec!["logging", "chain", "remote-store"]); + } + + #[test] + fn manifest_without_capabilities_section_is_an_error() { + let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); + assert!(err.contains("[capabilities]")); + } + + #[test] + fn manifest_with_non_string_capability_is_an_error() { + let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn world_is_valid_wit_shape() { + // Not a full WIT parse (that is the module build's job); pin the + // structural pieces the runtime contract depends on. + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.starts_with("package nexum:module-world;")); + assert!(world.wit.contains("world module {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!( + world + .wit + .contains("export on-event: func(event: event) -> result<_, fault>;") + ); + } + + #[test] + fn resolution_prefers_vendored_deps_over_own_wit() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/deps/pkg")).unwrap(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let paths = resolve_wit_packages(root, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/deps/pkg")]); + } + + #[test] + fn resolution_falls_back_to_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/pkg")]); + } + + #[test] + fn crate_local_package_shadows_the_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(leaf.join("wit/deps/pkg")).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![leaf.join("wit/deps/pkg")]); + } + + #[test] + fn extension_registry_resolves_from_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("extensions.toml"), "").unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + assert_eq!( + find_extensions_manifest(&leaf), + Some(root.join("extensions.toml")) + ); + } + + #[test] + fn absent_extension_registry_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_extensions_manifest(dir.path()), None); + } + + #[test] + fn missing_package_names_the_paths_tried() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); + assert!(err.contains("`pkg` WIT package")); + assert!(err.contains("wit/deps/pkg")); + } +} diff --git a/extensions.toml b/extensions.toml new file mode 100644 index 00000000..6a280f02 --- /dev/null +++ b/extensions.toml @@ -0,0 +1,13 @@ +# Extension capability registry for this composition root: the +# per-namespace rows the module world synthesis emits beyond the core +# nexum:host table. Each row names the WIT import a `[capabilities]` +# declaration turns into and the package directories its resolve path +# needs, in dependency order. + +[extensions.client] +import = "videre:venue/client@0.1.0" +packages = ["videre-value-flow", "videre-types", "videre-venue"] + +[extensions.cow-api] +import = "shepherd:cow/cow-api@0.1.0" +packages = ["shepherd-cow"] From 0ddfa130351ae3b5e12fa8c2b08663a6f261d630 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 23:05:19 +0000 Subject: [PATCH 18/53] feat: extract the supervised-actor primitive and generic provider boot --- crates/nexum-runtime/src/host/actor.rs | 58 ++++ crates/nexum-runtime/src/host/extension.rs | 45 ++- crates/nexum-runtime/src/host/mod.rs | 3 + .../nexum-runtime/src/host/venue_registry.rs | 273 ++++++++++------- .../src/manifest/capabilities.rs | 48 +-- crates/nexum-runtime/src/manifest/load.rs | 31 +- crates/nexum-runtime/src/manifest/mod.rs | 2 +- crates/nexum-runtime/src/manifest/types.rs | 59 ++-- crates/nexum-runtime/src/supervisor.rs | 278 ++++++++++-------- crates/nexum-runtime/src/supervisor/tests.rs | 59 +++- 10 files changed, 550 insertions(+), 306 deletions(-) create mode 100644 crates/nexum-runtime/src/host/actor.rs diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs new file mode 100644 index 00000000..10d5c461 --- /dev/null +++ b/crates/nexum-runtime/src/host/actor.rs @@ -0,0 +1,58 @@ +//! The supervised host-actor primitive: one component instance the host +//! holds and others call. The store is refuelled before each guest call, +//! a trap is projected onto a typed fault instead of unwinding into the +//! caller, and each instance sits behind an [`ActorSlot`] async mutex held +//! across the guest await, so one store never runs two guest calls at once. + +use std::sync::Arc; + +use tokio::sync::Mutex as AsyncMutex; +use wasmtime::Store; + +use super::component::RuntimeTypes; +use super::state::HostState; + +/// One supervised actor behind its serialising mutex. A wasmtime `Store` +/// is not `Sync`; concurrent callers queue here. +pub type ActorSlot = Arc>; + +/// A guest call failed outside the component's typed error space. +#[derive(Debug, thiserror::Error)] +pub enum ActorFault { + /// The pre-call refuel failed; the guest was never entered. + #[error("refuel failed: {0}")] + Refuel(wasmtime::Error), + /// The guest trapped. Carries the root cause only; the wasm frame + /// list stays out of the caller-facing message. + #[error("trapped: {}", .0.root_cause())] + Trap(wasmtime::Error), +} + +/// A supervised component store: refuelled before each guest call so every +/// invocation starts from a full budget, with traps projected onto +/// [`ActorFault`]. +pub struct SupervisedStore { + store: Store>, + fuel_per_call: u64, +} + +impl SupervisedStore { + /// Supervise an instantiated store with a per-call fuel budget. + pub fn new(store: Store>, fuel_per_call: u64) -> Self { + Self { + store, + fuel_per_call, + } + } + + /// Refuel, then run one guest call against the store. + pub async fn call( + &mut self, + call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, + ) -> Result { + self.store + .set_fuel(self.fuel_per_call) + .map_err(ActorFault::Refuel)?; + call(&mut self.store).await.map_err(ActorFault::Trap) + } +} diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 6f57e6a8..ed14de95 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -60,13 +60,46 @@ pub trait ProviderKind: Send + Sync + 'static { /// Adds the provider's imports to a provider linker. fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - /// Install one instantiated provider behind the extension's service. + /// Instantiate one provider and install it behind the owning service. + /// [`Installed::Dead`] reports a failed guest `init`; an `Err` is a + /// boot error. async fn install( &self, - component: &Component, - store: Store>, + instance: ProviderInstance<'_, T>, service: &Arc, - ) -> anyhow::Result<()>; + ) -> anyhow::Result; +} + +/// One provider instance ready to install: the compiled component, the +/// linker the kind's [`ProviderKind::link`] populated, the supervised +/// store, the manifest `[config]`, and the per-call fuel budget. +pub struct ProviderInstance<'a, T: RuntimeTypes> { + /// Compiled provider component. + pub component: &'a Component, + /// Linker carrying the kind's imports plus the WASI base. + pub linker: &'a Linker>, + /// Store the instance runs in; the kind takes ownership. + pub store: Store>, + /// Manifest `[config]` handed to the guest `init`. + pub config: Vec<(String, String)>, + /// Fuel budget applied before each routed guest call. + pub fuel_per_call: u64, +} + +/// Outcome of one provider install. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Installed { + /// `init` succeeded; the instance is installed and routable. + Live, + /// `init` returned a fault; the instance is loaded but not routable. + Dead, +} + +/// Downcast a type-erased service to `S`. `None` when the type differs. +pub fn downcast_service(service: &Arc) -> Option> { + let service = Arc::clone(service); + let erased: Arc = service; + erased.downcast().ok() } /// Immutable per-namespace service map: each extension's [`HostService`] @@ -103,9 +136,7 @@ impl HostServices { /// The service under `namespace`, downcast to its concrete type. /// `None` when the namespace is absent or the type does not match. pub fn get(&self, namespace: &str) -> Option> { - let service = Arc::clone(self.0.get(namespace)?); - let erased: Arc = service; - erased.downcast().ok() + downcast_service(self.0.get(namespace)?) } /// The raw type-erased service under `namespace`. diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 1cf10f5d..ac9e0634 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -19,11 +19,14 @@ //! namespace) an extension is wired in through at the composition root. //! Domain extensions such as cow-api live in their own crates and plug //! in through this seam rather than being hard-linked into the core host. +//! - [`actor`]: the supervised host-actor primitive provider instances +//! run behind (refuel, trap projection, serialising slot). //! - [`http`]: the wasi:http outgoing gate enforcing the per-module //! `[capabilities.http].allow` list. //! - [`logs`]: the typed module-log pipeline (capture points -> router -> //! tracing event + retention store) and its embedder read surface. +pub mod actor; pub mod component; pub mod error; pub mod extension; diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index f13d87aa..8e93942d 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -9,9 +9,9 @@ //! Status and cancel are pass-throughs; they are not submissions, so they //! skip the header, the guard, and the quota. //! -//! Invocation is serialised per adapter. A wasmtime `Store` is not `Sync`, -//! so each adapter sits behind its own async mutex: concurrent client calls -//! to the same venue queue on that mutex, while calls to different venues run +//! Invocation is serialised per adapter through the supervised-actor +//! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent +//! client calls to the same venue queue while calls to different venues run //! in parallel. The lock is held across the guest await, which is the whole //! point - it is the actor boundary that keeps one adapter store //! single-threaded. @@ -28,17 +28,24 @@ use std::fmt; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use anyhow::{Context, anyhow}; +use async_trait::async_trait; use futures::future::BoxFuture; use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; -use tracing::warn; +use tracing::{info, warn}; use wasmtime::Store; +use wasmtime::component::HasSelf; use crate::bindings::{ IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, - VenueAdapter, VenueError, + VenueAdapter, VenueError, nexum, }; +use crate::host::actor::{ActorFault, ActorSlot, SupervisedStore}; use crate::host::component::RuntimeTypes; +use crate::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; use crate::host::state::HostState; /// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. @@ -207,41 +214,31 @@ pub trait VenueInvoker: Send { fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; } -/// The live adapter: a supervised wasmtime `Store` plus the `venue-adapter` -/// bindings, refuelled before each guest call. A trap is projected onto -/// `unavailable` rather than propagated: a misbehaving adapter must not be -/// the caller's fault, and it must not unwind through the registry into the -/// calling module's store. +/// The live adapter: a [`SupervisedStore`] plus the `venue-adapter` +/// bindings. Each guest call is refuelled by the primitive; a trap is +/// projected onto `unavailable` rather than propagated, because a +/// misbehaving adapter must not be the caller's fault and must not unwind +/// through the registry into the calling module's store. pub struct VenueActor { - store: Store>, + actor: SupervisedStore, bindings: VenueAdapter, - fuel_per_call: u64, } impl VenueActor { /// Wrap an instantiated adapter store for routing. pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { Self { - store, + actor: SupervisedStore::new(store, fuel_per_call), bindings, - fuel_per_call, } } - - /// Refuel the store before a guest call so each invocation starts from a - /// full budget, mirroring the supervisor's per-event refuel. - fn refuel(&mut self) -> Result<(), VenueError> { - self.store - .set_fuel(self.fuel_per_call) - .map_err(|e| VenueError::Unavailable(format!("adapter refuel failed: {e}"))) - } } -/// Project a wasmtime trap into the venue-error space. The root cause is -/// carried so an operator sees why the adapter died without the wasm frame -/// list leaking to the calling module. -fn trap_to_venue_error(trap: wasmtime::Error) -> VenueError { - VenueError::Unavailable(format!("adapter trapped: {}", trap.root_cause())) +/// Project an actor fault into the venue-error space. The fault carries +/// the root cause only, so an operator sees why the adapter died without +/// the wasm frame list leaking to the calling module. +fn venue_fault(fault: ActorFault) -> VenueError { + VenueError::Unavailable(format!("adapter {fault}")) } impl VenueInvoker for VenueActor { @@ -250,31 +247,21 @@ impl VenueInvoker for VenueActor { body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_derive_header(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_derive_header(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_quote(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_quote(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } @@ -283,52 +270,37 @@ impl VenueInvoker for VenueActor { body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_submit(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_submit(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_status(&mut self.store, &receipt) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_status(store, &receipt).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_cancel(&mut self.store, &receipt) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_cancel(store, &receipt).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } } -/// One installed adapter behind its serialising mutex. -type AdapterSlot = Arc>; +/// One installed adapter behind its serialising slot. +type AdapterSlot = ActorSlot; /// Per-caller charge history, pruned to the quota window on each touch. #[derive(Default)] @@ -380,9 +352,11 @@ fn status_body(status: IntentStatus) -> StatusBody { /// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; /// every module store carries the same handle, so a submission from any -/// module reaches the same adapters and the same quota ledger. +/// module reaches the same adapters and the same quota ledger. Adapters +/// install through the shared handle at provider boot, before any client +/// call routes. struct VenueRegistryInner { - adapters: HashMap, + adapters: Mutex>, guard: Arc, quota: SubmitQuota, ledger: Mutex, @@ -400,6 +374,9 @@ pub struct VenueRegistry { inner: Arc, } +/// The registry is the venue-routing host service. +impl HostService for VenueRegistry {} + impl VenueRegistry { /// An empty registry: no adapters, the unit guard, the default quota. /// This is what an adapter store (which cannot call the client face) and @@ -408,10 +385,28 @@ impl VenueRegistry { VenueRegistryBuilder::new(SubmitQuota::default()).build() } + /// Install an adapter under its venue id. Rejects a duplicate id: two + /// adapters answering the same venue would silently shadow one another, + /// which is a config error worth failing boot over. + pub fn install( + &self, + venue: VenueId, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + if adapters.contains_key(&venue) { + return Err(DuplicateVenue { venue }); + } + adapters.insert(venue, Arc::new(AsyncMutex::new(invoker))); + Ok(()) + } + /// Resolve a venue id to its installed adapter slot. fn resolve(&self, venue: &VenueId) -> Result { self.inner .adapters + .lock() + .expect("adapter map poisoned") .get(venue) .cloned() .ok_or(VenueError::UnknownVenue) @@ -683,7 +678,85 @@ impl VenueRegistry { /// Number of installed, routable adapters. pub fn venue_count(&self) -> usize { - self.inner.adapters.len() + self.inner + .adapters + .lock() + .expect("adapter map poisoned") + .len() + } +} + +/// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` +/// component and installs its actor in the venue registry. Registered by +/// the boot path while the registry lives in-core; the videre extension +/// takes it over. +pub struct VenueAdapterKind; + +impl VenueAdapterKind { + /// The manifest kind spelling. + pub const KIND: &'static str = "venue-adapter"; +} + +#[async_trait] +impl ProviderKind for VenueAdapterKind { + fn kind(&self) -> &'static str { + Self::KIND + } + + fn link(&self, linker: &mut wasmtime::component::Linker>) -> anyhow::Result<()> { + // The scoped transport only; the WASI base is the host's, and the + // withheld core interfaces fail instantiation. + nexum::host::chain::add_to_linker::, HasSelf>>(linker, |s| s)?; + nexum::host::messaging::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } + + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result { + let registry = downcast_service::(service) + .ok_or_else(|| anyhow!("the venue-adapter kind requires the venue-registry service"))?; + let ProviderInstance { + component, + linker, + mut store, + config, + fuel_per_call, + } = instance; + let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) + .await + .map_err(anyhow::Error::from) + .context("instantiate adapter")?; + // The venue id is the adapter's namespace: its manifest name. + let venue_id = VenueId::from(&*store.data().run.module); + match bindings + .call_init(&mut store, &config) + .await + .map_err(anyhow::Error::from)? + { + Ok(()) => info!(adapter = %venue_id, "adapter init succeeded"), + Err(e) => { + warn!( + adapter = %venue_id, + kind = crate::host::error::fault_label(&e), + message = crate::host::error::fault_message(&e), + "adapter init failed - loaded but marked dead", + ); + return Ok(Installed::Dead); + } + } + registry + .install( + venue_id.clone(), + VenueActor::new(store, bindings, fuel_per_call), + ) + .with_context(|| format!("install adapter {venue_id}"))?; + Ok(Installed::Live) } } @@ -713,12 +786,11 @@ fn prune(history: &mut VecDeque, window: Duration) { } } -/// Assembles a [`VenueRegistry`]: adapters install first (at supervisor -/// boot, before any module store carries the built registry), then the -/// registry freezes. The guard defaults to the unit guard; the egress-guard +/// Assembles a [`VenueRegistry`]'s policy: guard, quota, and watch bounds +/// freeze at build; adapters install afterwards through the shared handle +/// at provider boot. The guard defaults to the unit guard; the egress-guard /// epic overrides it here. pub struct VenueRegistryBuilder { - adapters: HashMap, guard: Arc, quota: SubmitQuota, watch_limit: WatchLimit, @@ -729,7 +801,6 @@ impl VenueRegistryBuilder { /// the default watch limit. pub fn new(quota: SubmitQuota) -> Self { Self { - adapters: HashMap::new(), guard: Arc::new(()), quota, watch_limit: WatchLimit::default(), @@ -750,22 +821,6 @@ impl VenueRegistryBuilder { self } - /// Install an adapter under its venue id. Rejects a duplicate id: two - /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. - pub fn install( - &mut self, - venue: VenueId, - invoker: impl VenueInvoker + 'static, - ) -> Result<(), DuplicateVenue> { - if self.adapters.contains_key(&venue) { - return Err(DuplicateVenue { venue }); - } - self.adapters - .insert(venue, Arc::new(AsyncMutex::new(invoker))); - Ok(()) - } - /// Freeze the builder into a shared registry. pub fn build(self) -> VenueRegistry { if self.quota.max_charges == 0 { @@ -784,7 +839,7 @@ impl VenueRegistryBuilder { WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); VenueRegistry { inner: Arc::new(VenueRegistryInner { - adapters: self.adapters, + adapters: Mutex::new(HashMap::new()), guard: self.guard, quota, watch_limit, @@ -1009,8 +1064,9 @@ mod tests { if let Some(guard) = guard { builder = builder.with_guard(guard); } - builder.install(cow(), adapter).expect("install adapter"); - builder.build() + let registry = builder.build(); + registry.install(cow(), adapter).expect("install adapter"); + registry } #[tokio::test] @@ -1310,13 +1366,13 @@ mod tests { #[test] fn duplicate_venue_id_is_rejected() { - let mut builder = VenueRegistryBuilder::new(SubmitQuota::default()); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); let a = Arc::new(StubCalls::default()); let b = Arc::new(StubCalls::default()); - builder + registry .install(cow(), StubAdapter::new(a)) .expect("first install"); - let err = builder + let err = registry .install(cow(), StubAdapter::new(b)) .expect_err("second install collides"); assert_eq!(err.venue, cow()); @@ -1467,10 +1523,11 @@ mod tests { /// A registry with the given watch bounds and one echo-receipt-capable /// stub adapter under `cow`. fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { - let mut builder = - VenueRegistryBuilder::new(SubmitQuota::default()).with_watch_limit(watch_limit); - builder.install(cow(), adapter).expect("install adapter"); - builder.build() + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(watch_limit) + .build(); + registry.install(cow(), adapter).expect("install adapter"); + registry } #[tokio::test] diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 4264c6e6..8ad2b4d3 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -53,20 +53,20 @@ pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: VENUE_CAPABILITIES, }; -/// The interfaces a `venue-adapter` world links: the scoped transport -/// only. An adapter has no local-store, remote-store, identity, or -/// logging - it moves bytes to and from its venue and nothing else. `http` -/// is not listed here for the same reason it is not in the core set: it +/// The interfaces a provider world links: the scoped transport only. A +/// provider has no local-store, remote-store, identity, or logging - it +/// moves bytes to and from its counterparty and nothing else. `http` is +/// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const ADAPTER_CAPABILITIES: &[&str] = &["chain", "messaging"]; +pub const PROVIDER_CAPABILITIES: &[&str] = &["chain", "messaging"]; -/// The adapter namespace: the same `nexum:host/` prefix as core but only -/// the scoped-transport interfaces. Validating an adapter manifest against +/// The provider namespace: the same `nexum:host/` prefix as core but only +/// the scoped-transport interfaces. Validating a provider manifest against /// a registry built from this namespace rejects a declaration of any core -/// interface an adapter must not reach (e.g. `local-store`) as unknown. -pub const ADAPTER_NAMESPACE: NamespaceCaps = NamespaceCaps { +/// interface a provider must not reach (e.g. `local-store`) as unknown. +pub const PROVIDER_NAMESPACE: NamespaceCaps = NamespaceCaps { prefix: "nexum:host/", - ifaces: ADAPTER_CAPABILITIES, + ifaces: PROVIDER_CAPABILITIES, }; /// Import prefix of the wasi:http package. Every interface under it @@ -135,14 +135,14 @@ impl CapabilityRegistry { } } - /// The registry a venue adapter validates against: only the scoped - /// transport interfaces plus `http`. An adapter manifest that declares + /// The registry a provider validates against: only the scoped + /// transport interfaces plus `http`. A provider manifest that declares /// a core-only capability (e.g. `local-store`) fails as unknown here, - /// and the adapter linker withholds the same interfaces so the + /// and the provider linker withholds the same interfaces so the /// component cannot instantiate against them either. - pub fn adapter() -> Self { + pub fn provider() -> Self { Self { - namespaces: vec![ADAPTER_NAMESPACE], + namespaces: vec![PROVIDER_NAMESPACE], } } @@ -446,11 +446,11 @@ mod tests { } #[test] - fn adapter_registry_knows_only_scoped_transport() { + fn provider_registry_knows_only_scoped_transport() { // The scoped transport plus http are known; the core-only - // interfaces an adapter must not reach are not, so a manifest + // interfaces a provider must not reach are not, so a manifest // declaring them fails validation as unknown. - let r = CapabilityRegistry::adapter(); + let r = CapabilityRegistry::provider(); assert!(r.is_known("chain")); assert!(r.is_known("messaging")); assert!(r.is_known("http")); @@ -461,8 +461,8 @@ mod tests { } #[test] - fn adapter_registry_maps_transport_imports_but_not_core_only() { - let r = CapabilityRegistry::adapter(); + fn provider_registry_maps_transport_imports_but_not_core_only() { + let r = CapabilityRegistry::provider(); assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( r.wit_import_to_cap("nexum:host/messaging@0.1.0"), @@ -472,15 +472,15 @@ mod tests { r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), Some("http") ); - // A core-only interface is not a recognised adapter capability. + // A core-only interface is not a recognised provider capability. assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.1.0"), None); } #[test] - fn adapter_manifest_declaring_a_core_only_cap_is_unknown() { + fn provider_manifest_declaring_a_core_only_cap_is_unknown() { // The load path validates declared names against the registry; an - // adapter declaring `local-store` must surface as unknown. - let r = CapabilityRegistry::adapter(); + // provider declaring `local-store` must surface as unknown. + let r = CapabilityRegistry::provider(); assert!(!r.is_known("local-store")); assert!(r.known_names().split(", ").all(|n| n != "local-store")); } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 8abb74ea..6ad22674 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -295,8 +295,8 @@ enabled = true } #[test] - fn module_kind_defaults_to_event_module() { - use crate::manifest::types::ModuleKind; + fn component_kind_defaults_to_the_worker() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] @@ -304,12 +304,12 @@ name = "plain" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::EventModule); + assert_eq!(manifest.module.kind, ComponentKind::Worker); } #[test] - fn module_kind_parses_venue_adapter() { - use crate::manifest::types::ModuleKind; + fn component_kind_carries_a_provider_spelling() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] @@ -318,23 +318,28 @@ kind = "venue-adapter" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::VenueAdapter); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("venue-adapter".to_owned()), + ); } + /// An unknown spelling parses as a provider kind; boot refuses it + /// against the registered kinds, where the valid set is known. #[test] - fn module_kind_rejects_unknown_variant() { - let err = toml::from_str::( + fn component_kind_keeps_an_unregistered_spelling_for_boot_to_refuse() { + use crate::manifest::types::ComponentKind; + let manifest: Manifest = toml::from_str( r#" [module] name = "bad" kind = "gadget" "#, ) - .expect_err("unknown kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("venue-adapter") || msg.contains("event-module"), - "error names the valid kinds: {msg}", + .expect("parse"); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("gadget".to_owned()), ); } diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index fef64e1e..e93e179b 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,7 +35,7 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; -pub(crate) use types::{LoadedManifest, ModuleKind, ResourceSection, Subscription}; +pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 78c62fb4..dbaf774a 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,6 +4,8 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::fmt; + use serde::Deserialize; /// Core capability names: the `nexum:host` interfaces the `event-module` @@ -115,31 +117,54 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, - /// Which component kind this manifest describes. Defaults to - /// `event-module` so every existing `module.toml` keeps its meaning; - /// a venue adapter sets `kind = "venue-adapter"`. The supervisor picks - /// the bindgen and the scoped capability set from this discriminator. + /// Which component kind this manifest describes. Defaults to the + /// worker kind (`event-module`) so every existing `module.toml` keeps + /// its meaning; a provider names its registered kind. The supervisor + /// resolves the boot path from this discriminator. #[serde(default)] - pub kind: ModuleKind, + pub kind: ComponentKind, /// Per-module resource overrides; each unset field inherits the engine /// `[limits]` default. #[serde(default)] pub resources: ResourceSection, } -/// The component kind a manifest declares. The runtime carries two: the -/// original event-module over the six core primitives, and the venue -/// adapter over scoped transport only. Defaulting to `event-module` -/// preserves the meaning of every manifest written before adapters -/// existed. -#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum ModuleKind { - /// Event-driven automation over the six core primitives. +/// The worker kind's manifest spelling. +pub const WORKER_KIND: &str = "event-module"; + +/// The component kind a manifest declares: the core worker kind, or the +/// manifest spelling of a provider kind an extension registers. Defaults +/// to the worker so every manifest written before providers existed keeps +/// its meaning; an unregistered provider spelling is refused at boot, +/// where the registered kinds are known. +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(from = "String")] +pub enum ComponentKind { + /// Event-driven worker over the six core primitives (`event-module`). #[default] - EventModule, - /// A single-venue adapter over scoped chain, messaging, and HTTP. - VenueAdapter, + Worker, + /// A provider the host holds behind a serialised actor, named by its + /// manifest spelling. + Provider(String), +} + +impl From for ComponentKind { + fn from(kind: String) -> Self { + if kind == WORKER_KIND { + Self::Worker + } else { + Self::Provider(kind) + } + } +} + +impl fmt::Display for ComponentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Worker => f.write_str(WORKER_KIND), + Self::Provider(kind) => f.write_str(kind), + } + } } /// `[module.resources]` overrides layered over the engine `[limits]` /// defaults. Every field is optional; an unset field keeps the default. diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index f31a9a52..154e8193 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -26,6 +26,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. +use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -38,12 +39,14 @@ use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; -use crate::bindings::{Config, EventModule, VenueAdapter, nexum}; +use crate::bindings::{Config, EventModule, nexum}; use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::{Extension, HostServices}; +use crate::host::extension::{ + Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, +}; use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; @@ -51,9 +54,9 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::host::venue_registry::{VenueActor, VenueId, VenueRegistry, VenueRegistryBuilder}; +use crate::host::venue_registry::{VenueAdapterKind, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ - self, CapabilityRegistry, LoadedManifest, ModuleKind, ResourceSection, Subscription, + self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; /// Owns every loaded module and exposes the dispatch surface the @@ -256,19 +259,52 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// A venue adapter instantiated into a supervised store, ready to install in -/// the venue registry. It boots through the same store, fuel, and memory -/// machinery as a module but carries no subscriptions: modules reach it -/// through the registry, not through dispatch. Adapter restart and poison -/// handling are still a later change; an `init` failure leaves `alive` false -/// so the adapter is loaded but not routable. -struct LoadedAdapter { - /// Venue id the adapter answers for (its manifest name). - venue_id: VenueId, - /// The refuelable adapter store, ready to serialise behind a registry mutex. - actor: VenueActor, - /// Whether `init` succeeded; a failed adapter is not installed for routing. - alive: bool, +/// One registered provider kind paired with the service its installs bind to. +type ProviderRow = (Box>, Arc); + +/// Registered provider kinds, keyed by their manifest spelling. +type ProviderKinds = BTreeMap<&'static str, ProviderRow>; + +/// Collect each extension's provider kind paired with that extension's +/// service. Refuses a duplicate spelling and a provider whose extension +/// owns no service to install into. +fn provider_kinds( + extensions: &[Arc>], + services: &HostServices, +) -> Result> { + let mut kinds = ProviderKinds::new(); + for ext in extensions { + let Some(provider) = ext.provider() else { + continue; + }; + let service = services.raw(ext.namespace()).cloned().ok_or_else(|| { + anyhow!( + "extension {} registers provider kind {} without a host service", + ext.namespace(), + provider.kind(), + ) + })?; + register_kind(&mut kinds, provider, service)?; + } + Ok(kinds) +} + +/// Insert one kind row, refusing a duplicate manifest spelling. +fn register_kind( + kinds: &mut ProviderKinds, + provider: Box>, + service: Arc, +) -> Result<()> { + let kind = provider.kind(); + if kinds.insert(kind, (provider, service)).is_some() { + return Err(anyhow!("provider kind {kind} is registered twice")); + } + Ok(()) +} + +/// Comma-joined registered provider kind spellings, for boot errors. +fn registered_kinds(kinds: &ProviderKinds) -> String { + kinds.keys().copied().collect::>().join(", ") } impl Supervisor { @@ -285,44 +321,44 @@ impl Supervisor { ) -> Result { let registry = capability_registry(extensions); let services = HostServices::from_extensions(extensions)?; - // Adapters instantiate first: the venue registry must contain them - // before any module store (which carries the built registry) is - // built. Adapters link only their scoped transport, against a - // dedicated linker built from the same core backends, and their own - // stores carry an empty registry since an adapter cannot call the + let venue_registry = VenueRegistryBuilder::new(engine_cfg.limits.quota()) + .with_watch_limit(engine_cfg.limits.watch()) + .build(); + // Provider kinds the boot loop resolves manifest kinds against: + // every extension-registered kind plus the venue-adapter row, seeded + // here while the registry lives in-core; the videre extension takes + // it over. + let mut kinds = provider_kinds(extensions, &services)?; + register_kind( + &mut kinds, + Box::new(VenueAdapterKind), + Arc::new(venue_registry.clone()), + )?; + // Providers boot first into the shared registry handle, so every + // module store built below already routes to the installed venues. + // Providers link only their kind's scoped imports, and their own + // stores carry an empty registry since a provider cannot call the // client face. - let adapter_linker = build_adapter_linker::(engine)?; - let adapter_registry = CapabilityRegistry::adapter(); - let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()); + let provider_registry = CapabilityRegistry::provider(); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; for entry in &engine_cfg.adapters { - let loaded = Self::load_adapter( + let installed = Self::load_provider( engine, - &adapter_linker, entry, components, &engine_cfg.limits, - &adapter_registry, + &provider_registry, clocks.as_ref(), services.clone(), + &kinds, ) .await - .with_context(|| format!("load adapter {}", entry.path.display()))?; - if loaded.alive { + .with_context(|| format!("load provider {}", entry.path.display()))?; + if installed == Installed::Live { adapters_alive += 1; - registry_builder - .install(loaded.venue_id.clone(), loaded.actor) - .with_context(|| format!("install adapter {}", loaded.venue_id))?; - } else { - warn!( - adapter = %loaded.venue_id, - "adapter init failed - not installed for routing", - ); } } - let venue_registry = registry_builder.build(); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { @@ -672,64 +708,78 @@ impl Supervisor { }) } - /// Load one `[[adapters]]` entry: resolve its manifest, verify it - /// declares the venue-adapter kind, enforce the scoped-transport - /// capability set, build a supervised store carrying the operator's - /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings - /// against the adapter linker, and run `init`. Nothing dispatches to - /// the result yet; it boots so the registry can later reach it. + /// Load one `[[adapters]]` entry: resolve its manifest, resolve the + /// declared kind against the registered provider kinds, enforce the + /// scoped-transport capability set, build a supervised store carrying + /// the operator's HTTP and messaging grants, and hand the instance to + /// its kind to instantiate and install. [`Installed::Dead`] marks a + /// failed guest `init`: loaded and counted, but not routable. // One flat argument per shared input threaded onto the store, matching // the module load path. #[allow(clippy::too_many_arguments)] - async fn load_adapter( + async fn load_provider( engine: &Engine, - linker: &Linker>, entry: &AdapterEntry, components: &Components, limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, services: HostServices, - ) -> Result> { + kinds: &ProviderKinds, + ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading adapter manifest"); + info!(manifest = %p.display(), "loading provider manifest"); manifest::load(p, registry)? } _ => { warn!( component = %entry.path.display(), - "no module.toml - falling back to anonymous adapter" + "no module.toml - falling back to anonymous provider" ); manifest::fallback_manifest() } }; // The manifest kind is the discriminator: an [[adapters]] entry - // whose manifest is (or defaults to) an event-module is a config - // error, caught here before instantiation. A fallback manifest has - // the default event-module kind, so an adapter must ship a - // module.toml that declares the venue-adapter kind explicitly. - let kind = loaded_manifest.manifest.module.kind; - if kind != ModuleKind::VenueAdapter { - return Err(anyhow!( - "adapter {} declares module kind {kind:?}; an [[adapters]] entry requires \ - a module.toml with [module] kind = \"venue-adapter\"", - entry.path.display(), - )); - } + // must name a registered provider kind, caught here before + // instantiation. A fallback manifest has the default worker kind, + // so a provider must ship a module.toml that declares its kind + // explicitly. + let (kind, service) = match &loaded_manifest.manifest.module.kind { + ComponentKind::Worker => { + return Err(anyhow!( + "{} declares the worker kind; an [[adapters]] entry requires a \ + module.toml declaring a registered provider kind ({})", + entry.path.display(), + registered_kinds(kinds), + )); + } + ComponentKind::Provider(spelling) => kinds.get(spelling.as_str()).ok_or_else(|| { + anyhow!( + "{} declares unregistered provider kind {spelling}; registered \ + kinds: {}", + entry.path.display(), + registered_kinds(kinds), + ) + })?, + }; - info!(component = %entry.path.display(), "compiling adapter component"); + info!( + component = %entry.path.display(), + kind = kind.kind(), + "compiling provider component", + ); let component = Component::from_file(engine, &entry.path) .map_err(Error::from) .with_context(|| format!("compile {}", entry.path.display()))?; // Enforce the scoped-transport capability set: `registry` is the - // adapter registry, so a declaration of any core-only interface + // provider registry, so a declaration of any core-only interface // fails at manifest load, and an undeclared transport import fails - // here. The linker withholds the same core-only interfaces, so an - // adapter reaching for one also fails to instantiate below. + // here. The linker withholds the same core-only interfaces, so a + // provider reaching for one also fails to instantiate. manifest::enforce_capabilities( &loaded_manifest, component.component_type().imports(engine).map(|(n, _)| n), @@ -737,29 +787,31 @@ impl Supervisor { ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let adapter_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "adapter".to_owned() + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() } else { loaded_manifest.manifest.module.name.clone() }; info!( - adapter = %adapter_namespace, + provider = %namespace, + kind = kind.kind(), fuel = limits_cfg.fuel(), memory_bytes = limits_cfg.memory(), http_allow = entry.http_allow.len(), messaging_topics = entry.messaging_topics.len(), - "applied adapter resource limits and transport scope", + "applied provider resource limits and transport scope", ); - let run = RunId::new(adapter_namespace.clone(), 0); - // An adapter store cannot call the client face, so it carries an + let linker = build_provider_linker::(engine, kind.as_ref())?; + let run = RunId::new(namespace.clone(), 0); + // A provider store cannot call the client face, so it carries an // empty registry; this also keeps the real registry out of the - // adapter's `HostState`, so there is no reference cycle back into + // provider's `HostState`, so there is no reference cycle back into // the registry that owns it. - let mut store = Self::build_store( + let store = Self::build_store( engine, components, - run.clone(), + run, entry.http_allow.clone(), limits_cfg.http(), entry.messaging_topics.clone(), @@ -771,43 +823,24 @@ impl Supervisor { VenueRegistry::empty(), services, )?; - let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) - .await - .map_err(Error::from) - .with_context(|| format!("instantiate {}", entry.path.display()))?; let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), adapter_namespace.clone())] + vec![("name".into(), namespace)] } else { loaded_manifest.config.clone() }; - let init_succeeded = match bindings - .call_init(&mut store, &config) - .await - .map_err(Error::from)? - { - Ok(()) => { - info!(adapter = %adapter_namespace, "adapter init succeeded"); - true - } - Err(e) => { - warn!( - adapter = %adapter_namespace, - kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), - "adapter init failed - loaded but marked dead", - ); - false - } - }; - // Refuel after init so the first routed call starts with a full budget. - store.set_fuel(limits_cfg.fuel())?; - - Ok(LoadedAdapter { - venue_id: VenueId::from(adapter_namespace), - actor: VenueActor::new(store, bindings, limits_cfg.fuel()), - alive: init_succeeded, - }) + kind.install( + ProviderInstance { + component: &component, + linker: &linker, + store, + config, + fuel_per_call: limits_cfg.fuel(), + }, + service, + ) + .await + .with_context(|| format!("install {}", entry.path.display())) } /// Number of modules currently loaded. @@ -1464,27 +1497,20 @@ pub fn build_linker( Ok(linker) } -/// Build a `Linker` for the `venue-adapter` world: only the scoped -/// transport an adapter may reach - `chain`, `messaging`, and the -/// allowlisted `wasi:http` - plus the ambient WASI base. The core -/// `nexum:host` interfaces an adapter must not touch (local-store, -/// remote-store, identity, logging) are deliberately withheld, so an -/// adapter that imports one of them fails to instantiate rather than -/// silently gaining reach. Extensions are not linked into adapters: an -/// adapter speaks its venue's protocol over the standard transport, not a -/// domain extension surface. -pub fn build_adapter_linker( +/// Build a `Linker` for one provider kind: the kind's own scoped imports +/// plus the ambient WASI base and the allowlisted `wasi:http`. The core +/// `nexum:host` interfaces a provider must not touch (local-store, +/// remote-store, identity, logging) are deliberately withheld, so a +/// provider that imports one of them fails to instantiate rather than +/// silently gaining reach. Extensions are not linked into providers: a +/// provider speaks its protocol over the standard transport, not a domain +/// extension surface. +pub fn build_provider_linker( engine: &Engine, + kind: &dyn ProviderKind, ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); - nexum::host::chain::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; - nexum::host::messaging::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; + kind.link(&mut linker)?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; Ok(linker) diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 6d814a16..68093413 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -646,13 +646,14 @@ impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { /// Build a registry with one scripted adapter installed under `cow`. fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { - let mut builder = crate::host::venue_registry::VenueRegistryBuilder::new( + let registry = crate::host::venue_registry::VenueRegistryBuilder::new( crate::host::venue_registry::SubmitQuota::default(), - ); - builder + ) + .build(); + registry .install(crate::host::venue_registry::VenueId::from("cow"), adapter) .expect("install"); - builder.build() + registry } /// Write a manifest subscribing the example module to intent-status @@ -2825,15 +2826,18 @@ fn chainlog_cursor_key_differs_by_each_input() { // ── venue-adapter boot ──────────────────────────────────────────────── -/// The venue-adapter linker binds only the scoped transport (chain, -/// messaging, wasi base, allowlisted http) and withholds the core-only -/// interfaces. Assembling it proves the scope wires without a +/// The venue-adapter provider linker binds only the scoped transport +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a /// duplicate-definition clash between the shared `nexum:host` interfaces. #[tokio::test] -async fn adapter_linker_assembles_with_scoped_transport() { +async fn provider_linker_assembles_with_scoped_transport() { let engine = make_wasmtime_engine(); - crate::supervisor::build_adapter_linker::(&engine) - .expect("adapter linker assembles"); + crate::supervisor::build_provider_linker::( + &engine, + &crate::host::venue_registry::VenueAdapterKind, + ) + .expect("provider linker assembles"); } /// The module-kind discriminator gates the adapter load path: an @@ -2876,6 +2880,41 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { ); } +/// A kind spelling no extension registered is refused at boot with a +/// message naming the registered kinds. +#[tokio::test] +async fn boot_rejects_an_unregistered_provider_kind() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write(&manifest, "[module]\nname = \"bad\"\nkind = \"gadget\"\n") + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("gadget.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + + let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("unregistered provider kind gadget") && msg.contains("venue-adapter"), + "the refusal names the unknown spelling and the registered kinds: {msg}", + ); +} + /// A venue-adapter manifest clears the discriminator; boot then reaches the /// compile step and fails only because the referenced wasm is absent. This /// proves the discriminator routed the entry to the adapter load path From e305556b55c95b49e300c4c97f7ca64cbda5671e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 22:42:22 +0000 Subject: [PATCH 19/53] feat: make the log pipeline pluggable through the components seam ComponentsBuilder grows a fourth logs slot, defaulting to the new LogPipelineBuilder (the in-memory pipeline sized from [limits.logs]); with_logs substitutes a custom builder. The Runtime preset names the slot as LogsBuilder and the launcher cars carry the parameter through. --- crates/nexum-runtime/src/builder.rs | 24 +++-- .../src/host/component/builder.rs | 94 ++++++++++++++++--- .../nexum-runtime/src/host/component/mod.rs | 2 +- crates/nexum-runtime/src/preset.rs | 14 ++- 4 files changed, 106 insertions(+), 28 deletions(-) diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 412c4d8f..f5559506 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -494,10 +494,10 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// Bind the component builders that open the backends at launch. - pub fn with_components( + pub fn with_components( self, - components: ComponentsBuilder, - ) -> ComponentsStage<'a, T, C, S, E> { + components: ComponentsBuilder, + ) -> ComponentsStage<'a, T, C, S, E, L> { ComponentsStage { config: self.config, extensions: self.extensions, @@ -511,19 +511,22 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// The component builders are bound; the add-on set remains. -pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> { +pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, _t: PhantomData T>, } -impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { +impl<'a, T: RuntimeTypes, C, S, E, L> ComponentsStage<'a, T, C, S, E, L> { /// Bind the cross-cutting add-on set installed before the engine boots. - pub fn with_add_ons(self, add_ons: &'a [&'a dyn RuntimeAddOn]) -> ReadyBuilder<'a, T, C, S, E> { + pub fn with_add_ons( + self, + add_ons: &'a [&'a dyn RuntimeAddOn], + ) -> ReadyBuilder<'a, T, C, S, E, L> { ReadyBuilder { config: self.config, extensions: self.extensions, @@ -538,22 +541,23 @@ impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { /// The assembly is complete; [`launch`](Self::launch) opens the backends and /// runs. -pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> { +pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, add_ons: &'a [&'a dyn RuntimeAddOn], } -impl ReadyBuilder<'_, T, C, S, E> +impl ReadyBuilder<'_, T, C, S, E, L> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { /// Open the backends and launch. Builds the [`Components`] bundle from the /// bound builders, then drives [`LaunchRuntime::launch`] with a fresh diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 705896bf..60b734cf 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -3,8 +3,9 @@ //! //! Each core backend is wrapped as a [`ComponentBuilder`], and //! [`ComponentsBuilder`] assembles the core seams (plus the lattice `Ext` -//! payload) into a [`Components`] bundle. The composition root names the -//! concrete builders once; boot drives them through this trait. +//! payload and the log pipeline) into a [`Components`] bundle. The +//! composition root names the concrete builders once; boot drives them +//! through this trait. use std::future::Future; use std::path::Path; @@ -81,6 +82,18 @@ impl ComponentBuilder for LocalStoreBuilder { } } +/// Builds the default [`LogPipeline`]: the byte-bounded in-memory backend +/// sized from `[limits.logs]`. +pub struct LogPipelineBuilder; + +impl ComponentBuilder for LogPipelineBuilder { + type Output = LogPipeline; + + async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { + Ok(LogPipeline::in_memory(ctx.config.limits.logs())) + } +} + /// Names the component slot whose build failed. The leaf cause stays an /// `anyhow::Error` because the backends fail for heterogeneous reasons /// (I/O for the store, network for the chain). @@ -95,6 +108,9 @@ pub enum BuildError { /// The extension payload builder failed. #[error("build the extension payload: {0}")] Ext(anyhow::Error), + /// The log pipeline builder failed. + #[error("build the log pipeline: {0}")] + Logs(anyhow::Error), } /// The empty extension payload: a no-op builder for a core-only lattice @@ -107,41 +123,62 @@ impl ComponentBuilder for () { } } -/// Assembles the core backend builders and the lattice `Ext` builder into -/// a [`Components`] bundle. The log pipeline is sized from `[limits.logs]` -/// and built here; the embedder retains its read handle by cloning -/// [`Components::logs`] after the build. -pub struct ComponentsBuilder { +/// Assembles the core backend builders, the lattice `Ext` builder, and the +/// log pipeline builder into a [`Components`] bundle. The logs slot defaults +/// to [`LogPipelineBuilder`]; the embedder retains the read handle by +/// cloning [`Components::logs`] after the build. +pub struct ComponentsBuilder { /// Builds the chain backend ([`RuntimeTypes::Chain`]). pub chain: C, /// Builds the store backend ([`RuntimeTypes::Store`]). pub store: S, /// Builds the extension payload ([`RuntimeTypes::Ext`]). pub ext: E, + /// Builds the shared [`LogPipeline`]. + pub logs: L, } impl ComponentsBuilder { - /// Create a new [`ComponentsBuilder`]. + /// Create a new [`ComponentsBuilder`] with the default log pipeline. pub fn new(chain: C, store: S, ext: E) -> Self { - Self { chain, store, ext } + Self { + chain, + store, + ext, + logs: LogPipelineBuilder, + } + } +} + +impl ComponentsBuilder { + /// Replace the log pipeline builder. + pub fn with_logs(self, logs: L2) -> ComponentsBuilder { + ComponentsBuilder { + chain: self.chain, + store: self.store, + ext: self.ext, + logs, + } } - /// Drive each builder against `ctx`, then bundle the backends with a - /// fresh log pipeline. The builder outputs must match the lattice - /// seams: chain to [`RuntimeTypes::Chain`], store to - /// [`RuntimeTypes::Store`], ext to [`RuntimeTypes::Ext`]. A failing - /// sub-build returns the [`BuildError`] variant naming that slot. + /// Drive each builder against `ctx` and bundle the backends. The + /// builder outputs must match the lattice seams: chain to + /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to + /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`]. A + /// failing sub-build returns the [`BuildError`] variant naming that + /// slot. pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { let chain = self.chain.build(ctx).await.map_err(BuildError::Chain)?; let store = self.store.build(ctx).await.map_err(BuildError::Store)?; let ext = self.ext.build(ctx).await.map_err(BuildError::Ext)?; - let logs = LogPipeline::in_memory(ctx.config.limits.logs()); + let logs = self.logs.build(ctx).await.map_err(BuildError::Logs)?; Ok(Components { chain, store, @@ -189,4 +226,31 @@ mod tests { // The bundle carries a live in-memory log pipeline. let _ = &components.logs; } + + /// `with_logs` substitutes the log pipeline builder: the bundle carries + /// the exact pipeline the custom builder yields. + #[tokio::test] + async fn with_logs_substitutes_the_pipeline() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = nexum_tasks::TaskManager::new(); + let executor = tasks.executor(); + let ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(crate::test_utils::Prebuilt(custom.clone())) + .build::(&ctx) + .await + .expect("build with a custom log pipeline"); + + assert!( + std::sync::Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the substituted pipeline", + ); + } } diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index 0adb15fa..eaa0e70a 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -11,7 +11,7 @@ mod state; pub use builder::{ BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - ProviderPoolBuilder, + LogPipelineBuilder, ProviderPoolBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 05556ee3..19a948a9 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -8,9 +8,11 @@ use crate::addons::{AddOns, PrometheusAddOn}; use crate::host::component::{ - ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, + ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, + ProviderPoolBuilder, RuntimeTypes, }; use crate::host::local_store_redb::LocalStore; +use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; /// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component @@ -27,9 +29,16 @@ pub trait Runtime { type StoreBuilder: ComponentBuilder::Store>; /// Builds the extension payload ([`RuntimeTypes::Ext`]). type ExtBuilder: ComponentBuilder::Ext>; + /// Builds the shared [`LogPipeline`]. + type LogsBuilder: ComponentBuilder; /// The component builders that open the backends at launch. - fn components() -> ComponentsBuilder; + fn components() -> ComponentsBuilder< + Self::ChainBuilder, + Self::StoreBuilder, + Self::ExtBuilder, + Self::LogsBuilder, + >; /// The cross-cutting add-ons installed before the engine boots. fn add_ons() -> AddOns; @@ -52,6 +61,7 @@ impl Runtime for CoreRuntime { type ChainBuilder = ProviderPoolBuilder; type StoreBuilder = LocalStoreBuilder; type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; fn components() -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) From a3f76a6667b2eca48aa56b70105abd38c999975d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 23:45:57 +0000 Subject: [PATCH 20/53] runtime: carry the venue registry through the services map and delete the privileged field The client face resolves the registry from the store's service map under the videre namespace; no service means every call resolves to unknown-venue. The boot path seeds the registry into the map until the videre extension takes it over, provider stores carry an empty map so the registry never cycles back into a store it owns, and the supervisor field is gone. --- crates/nexum-runtime/src/builder.rs | 16 ++-- crates/nexum-runtime/src/host/extension.rs | 14 ++++ .../src/host/impls/venue_client.rs | 37 +++++---- crates/nexum-runtime/src/host/state.rs | 8 +- .../nexum-runtime/src/host/venue_registry.rs | 9 +-- crates/nexum-runtime/src/supervisor.rs | 77 +++++++------------ crates/nexum-runtime/src/supervisor/tests.rs | 2 +- 7 files changed, 79 insertions(+), 84 deletions(-) diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index f5559506..beaa2eb6 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -267,10 +267,14 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { let logs = components.logs.clone(); let chain_log_subs = supervisor.chain_log_subscriptions(); // Status polling runs only when it can produce something a module - // will see: at least one intent-status subscriber and at least one - // installed adapter to poll. - let poll_statuses = supervisor.has_intent_status_subscribers() - && supervisor.venue_registry().venue_count() > 0; + // will see: at least one intent-status subscriber, a registered + // venue-registry service, and at least one installed adapter to poll. + let status_registry = supervisor + .has_intent_status_subscribers() + .then(|| supervisor.venue_registry()) + .flatten() + .filter(|registry| registry.venue_count() > 0); + let poll_statuses = status_registry.is_some(); // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. @@ -308,9 +312,9 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &executor, &mut reconnect_tasks, ); - let intent_status_stream = poll_statuses.then(|| { + let intent_status_stream = status_registry.map(|registry| { event_loop::open_intent_status_stream( - supervisor.venue_registry(), + registry, engine_cfg.limits.status_poll_interval(), &executor, &mut reconnect_tasks, diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index ed14de95..a282d4ba 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -143,6 +143,20 @@ impl HostServices { pub fn raw(&self, namespace: &str) -> Option<&Arc> { self.0.get(namespace) } + + /// Publish `service` under `namespace`, refusing a duplicate. The boot + /// path seeds a service no extension registers yet. + pub fn with_service( + self, + namespace: &'static str, + service: Arc, + ) -> anyhow::Result { + let mut map = Arc::unwrap_or_clone(self.0); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + Ok(Self(Arc::new(map))) + } } #[cfg(test)] diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/nexum-runtime/src/host/impls/venue_client.rs index 0fac7d94..8b86e8a4 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/nexum-runtime/src/host/impls/venue_client.rs @@ -1,25 +1,36 @@ -//! `videre:venue/client`: the keeper-facing venue import. Every method is a -//! thin delegation to the shared -//! [`VenueRegistry`](crate::host::venue_registry) carried in the store; the -//! registry owns the venue resolution, per-adapter serialisation, guard -//! seam (advisory-only for now), and quota. The caller identity the registry -//! meters against is this store's module namespace. +//! `videre:venue/client`: the keeper-facing venue import. Every method +//! resolves the shared [`VenueRegistry`] from the store's service map under +//! the videre namespace and delegates; the registry owns the venue +//! resolution, per-adapter serialisation, guard seam (advisory-only for +//! now), and quota. The caller identity the registry meters against is this +//! store's module namespace. No registry service means no venues, so every +//! call resolves to `unknown-venue`. + +use std::sync::Arc; use crate::bindings::client::Host; use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; -use crate::host::venue_registry::VenueId; +use crate::host::venue_registry::{VenueId, VenueRegistry}; + +/// The registry published under the videre service namespace. +fn registry(state: &HostState) -> Result, VenueError> { + state + .services + .get::(VenueRegistry::NAMESPACE) + .ok_or(VenueError::UnknownVenue) +} impl Host for HostState { async fn quote(&mut self, venue: String, body: Vec) -> Result { - self.venue_registry + registry(self)? .quote(&self.run.module, &VenueId::from(venue), body) .await } async fn submit(&mut self, venue: String, body: Vec) -> Result { - self.venue_registry + registry(self)? .submit(&self.run.module, &VenueId::from(venue), body) .await } @@ -29,14 +40,10 @@ impl Host for HostState { venue: String, receipt: Vec, ) -> Result { - self.venue_registry - .status(&VenueId::from(venue), receipt) - .await + registry(self)?.status(&VenueId::from(venue), receipt).await } async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { - self.venue_registry - .cancel(&VenueId::from(venue), receipt) - .await + registry(self)?.cancel(&VenueId::from(venue), receipt).await } } diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 3d85117d..b1b103cb 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -14,7 +14,6 @@ use super::component::{Handle, RuntimeTypes}; use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; -use super::venue_registry::VenueRegistry; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -52,12 +51,9 @@ pub struct HostState { /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, - /// The venue registry the `videre:venue/client` import dispatches to. - /// Every module store carries the same shared handle; an adapter store, - /// which cannot call the client face, carries an empty one. - pub venue_registry: VenueRegistry, /// Extension-owned host services, keyed by extension namespace and - /// downcast at the call site. One shared map across every store. + /// downcast at the call site. One shared map across every module store; + /// a provider store carries an empty map. pub services: HostServices, } diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 8e93942d..46c5485c 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -378,12 +378,9 @@ pub struct VenueRegistry { impl HostService for VenueRegistry {} impl VenueRegistry { - /// An empty registry: no adapters, the unit guard, the default quota. - /// This is what an adapter store (which cannot call the client face) and - /// the single-module `just run` path carry. - pub fn empty() -> Self { - VenueRegistryBuilder::new(SubmitQuota::default()).build() - } + /// Service namespace the registry publishes under: the videre + /// extension's. + pub const NAMESPACE: &'static str = "videre"; /// Install an adapter under its venue id. Rejects a duplicate id: two /// adapters answering the same venue would silently shadow one another, diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 154e8193..92894f6b 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -64,13 +64,6 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// The venue registry: every installed venue adapter's serialising - /// store, keyed by venue id. Cached so a module restart rebuilds a store - /// carrying the same shared handle. Adapters boot through the same store, - /// fuel, and memory machinery as modules but carry no subscriptions: - /// modules reach them through this registry, not through dispatch. Folding - /// adapters into the restart and poison sweeps is still a later change. - venue_registry: VenueRegistry, /// Venue adapters loaded at boot, whether or not `init` succeeded. adapters_total: usize, /// Adapters whose `init` succeeded and that are installed for routing. @@ -320,25 +313,22 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - let services = HostServices::from_extensions(extensions)?; - let venue_registry = VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()) - .build(); - // Provider kinds the boot loop resolves manifest kinds against: - // every extension-registered kind plus the venue-adapter row, seeded - // here while the registry lives in-core; the videre extension takes - // it over. + // The venue registry rides the generic service map under the videre + // namespace, seeded here while it lives in-core; the videre + // extension takes it over. Same for the venue-adapter kind row. + let venue_service: Arc = Arc::new( + VenueRegistryBuilder::new(engine_cfg.limits.quota()) + .with_watch_limit(engine_cfg.limits.watch()) + .build(), + ); + let services = HostServices::from_extensions(extensions)? + .with_service(VenueRegistry::NAMESPACE, Arc::clone(&venue_service))?; + // Provider kinds the boot loop resolves manifest kinds against. let mut kinds = provider_kinds(extensions, &services)?; - register_kind( - &mut kinds, - Box::new(VenueAdapterKind), - Arc::new(venue_registry.clone()), - )?; + register_kind(&mut kinds, Box::new(VenueAdapterKind), venue_service)?; // Providers boot first into the shared registry handle, so every // module store built below already routes to the installed venues. - // Providers link only their kind's scoped imports, and their own - // stores carry an empty registry since a provider cannot call the - // client face. + // Providers link only their kind's scoped imports. let provider_registry = CapabilityRegistry::provider(); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; @@ -350,7 +340,6 @@ impl Supervisor { &engine_cfg.limits, &provider_registry, clocks.as_ref(), - services.clone(), &kinds, ) .await @@ -370,7 +359,6 @@ impl Supervisor { &engine_cfg.limits, ®istry, clocks.as_ref(), - venue_registry.clone(), services.clone(), ) .await @@ -387,7 +375,6 @@ impl Supervisor { ); Ok(Self { modules, - venue_registry, adapters_total, adapters_alive, engine: engine.clone(), @@ -423,9 +410,8 @@ impl Supervisor { manifest: manifest.map(Path::to_path_buf), }; // The single-module override path serves `just run`; adapters are - // configured through `engine.toml`, so the registry is empty here and - // every client call resolves to `unknown-venue`. - let venue_registry = VenueRegistry::empty(); + // configured through `engine.toml`, so no registry service is + // published and every client call resolves to `unknown-venue`. let loaded = Self::load_one( engine, linker, @@ -434,13 +420,11 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), - venue_registry.clone(), services.clone(), ) .await?; Ok(Self { modules: vec![loaded], - venue_registry, adapters_total: 0, adapters_alive: 0, engine: engine.clone(), @@ -471,7 +455,6 @@ impl Supervisor { chain_response_max_bytes: usize, state_quota: u64, clocks: Option<&WasiClockOverride>, - venue_registry: VenueRegistry, services: HostServices, ) -> Result> { let namespace: &str = &run.module; @@ -530,7 +513,6 @@ impl Supervisor { chain: components.chain.clone(), chain_response_max_bytes, store: module_store, - venue_registry, services, }, ); @@ -539,8 +521,7 @@ impl Supervisor { Ok(store) } - // One flat argument per shared input threaded onto the store, plus the - // venue registry the module's `videre:venue/client` import dispatches to. + // One flat argument per shared input threaded onto the store. #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, @@ -550,7 +531,6 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - venue_registry: VenueRegistry, services: HostServices, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); @@ -616,7 +596,6 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), state_bytes, clocks, - venue_registry, services, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) @@ -724,7 +703,6 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - services: HostServices, kinds: &ProviderKinds, ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); @@ -804,10 +782,9 @@ impl Supervisor { let linker = build_provider_linker::(engine, kind.as_ref())?; let run = RunId::new(namespace.clone(), 0); - // A provider store cannot call the client face, so it carries an - // empty registry; this also keeps the real registry out of the - // provider's `HostState`, so there is no reference cycle back into - // the registry that owns it. + // A provider links no service-consuming import, so its store carries + // an empty service map; the shared map holds the registry that owns + // the provider's store, and carrying it here would cycle. let store = Self::build_store( engine, components, @@ -820,8 +797,7 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), limits_cfg.state_bytes(), clocks, - VenueRegistry::empty(), - services, + HostServices::default(), )?; let config: Config = if loaded_manifest.config.is_empty() { @@ -969,10 +945,9 @@ impl Supervisor { let linker = build_linker::(&self.engine, &self.extensions)?; // Borrowed before the `&mut self.modules[idx]` reborrow so the restart - // path applies the same clock override and the same shared registry + // path applies the same clock override and the same shared services // as the initial boot. let clocks = self.clocks.clone(); - let venue_registry = self.venue_registry.clone(); let services = self.services.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key @@ -990,7 +965,6 @@ impl Supervisor { module.chain_response_max_bytes, module.local_store_bytes, clocks.as_ref(), - venue_registry, services, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) @@ -1244,9 +1218,12 @@ impl Supervisor { }) } - /// The shared venue registry carried by every module store. - pub fn venue_registry(&self) -> VenueRegistry { - self.venue_registry.clone() + /// The venue registry published under the videre service namespace, + /// when one is. Shared by every module store through the service map. + pub fn venue_registry(&self) -> Option { + self.services + .get::(VenueRegistry::NAMESPACE) + .map(|registry| (*registry).clone()) } /// Shared per-module dispatch path: refuel, call `on_event`, and diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 68093413..3830e6f3 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -898,7 +898,7 @@ async fn e2e_echo_module_registry_adapter_round_trip() { // Poll the registry the module submitted through and fan its transitions // back to the module. echo-venue settles instantly, so the first poll // reports a terminal status and the watch is pruned. - let registry = supervisor.venue_registry(); + let registry = supervisor.venue_registry().expect("registry service"); let mut delivered = 0; for _ in 0..2 { for update in registry.poll_status_transitions().await { From a3e616232d0806b8458a92edb2b8a30c38e060a8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 16 Jul 2026 23:45:37 +0000 Subject: [PATCH 21/53] feat: let a runtime preset carry extensions and pre-built backends --- crates/nexum-runtime/examples/embed.rs | 2 +- crates/nexum-runtime/src/builder.rs | 198 ++++++++++++++++++++++--- crates/nexum-runtime/src/preset.rs | 49 ++++-- 3 files changed, 216 insertions(+), 33 deletions(-) diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index c166e006..7d92343f 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -5,7 +5,7 @@ //! backends (chain provider pool, local redb store, empty extension slot) and //! the Prometheus add-on. A domain capability such as cow-api is added by //! writing a preset that names its extension builder in the `Ext` slot and -//! its linker hook via `with_extensions`, or by dropping to the explicit +//! returns its linker extensions, or by dropping to the explicit //! `with_components` builder path. The returned [`RuntimeHandle`] carries the //! in-process log read side; clone it to keep reading after `wait` consumes //! the handle. diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index beaa2eb6..49eb53aa 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -13,7 +13,9 @@ //! an embedder holding pre-built backends constructs an [`AssembledRuntime`] //! and calls [`LaunchRuntime::launch`] directly. For the common case, //! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the -//! lattice, component builders, and add-ons in one call. +//! lattice, component builders, extensions, and add-ons in one call; +//! [`RuntimeBuilder::with_runtime`] binds a preset value carrying pre-built +//! backends. use std::future::{Future, IntoFuture}; use std::marker::PhantomData; @@ -372,35 +374,42 @@ impl<'a> RuntimeBuilder<'a> { } } - /// Bind a [`Runtime`] preset that bundles the lattice, the component - /// builders, and the add-on set. Sugar over the type-state chain: an + /// Bind a [`Runtime`] preset by marker. Sugar over + /// [`with_runtime`](Self::with_runtime) for a `Default` preset: an /// embedder writes `RuntimeBuilder::new(cfg).runtime::().launch()`. - pub fn runtime(self) -> PresetBuilder<'a, R> { + pub fn runtime(self) -> PresetBuilder<'a, R> { + self.with_runtime(R::default()) + } + + /// Bind a [`Runtime`] preset by value, so a preset can carry pre-built + /// backends and extensions into the launch. + pub fn with_runtime(self, preset: R) -> PresetBuilder<'a, R> { PresetBuilder { config: self.config, + preset, extensions: Vec::new(), wasm: None, manifest: None, clocks: None, - _r: PhantomData, } } } /// Terminal stage of the preset shortcut: the [`Runtime`] preset supplies the -/// lattice, the component builders, and the add-on set, leaving only the -/// optional extension hooks and module source before [`launch`](Self::launch). +/// lattice, the component builders, its extensions, and the add-on set, +/// leaving only the optional extension hooks and module source before +/// [`launch`](Self::launch). pub struct PresetBuilder<'a, R: Runtime> { config: &'a EngineConfig, + preset: R, extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - _r: PhantomData R>, } impl<'a, R: Runtime> PresetBuilder<'a, R> { - /// Add extensions on top of the preset. The default preset carries none. + /// Append extensions on top of the preset's own. pub fn with_extensions( mut self, extensions: impl IntoIterator>>, @@ -426,8 +435,9 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { } /// Open the preset's backends and launch. Builds the [`Components`] bundle - /// from the preset's component builders, installs the preset's add-ons, - /// then drives [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. + /// from the preset's component builders, gathers the preset's extensions + /// (appended ones after), installs the preset's add-ons, then drives + /// [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -437,16 +447,21 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { data_dir: &data_dir, executor: &executor, }; - let components = R::components().build::(&build_ctx).await?; - + let mut extensions = self.preset.extensions(); + extensions.extend(self.extensions); // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is // consumed by the launch call, so both must stay in scope for that call. - let add_ons = R::add_ons(); + let add_ons = self.preset.add_ons(); let add_on_refs: Vec<&dyn RuntimeAddOn> = add_ons.iter().map(|a| &**a).collect(); + let components = self + .preset + .components() + .build::(&build_ctx) + .await?; let runtime = AssembledRuntime { components, - extensions: self.extensions, + extensions, add_ons: &add_on_refs, wasm: self.wasm.as_deref(), manifest: self.manifest.as_deref(), @@ -599,9 +614,14 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; + use crate::addons::AddOns; use crate::engine_config::EngineConfig; - use crate::host::component::{LocalStoreBuilder, ProviderPoolBuilder}; - use crate::preset::CoreRuntime; + use crate::host::component::{LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder}; + use crate::host::state::HostState; + use crate::manifest::NamespaceCaps; + use crate::preset::{CoreRuntime, Runtime as RuntimePreset}; + use crate::test_utils::Prebuilt; + use wasmtime::component::Linker; /// The preset shortcut is exercised at runtime, not just compiled: the /// component builders open the backends, the add-ons install, and the @@ -625,6 +645,150 @@ mod tests { assert!(err.to_string().contains("no modules to run"), "{err}"); } + /// Counts linker hook runs, so a test observes an extension reaching the + /// launch's linker build. + struct CountingExt { + namespace: &'static str, + prefix: &'static str, + linked: Arc, + } + + impl Extension for CountingExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: self.prefix, + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + self.linked.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + /// A value-bound preset carrying its own extension. + struct ExtPreset { + linked: Arc, + } + + impl RuntimePreset for ExtPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + + fn extensions(&self) -> Vec>> { + vec![Arc::new(CountingExt { + namespace: "alpha", + prefix: "alpha:ext/", + linked: self.linked.clone(), + })] + } + } + + /// The preset's own extensions and the appended ones both reach the + /// launch's linker build, each linked exactly once, before the boot + /// bails on the empty module set. + #[tokio::test] + async fn preset_extensions_and_appended_extensions_both_link() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let preset_linked = Arc::new(AtomicUsize::new(0)); + let appended_linked = Arc::new(AtomicUsize::new(0)); + let appended: Arc> = Arc::new(CountingExt { + namespace: "beta", + prefix: "beta:ext/", + linked: appended_linked.clone(), + }); + + let err = match RuntimeBuilder::new(&config) + .with_runtime(ExtPreset { + linked: preset_linked.clone(), + }) + .with_extensions([appended]) + .launch() + .await + { + Ok(_) => panic!("default config declares no modules; launch must bail"), + Err(err) => err, + }; + assert!(err.to_string().contains("no modules to run"), "{err}"); + assert_eq!(preset_linked.load(Ordering::SeqCst), 1, "preset extension"); + assert_eq!( + appended_linked.load(Ordering::SeqCst), + 1, + "appended extension" + ); + } + + /// A value-bound preset handing back an already-built backend. + struct PrebuiltLogsPreset { + logs: LogPipeline, + } + + impl RuntimePreset for PrebuiltLogsPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = Prebuilt; + + fn components( + self, + ) -> ComponentsBuilder> + { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(Prebuilt(self.logs)) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + } + + /// `components(self)` hands a pre-built instance through the preset seam: + /// the built bundle carries the exact pipeline the preset owned. + #[tokio::test] + async fn preset_hands_over_a_prebuilt_backend() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = TaskManager::new(); + let executor = tasks.executor(); + let build_ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = PrebuiltLogsPreset { + logs: custom.clone(), + } + .components() + .build::(&build_ctx) + .await + .expect("build from the preset's builders"); + + assert!( + Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the preset's pre-built pipeline", + ); + } + /// when every configured module fails `init`, launch must /// abort with an operator-facing error instead of idling behind an /// empty event loop. diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 19a948a9..17044af2 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -1,25 +1,34 @@ -//! Runtime presets: a preset names a lattice, its component builders, and its -//! add-on set as one bundle, so an embedder launches with +//! Runtime presets: a preset names a lattice, its component builders, its +//! extensions, and its add-on set as one bundle, so an embedder launches with //! `RuntimeBuilder::new(cfg).runtime::().launch()` instead of naming -//! each seam. [`CoreRuntime`] is the domain-free default: the reference core -//! backends (a chain provider pool and a local redb store, no extension -//! payload) with the Prometheus add-on. A domain assembly ships its own -//! preset naming its extension builder in the `Ext` slot. +//! each seam. A preset carrying pre-built backends or non-static extensions +//! binds by value through +//! [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime). +//! [`CoreRuntime`] is the domain-free default: the reference core backends +//! (a chain provider pool and a local redb store, no extension payload) with +//! the Prometheus add-on. A domain assembly ships its own preset naming its +//! extension builder in the `Ext` slot and returning its linker extensions. + +use std::sync::Arc; use crate::addons::{AddOns, PrometheusAddOn}; use crate::host::component::{ ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, }; +use crate::host::extension::Extension; use crate::host::local_store_redb::LocalStore; use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; -/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component -/// builders and add-ons the launcher needs, gathered behind one name. -/// Implemented by zero-sized markers; +/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the +/// component builders, extensions, and add-ons the launcher needs, gathered +/// behind one name. /// [`RuntimeBuilder::runtime`](crate::builder::RuntimeBuilder::runtime) binds -/// one and launches it. +/// a `Default` marker; +/// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) +/// binds a value, so a preset can hand back already-built backends through a +/// pass-through builder such as `Prebuilt`. pub trait Runtime { /// The lattice the preset assembles. type Types: RuntimeTypes; @@ -32,8 +41,11 @@ pub trait Runtime { /// Builds the shared [`LogPipeline`]. type LogsBuilder: ComponentBuilder; - /// The component builders that open the backends at launch. - fn components() -> ComponentsBuilder< + /// The component builders that open the backends at launch. Consumes the + /// preset, so a value-bound preset hands over owned, pre-built backends. + fn components( + self, + ) -> ComponentsBuilder< Self::ChainBuilder, Self::StoreBuilder, Self::ExtBuilder, @@ -41,7 +53,14 @@ pub trait Runtime { >; /// The cross-cutting add-ons installed before the engine boots. - fn add_ons() -> AddOns; + fn add_ons(&self) -> AddOns; + + /// The linker extensions the preset launches with. None by default; + /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) + /// appends on top. + fn extensions(&self) -> Vec>> { + Vec::new() + } } /// The domain-free default preset: the reference core backends (a chain @@ -63,11 +82,11 @@ impl Runtime for CoreRuntime { type ExtBuilder = (); type LogsBuilder = LogPipelineBuilder; - fn components() -> ComponentsBuilder { + fn components(self) -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } - fn add_ons() -> AddOns { + fn add_ons(&self) -> AddOns { vec![Box::new(PrometheusAddOn)] } } From 44b6d6a7bb0af342e3e9b8b4a7d66bcb65cae312 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 00:43:22 +0000 Subject: [PATCH 22/53] runtime: fold venue adapters into the restart and poison sweeps A trap in a routed adapter call marks a shared Liveness dead; the dispatch-time sweeps restart the provider after the module backoff policy and quarantine a crash-looper per the poison policy. A dead venue resolves to unavailable, distinct from unknown-venue, and adapter_alive_count reports live routability. The flaky-venue fixture drives the trap-to-recovery and poison paths end to end. --- .github/workflows/ci.yml | 6 +- Cargo.lock | 8 + Cargo.toml | 1 + crates/nexum-runtime/src/host/actor.rs | 61 +++- crates/nexum-runtime/src/host/extension.rs | 4 + .../nexum-runtime/src/host/venue_registry.rs | 129 +++++-- crates/nexum-runtime/src/supervisor.rs | 325 +++++++++++++++--- crates/nexum-runtime/src/supervisor/tests.rs | 167 ++++++++- justfile | 4 +- modules/fixtures/flaky-venue/Cargo.toml | 17 + modules/fixtures/flaky-venue/module.toml | 19 + modules/fixtures/flaky-venue/src/lib.rs | 76 ++++ 12 files changed, 731 insertions(+), 86 deletions(-) create mode 100644 modules/fixtures/flaky-venue/Cargo.toml create mode 100644 modules/fixtures/flaky-venue/module.toml create mode 100644 modules/fixtures/flaky-venue/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 186bce4d..42cc34c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 15 guest module wasms ONCE (release/wasm32-wasip2): the single + # Build all 16 guest module wasms ONCE (release/wasm32-wasip2): the single # source of truth for guest buildability and the artifacts the integration # tests load. Replaces the deleted 9-way build-module matrix, which recompiled # the shared wasm dependency graph ~9x cold. Per-module size report folded in; @@ -88,8 +88,8 @@ jobs: cargo build --release --target wasm32-wasip2 --locked \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ - -p echo-client -p clock-reader -p flaky-bomb -p fuel-bomb \ - -p memory-bomb -p panic-bomb -p slow-host + -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue \ + -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host { echo "### module .wasm sizes" echo "| module | bytes |" diff --git a/Cargo.lock b/Cargo.lock index 0b867ea8..217777ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2377,6 +2377,14 @@ dependencies = [ "wit-bindgen 0.59.0", ] +[[package]] +name = "flaky-venue" +version = "0.1.0" +dependencies = [ + "nexum-venue-sdk", + "wit-bindgen 0.58.0", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index d4ee5eb6..418564d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "modules/examples/stop-loss", "modules/fixtures/clock-reader", "modules/fixtures/flaky-bomb", + "modules/fixtures/flaky-venue", "modules/fixtures/fuel-bomb", "modules/fixtures/memory-bomb", "modules/fixtures/panic-bomb", diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs index 10d5c461..f55bf2c7 100644 --- a/crates/nexum-runtime/src/host/actor.rs +++ b/crates/nexum-runtime/src/host/actor.rs @@ -4,7 +4,8 @@ //! caller, and each instance sits behind an [`ActorSlot`] async mutex held //! across the guest await, so one store never runs two guest calls at once. -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; use tokio::sync::Mutex as AsyncMutex; use wasmtime::Store; @@ -16,6 +17,47 @@ use super::state::HostState; /// is not `Sync`; concurrent callers queue here. pub type ActorSlot = Arc>; +/// Shared liveness of one supervised component. The store marks it dead on +/// a trap, recording when, so the supervisor's restart sweep can count the +/// backoff from the death rather than from the sweep that observed it. +/// Cloning shares the flag. Starts alive. +#[derive(Clone, Debug, Default)] +pub struct Liveness(Arc>>); + +impl Liveness { + /// Whether the component is currently callable. + pub fn is_alive(&self) -> bool { + self.lock().is_none() + } + + /// When the component died, while it is dead. + pub fn dead_since(&self) -> Option { + *self.lock() + } + + /// Mark the component dead: its store trapped and is unusable. Keeps + /// the first death instant when already dead. + pub fn mark_dead(&self) { + let mut died_at = self.lock(); + if died_at.is_none() { + *died_at = Some(Instant::now()); + } + } + + /// Mark the component alive again after a restart. + pub fn mark_alive(&self) { + *self.lock() = None; + } + + /// The flag, recovered from a poisoned lock: the state is a bare + /// `Option`, valid under any interleaving. + fn lock(&self) -> MutexGuard<'_, Option> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + /// A guest call failed outside the component's typed error space. #[derive(Debug, thiserror::Error)] pub enum ActorFault { @@ -30,22 +72,26 @@ pub enum ActorFault { /// A supervised component store: refuelled before each guest call so every /// invocation starts from a full budget, with traps projected onto -/// [`ActorFault`]. +/// [`ActorFault`] and recorded on the shared [`Liveness`]. pub struct SupervisedStore { store: Store>, fuel_per_call: u64, + liveness: Liveness, } impl SupervisedStore { - /// Supervise an instantiated store with a per-call fuel budget. - pub fn new(store: Store>, fuel_per_call: u64) -> Self { + /// Supervise an instantiated store with a per-call fuel budget, + /// reporting traps on `liveness`. + pub fn new(store: Store>, fuel_per_call: u64, liveness: Liveness) -> Self { Self { store, fuel_per_call, + liveness, } } - /// Refuel, then run one guest call against the store. + /// Refuel, then run one guest call against the store. A trap marks the + /// shared liveness dead: the store is poisoned until reinstantiated. pub async fn call( &mut self, call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, @@ -53,6 +99,9 @@ impl SupervisedStore { self.store .set_fuel(self.fuel_per_call) .map_err(ActorFault::Refuel)?; - call(&mut self.store).await.map_err(ActorFault::Trap) + call(&mut self.store).await.map_err(|trap| { + self.liveness.mark_dead(); + ActorFault::Trap(trap) + }) } } diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index a282d4ba..00d82442 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -11,6 +11,7 @@ use async_trait::async_trait; use wasmtime::Store; use wasmtime::component::{Component, Linker}; +use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; use crate::manifest::NamespaceCaps; @@ -84,6 +85,9 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub config: Vec<(String, String)>, /// Fuel budget applied before each routed guest call. pub fuel_per_call: u64, + /// Shared liveness the installed instance reports traps on and the + /// supervisor's restart sweep reads. + pub liveness: Liveness, } /// Outcome of one provider install. diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 46c5485c..301e371e 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -41,7 +41,7 @@ use crate::bindings::{ IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, nexum, }; -use crate::host::actor::{ActorFault, ActorSlot, SupervisedStore}; +use crate::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; use crate::host::component::RuntimeTypes; use crate::host::extension::{ HostService, Installed, ProviderInstance, ProviderKind, downcast_service, @@ -225,10 +225,16 @@ pub struct VenueActor { } impl VenueActor { - /// Wrap an instantiated adapter store for routing. - pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { + /// Wrap an instantiated adapter store for routing, reporting traps on + /// the shared `liveness`. + pub fn new( + store: Store>, + bindings: VenueAdapter, + fuel_per_call: u64, + liveness: Liveness, + ) -> Self { Self { - actor: SupervisedStore::new(store, fuel_per_call), + actor: SupervisedStore::new(store, fuel_per_call, liveness), bindings, } } @@ -302,6 +308,15 @@ impl VenueInvoker for VenueActor { /// One installed adapter behind its serialising slot. type AdapterSlot = ActorSlot; +/// One installed venue: the adapter slot plus the liveness the supervisor's +/// sweep shares with the actor. A dead entry stays installed, so the venue +/// resolves to `unavailable` (temporarily dead) rather than `unknown-venue` +/// (never installed) until the sweep restarts it. +struct InstalledVenue { + slot: AdapterSlot, + liveness: Liveness, +} + /// Per-caller charge history, pruned to the quota window on each touch. #[derive(Default)] struct QuotaLedger { @@ -356,7 +371,7 @@ fn status_body(status: IntentStatus) -> StatusBody { /// install through the shared handle at provider boot, before any client /// call routes. struct VenueRegistryInner { - adapters: Mutex>, + adapters: Mutex>, guard: Arc, quota: SubmitQuota, ledger: Mutex, @@ -382,31 +397,44 @@ impl VenueRegistry { /// extension's. pub const NAMESPACE: &'static str = "videre"; - /// Install an adapter under its venue id. Rejects a duplicate id: two + /// Install an adapter under its venue id, sharing `liveness` with its + /// invoker. Rejects a duplicate id while the incumbent is alive: two /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. + /// which is a config error worth failing boot over. A dead incumbent is + /// replaced: that is the sweep restarting a trapped adapter. pub fn install( &self, venue: VenueId, + liveness: Liveness, invoker: impl VenueInvoker + 'static, ) -> Result<(), DuplicateVenue> { let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); - if adapters.contains_key(&venue) { + if adapters.get(&venue).is_some_and(|v| v.liveness.is_alive()) { return Err(DuplicateVenue { venue }); } - adapters.insert(venue, Arc::new(AsyncMutex::new(invoker))); + adapters.insert( + venue, + InstalledVenue { + slot: Arc::new(AsyncMutex::new(invoker)), + liveness, + }, + ); Ok(()) } - /// Resolve a venue id to its installed adapter slot. + /// Resolve a venue id to its installed adapter slot. An uninstalled + /// venue is `unknown-venue`; an installed but dead one is `unavailable` + /// pending the supervisor's restart sweep, without touching its + /// poisoned store. fn resolve(&self, venue: &VenueId) -> Result { - self.inner - .adapters - .lock() - .expect("adapter map poisoned") - .get(venue) - .cloned() - .ok_or(VenueError::UnknownVenue) + let adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + let installed = adapters.get(venue).ok_or(VenueError::UnknownVenue)?; + if !installed.liveness.is_alive() { + return Err(VenueError::Unavailable(format!( + "venue {venue} is dead pending restart" + ))); + } + Ok(Arc::clone(&installed.slot)) } /// Whether `caller` has budget left in the current window. Read-only: it @@ -580,8 +608,8 @@ impl VenueRegistry { } let mut updates = Vec::new(); for (venue, receipt) in snapshot { - // Installed adapters never leave the registry, so a resolve - // failure here is unreachable; skip defensively regardless. + // A dead venue fails to resolve; its watch stays for the + // cadence after the sweep restarts the adapter. let Ok(slot) = self.resolve(&venue) else { continue; }; @@ -724,6 +752,7 @@ impl ProviderKind for VenueAdapterKind { mut store, config, fuel_per_call, + liveness, } = instance; let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) .await @@ -750,7 +779,8 @@ impl ProviderKind for VenueAdapterKind { registry .install( venue_id.clone(), - VenueActor::new(store, bindings, fuel_per_call), + liveness.clone(), + VenueActor::new(store, bindings, fuel_per_call, liveness), ) .with_context(|| format!("install adapter {venue_id}"))?; Ok(Installed::Live) @@ -1062,7 +1092,9 @@ mod tests { builder = builder.with_guard(guard); } let registry = builder.build(); - registry.install(cow(), adapter).expect("install adapter"); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); registry } @@ -1367,14 +1399,61 @@ mod tests { let a = Arc::new(StubCalls::default()); let b = Arc::new(StubCalls::default()); registry - .install(cow(), StubAdapter::new(a)) + .install(cow(), Liveness::default(), StubAdapter::new(a)) .expect("first install"); let err = registry - .install(cow(), StubAdapter::new(b)) + .install(cow(), Liveness::default(), StubAdapter::new(b)) .expect_err("second install collides"); assert_eq!(err.venue, cow()); } + #[tokio::test] + async fn dead_venue_is_unavailable_not_unknown() { + let calls = Arc::new(StubCalls::default()); + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install(cow(), liveness.clone(), StubAdapter::new(calls.clone())) + .expect("install adapter"); + liveness.mark_dead(); + + // Temporarily dead resolves distinctly from never installed, and + // the dead adapter's slot is never entered. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_dead_incumbent_is_replaced_on_reinstall() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + let liveness = Liveness::default(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("first install"); + liveness.mark_dead(); + registry + .install( + cow(), + Liveness::default(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("a restart replaces the dead incumbent"); + assert_eq!(registry.venue_count(), 1); + } + #[test] fn zero_quota_saturates_to_one() { let registry = @@ -1523,7 +1602,9 @@ mod tests { let registry = VenueRegistryBuilder::new(SubmitQuota::default()) .with_watch_limit(watch_limit) .build(); - registry.install(cow(), adapter).expect("install adapter"); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); registry } diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 92894f6b..029fa1e3 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -18,6 +18,12 @@ //! `next_attempt = None` and never get scheduled - the init failure //! is treated as a manifest / config bug, not a transient. //! +//! Providers (venue adapters) ride the same sweeps: a trap inside a +//! routed call flips the [`Liveness`] their actor shares with the +//! supervisor, the venue resolves to `unavailable` (not +//! `unknown-venue`) while dead, and the sweep reinstalls the provider +//! after the same backoff and poison policies. +//! //! Multi-chain isolation: `dispatch_block(block)` walks //! every module but only enters those whose subscriptions match //! `block.chain_id`. Per-module restart / poison / fuel limits are @@ -43,6 +49,7 @@ use crate::bindings::{Config, EventModule, nexum}; use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; +use crate::host::actor::Liveness; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, @@ -64,10 +71,12 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// Venue adapters loaded at boot, whether or not `init` succeeded. - adapters_total: usize, - /// Adapters whose `init` succeeded and that are installed for routing. - adapters_alive: usize, + /// Providers (venue adapters) loaded at boot, whether or not `init` + /// succeeded. Swept for restart and poison alongside the modules. + providers: Vec, + /// Registered provider kinds paired with their services, kept for the + /// provider restart sweep to reinstall through. + kinds: ProviderKinds, /// Cached for module restart: re-instantiating a trapped module /// requires a fresh wasmtime `Store` + `Linker`, which in turn need /// the shared backends. The `Components` bundle is cheaply cloned @@ -252,6 +261,41 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } +/// One loaded provider (venue adapter). Mirrors [`LoadedModule`]'s restart +/// and poison bookkeeping; liveness is shared with the installed actor, +/// which marks it dead on a trap, and read back by the sweep. +struct LoadedProvider { + /// The provider's namespace: its manifest name, and its venue id. + name: String, + /// Registered kind spelling the restart sweep reinstalls through. + kind: &'static str, + /// Cached for restart, like a module's. + component: Component, + /// Cached for restart: the manifest `[config]` handed to `init`. + init_config: Config, + /// Cached for restart: the operator's transport grants. + http_allow: Vec, + messaging_topics: Vec, + /// Cached for restart: the engine `[limits]` applied at boot. + http_limits: OutboundHttpLimits, + fuel_per_call: u64, + memory_limit: usize, + chain_response_max_bytes: usize, + local_store_bytes: u64, + /// Trap flag shared with the installed actor. + liveness: Liveness, + /// Sequence of the run currently installed; restarts increment it. + run_seq: u64, + /// The sweep's view of `liveness`: a `true` here against a dead + /// liveness is an unrecorded trap. Boot init failure leaves it `false` + /// with `next_attempt = None`, permanent like a module's. + alive: bool, + failure_count: u32, + next_attempt: Option, + failure_timestamps: std::collections::VecDeque, + poisoned: bool, +} + /// One registered provider kind paired with the service its installs bind to. type ProviderRow = (Box>, Arc); @@ -330,10 +374,9 @@ impl Supervisor { // module store built below already routes to the installed venues. // Providers link only their kind's scoped imports. let provider_registry = CapabilityRegistry::provider(); - let adapters_total = engine_cfg.adapters.len(); - let mut adapters_alive = 0; + let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); for entry in &engine_cfg.adapters { - let installed = Self::load_provider( + let loaded = Self::load_provider( engine, entry, components, @@ -344,9 +387,7 @@ impl Supervisor { ) .await .with_context(|| format!("load provider {}", entry.path.display()))?; - if installed == Installed::Live { - adapters_alive += 1; - } + providers.push(loaded); } let mut modules = Vec::with_capacity(engine_cfg.modules.len()); @@ -366,17 +407,18 @@ impl Supervisor { modules.push(loaded); } let alive = modules.iter().filter(|m| m.alive).count(); + let adapters_alive = providers.iter().filter(|p| p.alive).count(); info!( loaded = modules.len(), alive, - adapters = adapters_total, + adapters = providers.len(), adapters_alive, "supervisor up" ); Ok(Self { modules, - adapters_total, - adapters_alive, + providers, + kinds, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -425,8 +467,8 @@ impl Supervisor { .await?; Ok(Self { modules: vec![loaded], - adapters_total: 0, - adapters_alive: 0, + providers: Vec::new(), + kinds: ProviderKinds::new(), engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -691,8 +733,8 @@ impl Supervisor { /// declared kind against the registered provider kinds, enforce the /// scoped-transport capability set, build a supervised store carrying /// the operator's HTTP and messaging grants, and hand the instance to - /// its kind to instantiate and install. [`Installed::Dead`] marks a - /// failed guest `init`: loaded and counted, but not routable. + /// its kind to instantiate and install. A failed guest `init` loads the + /// provider dead and unroutable, permanently like a module's. // One flat argument per shared input threaded onto the store, matching // the module load path. #[allow(clippy::too_many_arguments)] @@ -704,7 +746,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, kinds: &ProviderKinds, - ) -> Result { + ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { @@ -801,22 +843,48 @@ impl Supervisor { )?; let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), namespace)] + vec![("name".into(), namespace.clone())] } else { loaded_manifest.config.clone() }; - kind.install( - ProviderInstance { - component: &component, - linker: &linker, - store, - config, - fuel_per_call: limits_cfg.fuel(), - }, - service, - ) - .await - .with_context(|| format!("install {}", entry.path.display())) + let liveness = Liveness::default(); + let installed = kind + .install( + ProviderInstance { + component: &component, + linker: &linker, + store, + config: config.clone(), + fuel_per_call: limits_cfg.fuel(), + liveness: liveness.clone(), + }, + service, + ) + .await + .with_context(|| format!("install {}", entry.path.display()))?; + if installed == Installed::Dead { + liveness.mark_dead(); + } + Ok(LoadedProvider { + name: namespace, + kind: kind.kind(), + component, + init_config: config, + http_allow: entry.http_allow.clone(), + messaging_topics: entry.messaging_topics.clone(), + http_limits: limits_cfg.http(), + fuel_per_call: limits_cfg.fuel(), + memory_limit: limits_cfg.memory(), + chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), + local_store_bytes: limits_cfg.state_bytes(), + liveness, + run_seq: 0, + alive: installed == Installed::Live, + failure_count: 0, + next_attempt: None, + failure_timestamps: std::collections::VecDeque::new(), + poisoned: false, + }) } /// Number of modules currently loaded. @@ -826,14 +894,17 @@ impl Supervisor { /// Number of venue adapters loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { - self.adapters_total + self.providers.len() } - /// Number of adapters whose `init` succeeded and that are installed in the - /// venue registry for routing. + /// Number of adapters currently alive and routable. Live: a trap drops + /// it, the restart sweep raises it again. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { - self.adapters_alive + self.providers + .iter() + .filter(|p| p.liveness.is_alive()) + .count() } /// Chains any **alive** module asked for block events on. Dead modules @@ -1024,6 +1095,7 @@ impl Supervisor { for idx in restart_candidates { self.try_restart(idx).await; } + self.sweep_providers().await; let mut dispatched = 0; let candidate_indices: Vec = (0..self.modules.len()) @@ -1087,6 +1159,7 @@ impl Supervisor { cursor_key: Option<&str>, ) -> bool { let now = std::time::Instant::now(); + self.sweep_providers().await; let Some(idx) = self.modules.iter().position(|m| m.name == module_name) else { warn!(module = %module_name, "no such module - dropping chain-log"); return false; @@ -1176,6 +1249,7 @@ impl Supervisor { for idx in restart_candidates { self.try_restart(idx).await; } + self.sweep_providers().await; let candidate_indices: Vec = (0..self.modules.len()) .filter(|&i| { @@ -1418,6 +1492,133 @@ impl Supervisor { } } + /// Fold providers into the recovery path: record any trap the shared + /// liveness reports (backoff plus poison bookkeeping), then reinstall + /// dead, unpoisoned providers whose backoff has elapsed. Runs at the + /// head of every dispatch, beside the module restart sweep. + async fn sweep_providers(&mut self) { + let now = std::time::Instant::now(); + let policy = self.poison_policy; + for idx in 0..self.providers.len() { + let provider = &mut self.providers[idx]; + if provider.alive + && let Some(died_at) = provider.liveness.dead_since() + { + provider.alive = false; + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + // Backoff counts from the death, not from this sweep, so a + // trap whose backoff already elapsed restarts right below. + provider.next_attempt = Some(died_at.checked_add(backoff).unwrap_or(now)); + warn!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + "adapter trapped - marked dead; will restart after backoff", + ); + metrics::counter!( + "shepherd_adapter_errors_total", + "adapter" => provider.name.clone(), + "error_kind" => "trap", + ) + .increment(1); + if let Some(recent) = poison_crossed(&mut provider.failure_timestamps, policy) + && !provider.poisoned + { + provider.poisoned = true; + warn!( + adapter = %provider.name, + recent_failures = recent, + window_secs = policy.window.as_secs(), + "adapter poisoned - quarantined; remove from engine.toml + restart to clear", + ); + metrics::gauge!( + "shepherd_adapter_poisoned", + "adapter" => provider.name.clone(), + ) + .set(1.0); + } + } + let provider = &self.providers[idx]; + if !provider.poisoned + && !provider.alive + && provider.next_attempt.is_some_and(|t| t <= now) + { + self.try_restart_provider(idx).await; + } + } + } + + /// Attempt to reinstall a dead provider in place: fresh store, fresh + /// instance, `init`, and a re-install replacing the dead slot. On + /// success the shared liveness is revived; on failure the backoff + /// slides further out, like a module restart. + async fn try_restart_provider(&mut self, idx: usize) { + let name = self.providers[idx].name.clone(); + let failure_count = self.providers[idx].failure_count; + info!(adapter = %name, failure_count, "adapter restart attempt"); + metrics::counter!( + "shepherd_adapter_restarts_total", + "adapter" => name.clone(), + ) + .increment(1); + let outcome = self.reinstall_provider(idx).await; + let provider = &mut self.providers[idx]; + match outcome { + Ok(Installed::Live) => { + provider.run_seq += 1; + provider.liveness.mark_alive(); + provider.alive = true; + provider.failure_count = 0; + provider.next_attempt = None; + info!(adapter = %name, "adapter restart succeeded"); + } + Ok(Installed::Dead) => { + defer_provider_restart(provider, "init returned fault on restart"); + } + Err(e) => defer_provider_restart(provider, &format!("{e:#}")), + } + } + + /// Rebuild a provider from its cached component and grants, then hand + /// it back to its kind to instantiate and install over the dead slot. + async fn reinstall_provider(&mut self, idx: usize) -> Result { + let provider = &self.providers[idx]; + let (kind, service) = self + .kinds + .get(provider.kind) + .ok_or_else(|| anyhow!("provider kind {} is not registered", provider.kind))?; + let linker = build_provider_linker::(&self.engine, kind.as_ref())?; + // A restart is a new run, like a module's. + let run = RunId::new(provider.name.clone(), provider.run_seq + 1); + let store = Self::build_store( + &self.engine, + &self.components, + run, + provider.http_allow.clone(), + provider.http_limits, + provider.messaging_topics.clone(), + provider.memory_limit, + provider.fuel_per_call, + provider.chain_response_max_bytes, + provider.local_store_bytes, + self.clocks.as_ref(), + HostServices::default(), + )?; + kind.install( + ProviderInstance { + component: &provider.component, + linker: &linker, + store, + config: provider.init_config.clone(), + fuel_per_call: provider.fuel_per_call, + liveness: provider.liveness.clone(), + }, + service, + ) + .await + } + /// Count of modules currently alive. A module is not alive when its /// `init` returned `Err` (permanent, never retried) or when `on_event` /// trapped and its restart backoff has not yet elapsed. @@ -1591,28 +1792,38 @@ enum DispatchOutcome { RateLimited, } -/// Push the current trap timestamp into the module's -/// failure-window ring, drop entries older than the policy window, -/// and flip `poisoned = true` once the window holds more than -/// `policy.max_failures` traps. The first transition emits the -/// `shepherd_module_poisoned` gauge + a structured WARN. -fn record_failure_and_maybe_poison( - module: &mut LoadedModule, +/// Push the current trap timestamp into a component's failure-window +/// ring, drop entries older than the policy window, and report the +/// recent-failure count once it crosses `policy.max_failures`. Shared by +/// the module and provider poison sweeps. +fn poison_crossed( + failure_timestamps: &mut std::collections::VecDeque, policy: crate::runtime::poison_policy::PoisonPolicy, - last_error: &str, -) { +) -> Option { let now = std::time::Instant::now(); - // Prune entries outside the window. - while let Some(&front) = module.failure_timestamps.front() { + while let Some(&front) = failure_timestamps.front() { if now.duration_since(front) > policy.window { - module.failure_timestamps.pop_front(); + failure_timestamps.pop_front(); } else { break; } } - module.failure_timestamps.push_back(now); - let recent = module.failure_timestamps.len() as u32; - if crate::runtime::poison_policy::should_poison(policy, recent) && !module.poisoned { + failure_timestamps.push_back(now); + let recent = failure_timestamps.len() as u32; + crate::runtime::poison_policy::should_poison(policy, recent).then_some(recent) +} + +/// Flip `poisoned = true` once the module's failure window crosses the +/// policy threshold. The first transition emits the +/// `shepherd_module_poisoned` gauge + a structured WARN. +fn record_failure_and_maybe_poison( + module: &mut LoadedModule, + policy: crate::runtime::poison_policy::PoisonPolicy, + last_error: &str, +) { + if let Some(recent) = poison_crossed(&mut module.failure_timestamps, policy) + && !module.poisoned + { module.poisoned = true; warn!( module = %module.name, @@ -1629,6 +1840,20 @@ fn record_failure_and_maybe_poison( } } +/// Slide a failed provider restart's next attempt further out. +fn defer_provider_restart(provider: &mut LoadedProvider, error: &str) { + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + provider.next_attempt = Some(std::time::Instant::now() + backoff); + error!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + error, + "adapter restart failed - will retry after backoff", + ); +} + /// Persisted per-chain progress key; must stay numeric for data compat. fn progress_key(chain: Chain) -> String { format!("last_dispatched_block:{}", chain.id()) diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 3830e6f3..0bf06086 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -651,7 +651,11 @@ fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::V ) .build(); registry - .install(crate::host::venue_registry::VenueId::from("cow"), adapter) + .install( + crate::host::venue_registry::VenueId::from("cow"), + crate::host::actor::Liveness::default(), + adapter, + ) .expect("install"); registry } @@ -2959,3 +2963,164 @@ async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { "the kind gate passed rather than rejecting: {msg}", ); } + +// ── venue-adapter trap recovery ─────────────────────────────────────── + +/// Boot one flaky-venue adapter over the mock chain, whose head starts at +/// the fixture's poison sentinel. Returns the chain handle so the test can +/// let the venue recover. +async fn boot_flaky_venue( + adapter_wasm: PathBuf, + limits: crate::engine_config::ModuleLimits, +) -> ( + Supervisor, + crate::test_utils::MockChainProvider, +) { + use crate::engine_config::AdapterEntry; + use crate::host::component::ChainMethod; + + let chain = crate::test_utils::MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); + let components = crate::test_utils::mock_components_from( + chain.clone(), + crate::test_utils::MockStateStore::new(), + ); + let engine = make_wasmtime_engine(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(fixture_module_toml( + "modules/fixtures/flaky-venue/module.toml", + )), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + limits, + ..Default::default() + }; + let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) + .await + .expect("boot"); + (supervisor, chain) +} + +/// A test block that drives the dispatch-time sweeps. +fn sweep_block() -> nexum::host::types::Block { + nexum::host::types::Block { + chain_id: 1, + number: 1, + hash: vec![0; 32], + timestamp: 1_700_000_000_000, + } +} + +/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped +/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the +/// provider restart sweep reinstantiates it after backoff, after which a +/// submit succeeds again. +#[tokio::test] +async fn e2e_trapped_adapter_is_swept_and_restarts() { + use crate::bindings::{SubmitOutcome, VenueError}; + use crate::host::component::ChainMethod; + use crate::host::venue_registry::VenueId; + + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let (mut supervisor, chain) = + boot_flaky_venue(wasm, crate::engine_config::ModuleLimits::default()).await; + assert_eq!(supervisor.adapter_count(), 1); + assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); + let registry = supervisor.venue_registry().expect("registry service"); + let venue = VenueId::from("flaky-venue"); + + // The poison head detonates submit: the guest panic traps the store + // and the shared liveness drops. + let err = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect_err("the poison head traps the adapter"); + assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "the trap drops liveness" + ); + + // Temporarily dead resolves distinctly from never installed. + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + + // The venue recovers; past the 1s backoff the dispatch-time sweep + // reinstalls the adapter on a fresh store. + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); + let outcome = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect("the recovered adapter accepts"); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); +} + +/// A crash-looping adapter is quarantined by the provider poison sweep: +/// at the threshold the restarts stop, and the venue stays dead past every +/// backoff until an operator intervenes. +#[tokio::test] +async fn e2e_crash_looping_adapter_is_poisoned() { + use crate::bindings::VenueError; + use crate::engine_config::PoisonLimitsSection; + use crate::host::venue_registry::VenueId; + + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let limits = ModuleLimits { + poison: PoisonLimitsSection { + max_failures: Some(2), + window_secs: Some(600), + }, + ..ModuleLimits::default() + }; + // The chain head stays at the poison sentinel for the whole test: every + // submit after a restart traps again. + let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; + let registry = supervisor.venue_registry().expect("registry service"); + let venue = VenueId::from("flaky-venue"); + + // Trap 1, then a successful restart past the 1s backoff. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); + + // Trap 2 crosses the 2-failure threshold: the sweep quarantines the + // adapter instead of scheduling another restart. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); + + // Past every backoff the poisoned adapter stays dead and unavailable. + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "no restart while poisoned" + ); + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); +} diff --git a/justfile b/justfile index 964bba79..b1311631 100644 --- a/justfile +++ b/justfile @@ -97,6 +97,6 @@ ci: cargo build --release --target wasm32-wasip2 \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ - -p echo-client -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb \ - -p panic-bomb -p slow-host + -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue -p fuel-bomb \ + -p memory-bomb -p panic-bomb -p slow-host cargo test --workspace --all-features --no-fail-fast diff --git a/modules/fixtures/flaky-venue/Cargo.toml b/modules/fixtures/flaky-venue/Cargo.toml new file mode 100644 index 00000000..5237feb0 --- /dev/null +++ b/modules/fixtures/flaky-venue/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "flaky-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Evil-by-design venue adapter fixture: submit panics while the chain head reads as the poison sentinel and accepts once it stops. Drives the supervisor's provider trap-to-recovery and poison sweeps." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } +wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/flaky-venue/module.toml b/modules/fixtures/flaky-venue/module.toml new file mode 100644 index 00000000..c7667b9d --- /dev/null +++ b/modules/fixtures/flaky-venue/module.toml @@ -0,0 +1,19 @@ +# flaky-venue adapter manifest - the trap-to-recovery test fixture. +# Declares the chain capability its poison-sentinel read requires. + +[module] +name = "flaky-venue" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["chain"] +optional = [] + +[capabilities.http] +allow = [] + +[config] +name = "flaky-venue" diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs new file mode 100644 index 00000000..af1c9d9b --- /dev/null +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -0,0 +1,76 @@ +//! # flaky-venue (test fixture) +//! +//! A venue adapter whose `submit` panics (traps the store) while the chain +//! head reads as the poison sentinel `0xdead`, and accepts once the head +//! moves on. The controlling test programs the mock chain backend, so the +//! trap is deterministic and the recovery is host-driven: the supervisor's +//! sweep must reinstantiate the adapter before a submit succeeds again. +//! +//! Not a production adapter. Lives under `modules/fixtures/` so it is +//! obviously test-only. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::chain; +use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, +}; +use videre::value_flow::types::{Asset, AssetAmount}; + +/// The chain-head response that detonates `submit`. +const POISON_HEAD: &str = "0xdead"; + +struct FlakyVenue; + +#[nexum_venue_sdk::venue] +impl FlakyVenue { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(_body: Vec) -> Result { + Ok(IntentHeader { + gives: zero_native(), + wants: zero_native(), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, + }) + } + + fn quote(_body: Vec) -> Result { + Ok(Quotation { + gives: zero_native(), + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, + }) + } + + fn submit(body: Vec) -> Result { + let head = chain::request(1, "eth_blockNumber", "[]") + .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; + // The sentinel detonates the fixture: a guest panic traps the + // store, which is what the sweep under test must recover from. + assert!(!head.contains(POISON_HEAD), "flaky-venue poison head"); + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + Ok(IntentStatus::Open) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +/// A zero native amount. +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + } +} From f21855f52742a05d7290b8c6d8ff8aba0e73de26 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 00:28:13 +0000 Subject: [PATCH 23/53] feat: extract a generic launcher and split the bare and cow binaries nexum-launch owns the shared CLI, config load, tracing init, and the preset-driven run; nexum-cli becomes the bare Ext=() engine binary over CoreRuntime with no cow edge; the cow composition root moves to a new shepherd binary wiring the cow-api extension as a Runtime preset. The venue-agnostic guard's crate-graph check now covers the launcher and the bare binary, and the cow deployment tooling builds shepherd. --- Cargo.lock | 23 +++++++- Cargo.toml | 2 + Dockerfile | 12 ++-- README.md | 4 +- crates/nexum-cli/Cargo.toml | 7 +-- crates/nexum-cli/src/cli.rs | 60 -------------------- crates/nexum-cli/src/launch.rs | 58 -------------------- crates/nexum-cli/src/main.rs | 47 ++-------------- crates/nexum-launch/Cargo.toml | 17 ++++++ crates/nexum-launch/src/cli.rs | 85 +++++++++++++++++++++++++++++ crates/nexum-launch/src/lib.rs | 66 ++++++++++++++++++++++ crates/nexum-runtime/src/builder.rs | 2 +- crates/shepherd/Cargo.toml | 21 +++++++ crates/shepherd/src/main.rs | 60 ++++++++++++++++++++ docker-compose.soak.yml | 2 +- justfile | 13 +++-- scripts/check-venue-agnostic.sh | 34 +++++++----- scripts/e2e-run.sh | 6 +- scripts/load-run.sh | 6 +- 19 files changed, 321 insertions(+), 204 deletions(-) delete mode 100644 crates/nexum-cli/src/cli.rs delete mode 100644 crates/nexum-cli/src/launch.rs create mode 100644 crates/nexum-launch/Cargo.toml create mode 100644 crates/nexum-launch/src/cli.rs create mode 100644 crates/nexum-launch/src/lib.rs create mode 100644 crates/shepherd/Cargo.toml create mode 100644 crates/shepherd/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 217777ba..bf3873ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3575,10 +3575,18 @@ name = "nexum-cli" version = "0.2.0" dependencies = [ "anyhow", - "clap", + "nexum-launch", "nexum-runtime", - "shepherd-cow-host", "tokio", +] + +[[package]] +name = "nexum-launch" +version = "0.2.0" +dependencies = [ + "anyhow", + "clap", + "nexum-runtime", "tracing", "tracing-subscriber", ] @@ -5158,6 +5166,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shepherd" +version = "0.2.0" +dependencies = [ + "anyhow", + "nexum-launch", + "nexum-runtime", + "shepherd-cow-host", + "tokio", +] + [[package]] name = "shepherd-backtest" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 418564d7..0a562877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/cow-venue", "crates/nexum-cli", + "crates/nexum-launch", "crates/nexum-macros", "crates/nexum-runtime", "crates/nexum-sdk", @@ -11,6 +12,7 @@ members = [ "crates/nexum-venue-sdk", "crates/nexum-venue-test", "crates/nexum-world", + "crates/shepherd", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", diff --git a/Dockerfile b/Dockerfile index eb7fa3f0..d7465ff5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.6 # -# Multi-stage build for `nexum` (Shepherd) - the engine binary -# plus the five production WASM modules baked into a single image. +# Multi-stage build for `shepherd` - the cow composition-root engine +# binary plus the five production WASM modules baked into a single image. # # Stage 1 (`build`): full Rust toolchain + wasm32-wasip2 target, builds # the engine in release mode + each module to a Component Model wasm @@ -65,7 +65,7 @@ FROM chef AS build # changed workspace crate. `--locked` stays on the real builds below, which # validate the committed Cargo.lock verbatim. COPY --from=planner /src/recipe.json recipe.json -RUN cargo chef cook --release -p nexum-cli --recipe-path recipe.json \ +RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss --recipe-path recipe.json @@ -77,7 +77,7 @@ COPY . . # Engine binary in release. --locked ensures the committed Cargo.lock # is used verbatim so builds are reproducible. -RUN cargo build -p nexum-cli --release --locked +RUN cargo build -p shepherd --release --locked # Five production modules. The wasm artefacts land under # `target/wasm32-wasip2/release/.wasm`. @@ -108,7 +108,7 @@ RUN apt-get update \ && install -d -o root -g root -m 0755 /etc/shepherd # Engine binary. -COPY --from=build /src/target/release/nexum /usr/local/bin/nexum +COPY --from=build /src/target/release/shepherd /usr/local/bin/shepherd # Module .wasm artefacts. The Component Model wasm files are loaded # by the engine at boot via the `[[modules]]` entries in engine.toml. @@ -138,5 +138,5 @@ EXPOSE 9100 # `--engine-config /etc/shepherd/engine.toml` matches the production # guide's expected mount point. Operators override via # `docker run ... -v /path/to/engine.toml:/etc/shepherd/engine.toml:ro`. -ENTRYPOINT ["/usr/bin/tini", "--", "nexum"] +ENTRYPOINT ["/usr/bin/tini", "--", "shepherd"] CMD ["--engine-config", "/etc/shepherd/engine.toml"] diff --git a/README.md b/README.md index 91026dd0..8c94e3ac 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs | Path | Purpose | | --- | --- | | `crates/nexum-runtime/` | The **engine** - the Nexum Runtime's reference host: a wasmtime implementation of the `nexum:host` contract. | -| `crates/nexum-cli/` | The `nexum` binary - a thin CLI over the runtime library. | +| `crates/nexum-launch/` | The generic launcher library - shared CLI, config load, tracing, and the preset-driven launch. | +| `crates/nexum-cli/` | The bare `nexum` binary - the core lattice with no extension payload. | +| `crates/shepherd/` | The `shepherd` binary - the cow composition root wiring the cow-api extension. | | `crates/nexum-sdk/` | Generic guest SDK - the host trait seam, bind macro, chain/config/address helpers, wasi:http `fetch`, and tracing facade for any module. | | `crates/shepherd-sdk/` | CoW-domain guest SDK - the cow-api trait and CoW Protocol helpers on top of `nexum-sdk`. | | `wit/nexum-host/` | The **`nexum:host`** WIT package - the host/guest contract every engine implements and every module imports. | diff --git a/crates/nexum-cli/Cargo.toml b/crates/nexum-cli/Cargo.toml index cc727db9..05bab22d 100644 --- a/crates/nexum-cli/Cargo.toml +++ b/crates/nexum-cli/Cargo.toml @@ -13,13 +13,8 @@ name = "nexum" path = "src/main.rs" [dependencies] +nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } -# Composition root wires the cow-api host extension into the reference -# lattice; the runtime itself carries no cow dependency. -shepherd-cow-host = { path = "../shepherd-cow-host" } anyhow.workspace = true -clap.workspace = true tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true diff --git a/crates/nexum-cli/src/cli.rs b/crates/nexum-cli/src/cli.rs deleted file mode 100644 index 82b7739f..00000000 --- a/crates/nexum-cli/src/cli.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! CLI surface for the `nexum` binary, derived via clap. -//! -//! The 0.2 binary accepts either a positional ` []` -//! shortcut that synthesises a one-module engine config, or a -//! `--engine-config ` flag that points at a TOML declaring -//! multiple modules. Production deployments use the second form; the -//! positional shortcut stays for parity with the M1 reference CLI and -//! for smoke tests. - -use std::path::PathBuf; - -use clap::Parser; - -/// Parsed CLI surface. -/// -/// `nexum [ []] [--engine-config ] [--pretty-logs]` -/// -/// Positional `` is a backwards-compat shortcut that -/// synthesises a one-module engine config. Production deployments pass -/// `--engine-config` and declare modules in TOML. -/// -/// `--pretty-logs` selects the human-readable tracing formatter (the -/// historical 0.1 default). Without the flag the engine emits JSON -/// log lines per the structured-logging contract: a single -/// `jq` / Loki / Grafana stream reconstructs the full timeline of -/// any dispatch, host call, or order submission. -#[derive(Parser, Debug, Default)] -#[command( - name = "nexum", - about = "Run one or more Wasm Component modules under the Shepherd supervisor", - long_about = None, - version, -)] -pub struct Cli { - /// Optional positional path to a Wasm Component file. Synthesises - /// a one-module engine config when no `--engine-config` is given. - pub wasm: Option, - - /// Optional positional path to the module's `module.toml` manifest. - /// Only consulted alongside the positional `wasm` shortcut. - pub manifest: Option, - - /// Optional explicit path to the engine-wide `engine.toml` config. - /// When omitted, the engine resolves the default search path - /// documented in `engine_config::load_or_default`. - #[arg(long = "engine-config")] - pub engine_config: Option, - - /// Use the human-readable tracing formatter instead of the - /// default JSON formatter (structured-logging contract). - #[arg(long = "pretty-logs")] - pub pretty_logs: bool, - - /// Override the chain-log poller's per-block `eth_getLogs` - /// concurrency during backfill. Higher catches up faster at more - /// node load. Overrides `[engine] log_backfill_concurrency` when - /// set. - #[arg(long = "log-backfill-concurrency")] - pub log_backfill_concurrency: Option, -} diff --git a/crates/nexum-cli/src/launch.rs b/crates/nexum-cli/src/launch.rs deleted file mode 100644 index dd67890c..00000000 --- a/crates/nexum-cli/src/launch.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Composition root: bind the reference lattice (core backends plus the -//! cow-api extension in the `Ext` slot), build the shared backends and the -//! extension list, then hand off to the generic runtime launch. - -use std::path::Path; - -use nexum_runtime::addons::{PrometheusAddOn, RuntimeAddOn}; -use nexum_runtime::builder::RuntimeBuilder; -use nexum_runtime::engine_config::EngineConfig; -use nexum_runtime::host::component::{ - ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, -}; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; - -/// The backends the reference engine ships: the core seams plus the -/// cow-api extension payload in the [`Ext`](RuntimeTypes::Ext) slot. -#[derive(Debug, Clone, Copy, Default)] -struct ReferenceTypes; - -impl RuntimeTypes for ReferenceTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -/// Build the reference backends and extension list, then run until shutdown. -pub async fn run_from_config( - engine_cfg: &EngineConfig, - wasm: Option<&Path>, - manifest: Option<&Path>, -) -> anyhow::Result<()> { - // Attach the reference add-on set. The binary ships the Prometheus - // exporter; an embedder omits or replaces it by choosing a different - // list here. - let add_ons: [&dyn RuntimeAddOn; 1] = [&PrometheusAddOn]; - - // Assemble and launch over the type-state builder: bind the reference - // lattice, wire cow-api as an extension (linker hook plus capability - // namespace; the core runtime knows nothing of cow, it plugs in here), - // name the component builders, then drive to a running handle and block - // until the event loop returns on shutdown. - RuntimeBuilder::new(engine_cfg) - .with_types::() - .with_extensions([extension::()]) - .with_module_source(wasm.map(Path::to_path_buf), manifest.map(Path::to_path_buf)) - .with_components(ComponentsBuilder::new( - ProviderPoolBuilder, - LocalStoreBuilder, - ReferenceExtBuilder, - )) - .with_add_ons(&add_ons) - .launch() - .await? - .wait() - .await -} diff --git a/crates/nexum-cli/src/main.rs b/crates/nexum-cli/src/main.rs index f2ce5f46..c7be67c3 100644 --- a/crates/nexum-cli/src/main.rs +++ b/crates/nexum-cli/src/main.rs @@ -1,48 +1,11 @@ -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -mod cli; -mod launch; +//! The bare `nexum` engine binary: the core lattice with no extension +//! payload, composed over the generic launcher. -use clap::Parser; -use tracing::info; -use tracing_subscriber::EnvFilter; +#![cfg_attr(not(test), warn(unused_crate_dependencies))] -use crate::cli::Cli; -use nexum_runtime::engine_config; +use nexum_runtime::preset::CoreRuntime; #[tokio::main] async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - - let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; - if let Some(n) = cli.log_backfill_concurrency { - engine_cfg.engine.log_backfill_concurrency = n; - } - - let env_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) - .unwrap_or_else(|_| EnvFilter::new("info")); - // Structured logging: JSON by default (machine-readable - // for production; one `jq` query reconstructs any dispatch - // timeline); `--pretty-logs` opts back into the 0.1 human-readable - // formatter for local dev. The same `EnvFilter` applies to both - // so `RUST_LOG=debug` works identically. - if cli.pretty_logs { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .init(); - } else { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .json() - .flatten_event(true) - .with_current_span(false) - .init(); - } - - info!("nexum starting"); - - launch::run_from_config(&engine_cfg, cli.wasm.as_deref(), cli.manifest.as_deref()).await + nexum_launch::run("nexum", CoreRuntime).await } diff --git a/crates/nexum-launch/Cargo.toml b/crates/nexum-launch/Cargo.toml new file mode 100644 index 00000000..aff18adc --- /dev/null +++ b/crates/nexum-launch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nexum-launch" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +nexum-runtime = { path = "../nexum-runtime" } + +anyhow.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/nexum-launch/src/cli.rs b/crates/nexum-launch/src/cli.rs new file mode 100644 index 00000000..b9af9baa --- /dev/null +++ b/crates/nexum-launch/src/cli.rs @@ -0,0 +1,85 @@ +//! Shared CLI surface for engine binaries, derived via clap. + +use std::path::PathBuf; + +use clap::{CommandFactory, FromArgMatches, Parser}; + +/// Parsed CLI surface. +/// +/// ` [ []] [--engine-config ] [--pretty-logs]` +/// +/// Positional `` synthesises a one-module engine config. +/// Production deployments pass `--engine-config` and declare modules in +/// TOML. +/// +/// `--pretty-logs` selects the human-readable tracing formatter; without +/// it the engine emits JSON log lines per the structured-logging contract. +#[derive(Parser, Debug, Default)] +#[command( + about = "Run one or more Wasm Component modules under the engine supervisor", + long_about = None, + version, +)] +pub struct Cli { + /// Optional positional path to a Wasm Component file. Synthesises + /// a one-module engine config when no `--engine-config` is given. + pub wasm: Option, + + /// Optional positional path to the module's `module.toml` manifest. + /// Only consulted alongside the positional `wasm` shortcut. + pub manifest: Option, + + /// Optional explicit path to the engine-wide `engine.toml` config. + /// When omitted, the engine resolves the default search path + /// documented in `engine_config::load_or_default`. + #[arg(long = "engine-config")] + pub engine_config: Option, + + /// Use the human-readable tracing formatter instead of the + /// default JSON formatter (structured-logging contract). + #[arg(long = "pretty-logs")] + pub pretty_logs: bool, + + /// Override the chain-log poller's per-block `eth_getLogs` + /// concurrency during backfill. Higher catches up faster at more + /// node load. Overrides `[engine] log_backfill_concurrency` when + /// set. + #[arg(long = "log-backfill-concurrency")] + pub log_backfill_concurrency: Option, +} + +impl Cli { + /// Parse the process arguments under the binary's `name`, exiting on + /// `--help`/`--version` or a usage error. + #[must_use] + pub fn parse_as(name: &'static str) -> Self { + let matches = Self::command().name(name).get_matches(); + Self::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The flags land on the parsed surface under a caller-supplied name. + #[test] + fn flags_parse_under_a_supplied_name() { + let matches = Cli::command() + .name("nexum") + .try_get_matches_from([ + "nexum", + "--engine-config", + "engine.toml", + "--pretty-logs", + "--log-backfill-concurrency", + "8", + ]) + .expect("valid arguments parse"); + let cli = Cli::from_arg_matches(&matches).expect("matches destructure"); + assert_eq!(cli.engine_config, Some(PathBuf::from("engine.toml"))); + assert!(cli.pretty_logs); + assert_eq!(cli.log_backfill_concurrency, Some(8)); + assert!(cli.wasm.is_none()); + } +} diff --git a/crates/nexum-launch/src/lib.rs b/crates/nexum-launch/src/lib.rs new file mode 100644 index 00000000..6994ca29 --- /dev/null +++ b/crates/nexum-launch/src/lib.rs @@ -0,0 +1,66 @@ +//! Generic engine launcher: parse the shared CLI, load the engine config, +//! initialise tracing, and drive a [`Runtime`] preset until shutdown. +//! +//! A binary is one line: `nexum_launch::run("nexum", CoreRuntime)`. The +//! preset supplies the lattice, backends, extension list, and add-ons; +//! this crate knows nothing beyond the runtime seam. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod cli; + +pub use cli::Cli; + +use nexum_runtime::builder::RuntimeBuilder; +use nexum_runtime::engine_config::{self, EngineConfig}; +use nexum_runtime::preset::Runtime; +use tracing::info; +use tracing_subscriber::EnvFilter; + +/// Parse the process arguments as `name`, then [`launch`] the preset. +pub async fn run(name: &'static str, preset: R) -> anyhow::Result<()> { + launch(name, preset, Cli::parse_as(name)).await +} + +/// Load the config, initialise tracing, and run the preset until shutdown. +pub async fn launch(name: &str, preset: R, cli: Cli) -> anyhow::Result<()> { + let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; + if let Some(n) = cli.log_backfill_concurrency { + engine_cfg.engine.log_backfill_concurrency = n; + } + + init_tracing(cli.pretty_logs, &engine_cfg); + + info!("{name} starting"); + + RuntimeBuilder::new(&engine_cfg) + .with_runtime(preset) + .with_module_source(cli.wasm, cli.manifest) + .launch() + .await? + .wait() + .await +} + +/// Install the global tracing subscriber: JSON by default (machine-readable +/// for production), the human-readable formatter behind `--pretty-logs`. +/// The same [`EnvFilter`] applies to both, so `RUST_LOG` works identically. +fn init_tracing(pretty: bool, engine_cfg: &EngineConfig) { + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) + .unwrap_or_else(|_| EnvFilter::new("info")); + if pretty { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .json() + .flatten_event(true) + .with_current_span(false) + .init(); + } +} diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 49eb53aa..7c836f07 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -9,7 +9,7 @@ //! loop - and returns a [`RuntimeHandle`] owning the manager and the //! running tasks. //! -//! The reference binary reaches this through its `run_from_config` one-liner; +//! The engine binaries reach this through the `nexum-launch` preset run; //! an embedder holding pre-built backends constructs an [`AssembledRuntime`] //! and calls [`LaunchRuntime::launch`] directly. For the common case, //! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml new file mode 100644 index 00000000..63e99085 --- /dev/null +++ b/crates/shepherd/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "shepherd" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "shepherd" +path = "src/main.rs" + +[dependencies] +nexum-launch = { path = "../nexum-launch" } +nexum-runtime = { path = "../nexum-runtime" } +shepherd-cow-host = { path = "../shepherd-cow-host" } + +anyhow.workspace = true +tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs new file mode 100644 index 00000000..4022ff57 --- /dev/null +++ b/crates/shepherd/src/main.rs @@ -0,0 +1,60 @@ +//! The `shepherd` binary: the cow composition root. Binds the reference +//! lattice with the cow-api extension payload in the `Ext` slot and hands +//! it to the generic launcher; the engine itself stays cow-free. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +use std::sync::Arc; + +use nexum_runtime::addons::{AddOns, PrometheusAddOn}; +use nexum_runtime::host::component::{ + ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, +}; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::host::local_store_redb::LocalStore; +use nexum_runtime::host::provider_pool::ProviderPool; +use nexum_runtime::preset::Runtime; +use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; + +/// The reference lattice: the core backends with the cow-api payload in +/// the `Ext` slot. +#[derive(Debug, Clone, Copy, Default)] +struct ReferenceTypes; + +impl RuntimeTypes for ReferenceTypes { + type Chain = ProviderPool; + type Store = LocalStore; + type Ext = ReferenceExt; +} + +/// The cow preset: reference backends, the cow-api extension, and the +/// Prometheus add-on. +#[derive(Debug, Clone, Copy, Default)] +struct ShepherdRuntime; + +impl Runtime for ShepherdRuntime { + type Types = ReferenceTypes; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = ReferenceExtBuilder; + type LogsBuilder = LogPipelineBuilder; + + fn components( + self, + ) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ReferenceExtBuilder) + } + + fn add_ons(&self) -> AddOns { + vec![Box::new(PrometheusAddOn)] + } + + fn extensions(&self) -> Vec>> { + vec![extension::()] + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + nexum_launch::run("shepherd", ShepherdRuntime).await +} diff --git a/docker-compose.soak.yml b/docker-compose.soak.yml index 0921f186..b48a9027 100644 --- a/docker-compose.soak.yml +++ b/docker-compose.soak.yml @@ -1,7 +1,7 @@ # docker-compose.soak.yml — 7-day grant stability soak. # # Two services: -# engine — the nexum binary; restarts automatically on crash. +# engine — the shepherd binary; restarts automatically on crash. # snapshotter — Alpine loop that scrapes /metrics every hour and # writes evidence files to docs/operations/soak-reports/. # diff --git a/justfile b/justfile index b1311631..de542480 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,7 @@ -# Build the host engine +# Build the engine binaries: the bare `nexum` engine and the cow +# composition root `shepherd`. build-engine: - cargo build -p nexum-cli + cargo build -p nexum-cli -p shepherd # Build the example WASM module build-module: @@ -38,7 +39,7 @@ build-m2: # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. run-m2: build-m2 build-engine - cargo run -p nexum-cli -- --engine-config engine.m2.toml --pretty-logs + cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs # Build the M3 example modules (price-alert + balance-tracker + stop-loss) # for wasm32-wasip2. @@ -52,7 +53,7 @@ build-m3: # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. run-m3: build-m3 build-engine - cargo run -p nexum-cli -- --engine-config engine.m3.toml --pretty-logs + cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs # Build the http-probe example module (wasi:http fetch + allowlist # denial demo) for wasm32-wasip2. @@ -69,7 +70,7 @@ build-e2e: build-m2 build-m3 # downstream `jq` filter can mine submitted/dropped/backoff markers # for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. run-e2e: build-e2e build-engine - cargo run -p nexum-cli -- --engine-config engine.e2e.toml + cargo run -p shepherd -- --engine-config engine.e2e.toml # Assert nexum-runtime is venue-agnostic: crate graph, symbol scan, and # the nexum:host WIT leaf. Advisory in CI until the physical cut lands. @@ -80,7 +81,7 @@ check-venue-agnostic: check: cargo check --target wasm32-wasip2 -p example cargo check -p nexum-runtime - cargo check -p nexum-cli + cargo check -p nexum-cli -p shepherd # Run the full CI series locally before pushing. Mirrors # .github/workflows/ci.yml one-to-one: rustfmt, clippy, rustdoc, the diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index e3feef76..26d60d3a 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Venue-agnosticism check for nexum-runtime: the crate graph reaches no -# videre/intent/venue/cow crate, the sources carry no venue symbol, and -# nexum:host resolves as a leaf WIT package. Advisory in CI until the -# physical cut lands; run locally via `just check-venue-agnostic`. +# Venue-agnosticism check for the host layer: no host-layer crate graph +# (runtime, launcher, bare engine) reaches a videre/intent/venue/cow +# crate, the runtime sources carry no venue symbol, and nexum:host +# resolves as a leaf WIT package. Advisory in CI until the physical cut +# lands; run locally via `just check-venue-agnostic`. set -uo pipefail @@ -16,19 +17,22 @@ command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } status=0 -# 1. Crate graph: nothing venue-shaped reachable from nexum-runtime -# (normal + build edges; dev-deps stay local to the crate). -if tree="$(cargo tree -p nexum-runtime -e normal,build --all-features --prefix none --locked)"; then - reached="$(printf '%s\n' "$tree" | - awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" - if [[ -n $reached ]]; then - fail "crate graph reaches: $(tr '\n' ' ' <<<"$reached")" +# 1. Crate graph: nothing venue-shaped reachable from the host-layer +# crates - the runtime, the generic launcher, and the bare engine +# binary (normal + build edges; dev-deps stay local to the crate). +for crate in nexum-runtime nexum-launch nexum-cli; do + if tree="$(cargo tree -p "$crate" -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "$crate crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "$crate crate graph clean" + fi else - pass "crate graph clean" + fail "cargo tree failed for $crate" fi -else - fail "cargo tree failed" -fi +done # 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes # skip std::borrow::Cow, ProviderError, and "intentional". diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index ce24b343..8ec2f4e1 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -7,7 +7,7 @@ # gitignored. # 3. Cleans data/e2e for a fresh local-store. # 4. Builds all 5 modules + the engine. -# 5. Launches nexum via nohup, redirecting stdout/stderr to +# 5. Launches shepherd via nohup, redirecting stdout/stderr to # docs/operations/e2e-reports/engine-.log. JSON logs # (no --pretty-logs) so e2e-report-gen.sh can mine them with jq. # 6. Waits up to 60 s for the `supervisor ready modules=5 chains=1` @@ -52,7 +52,7 @@ log "building 5 modules + engine (this can take a minute on first run)" cargo build -p price-alert --target wasm32-wasip2 --release >/dev/null cargo build -p balance-tracker --target wasm32-wasip2 --release >/dev/null cargo build -p stop-loss --target wasm32-wasip2 --release >/dev/null - cargo build -p nexum-cli --release >/dev/null + cargo build -p shepherd --release >/dev/null ) ts="$(date -u +%Y%m%dT%H%M%SZ)" @@ -63,7 +63,7 @@ start_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" log "launching engine — log: $log_file" ( cd "$REPO_ROOT" - nohup "$REPO_ROOT/target/release/nexum" \ + nohup "$REPO_ROOT/target/release/shepherd" \ --engine-config "$REPO_ROOT/engine.e2e.local.toml" \ >"$log_file" 2>&1 & echo $! > "$STATE_FILE.pid.tmp" diff --git a/scripts/load-run.sh b/scripts/load-run.sh index 91cef9b0..af636aef 100755 --- a/scripts/load-run.sh +++ b/scripts/load-run.sh @@ -80,10 +80,10 @@ log "building modules + engine + load-gen (release)" ( cd "$REPO_ROOT" && cargo build --release --quiet \ --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher ) -( cd "$REPO_ROOT" && cargo build --release --quiet -p nexum-cli -p load-gen ) +( cd "$REPO_ROOT" && cargo build --release --quiet -p shepherd -p load-gen ) -log "starting nexum (engine.load.toml)" -( cd "$REPO_ROOT" && ./target/release/nexum --engine-config engine.load.toml ) \ +log "starting shepherd (engine.load.toml)" +( cd "$REPO_ROOT" && ./target/release/shepherd --engine-config engine.load.toml ) \ >"$LOG_DIR/engine.log" 2>&1 & ENGINE_PID=$! echo "ENGINE_PID=$ENGINE_PID" >>"$PID_FILE" From 1b9074a75657483452cb2a860af1349747b3dfe0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 02:03:38 +0000 Subject: [PATCH 24/53] feat: build the videre-host crate and register the venue platform through the seam Relocate the venue-adapter and client bindgens, the venue registry, the advisory egress guard, and the venue-adapter provider kind out of nexum-runtime into a new videre-host extension crate, registered whole via videre_host::platform(). The runtime grows two generic seams in exchange: extension-declared subscription kinds with attribute-filtered extension events through the event loop, and config-derived preset extensions. nexum-runtime now carries zero venue, intent, or cow symbols and the venue-agnostic check passes. --- Cargo.lock | 19 +- Cargo.toml | 1 + crates/nexum-runtime/Cargo.toml | 3 - crates/nexum-runtime/examples/embed.rs | 2 +- crates/nexum-runtime/src/bindings.rs | 247 +---- crates/nexum-runtime/src/bootstrap.rs | 2 +- crates/nexum-runtime/src/builder.rs | 48 +- crates/nexum-runtime/src/engine_config.rs | 137 ++- .../src/host/component/runtime_types.rs | 2 +- crates/nexum-runtime/src/host/error.rs | 2 +- crates/nexum-runtime/src/host/extension.rs | 98 +- crates/nexum-runtime/src/host/http.rs | 69 +- .../nexum-runtime/src/host/impls/messaging.rs | 20 +- crates/nexum-runtime/src/host/impls/mod.rs | 1 - crates/nexum-runtime/src/host/mod.rs | 10 +- .../nexum-runtime/src/host/provider_pool.rs | 2 +- crates/nexum-runtime/src/host/state.rs | 4 +- .../src/manifest/capabilities.rs | 93 +- crates/nexum-runtime/src/manifest/load.rs | 79 +- crates/nexum-runtime/src/manifest/types.rs | 118 ++- crates/nexum-runtime/src/preset.rs | 8 +- .../nexum-runtime/src/runtime/event_loop.rs | 75 +- .../src/runtime/poison_policy.rs | 2 +- crates/nexum-runtime/src/supervisor.rs | 154 +-- crates/nexum-runtime/src/supervisor/tests.rs | 931 +++--------------- crates/shepherd-cow-host/tests/cow_boot.rs | 48 +- crates/shepherd/Cargo.toml | 1 + crates/shepherd/src/main.rs | 17 +- crates/videre-host/Cargo.toml | 30 + crates/videre-host/src/bindings.rs | 231 +++++ .../src/client.rs} | 11 +- crates/videre-host/src/lib.rs | 145 +++ .../src/registry.rs} | 95 +- crates/videre-host/tests/platform.rs | 714 ++++++++++++++ 34 files changed, 1952 insertions(+), 1467 deletions(-) create mode 100644 crates/videre-host/Cargo.toml create mode 100644 crates/videre-host/src/bindings.rs rename crates/{nexum-runtime/src/host/impls/venue_client.rs => videre-host/src/client.rs} (84%) create mode 100644 crates/videre-host/src/lib.rs rename crates/{nexum-runtime/src/host/venue_registry.rs => videre-host/src/registry.rs} (95%) create mode 100644 crates/videre-host/tests/platform.rs diff --git a/Cargo.lock b/Cargo.lock index bf3873ae..26dbfa62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3622,7 +3622,6 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "nexum-runtime", - "nexum-status-body", "nexum-tasks", "redb", "serde", @@ -5175,6 +5174,7 @@ dependencies = [ "nexum-runtime", "shepherd-cow-host", "tokio", + "videre-host", ] [[package]] @@ -6040,6 +6040,23 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "videre-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "nexum-runtime", + "nexum-status-body", + "nexum-tasks", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasmtime", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0a562877..0d85dead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/shepherd-cow-host", "crates/shepherd-sdk", "crates/shepherd-sdk-test", + "crates/videre-host", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 7b66aa90..63c4be27 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -36,9 +36,6 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } -# Encoder for the opaque status body the host `event` stream carries; -# the router lowers an adapter-reported status through it. -nexum-status-body = { path = "../nexum-status-body" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index 7d92343f..feba0440 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -3,7 +3,7 @@ //! //! [`CoreRuntime`] is the domain-free preset: it bundles the reference core //! backends (chain provider pool, local redb store, empty extension slot) and -//! the Prometheus add-on. A domain capability such as cow-api is added by +//! the Prometheus add-on. A domain capability is added by //! writing a preset that names its extension builder in the `Ext` slot and //! returns its linker extensions, or by dropping to the explicit //! `with_components` builder path. The returned [`RuntimeHandle`] carries the diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 022fcdf1..46fb2424 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -3,19 +3,18 @@ //! The core host binds the `nexum:host/event-module` world: the six core //! primitives. Outbound HTTP is not a `nexum:host` interface: it is //! wasi:http, linked separately; clocks are ambient wasi:clocks. Domain -//! extensions such as cow-api bind their own world and wire themselves in -//! at the composition root; they are not part of this core surface. +//! extensions bind their own world and wire themselves in at the +//! composition root; they are not part of this core surface. //! //! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. //! -//! `nexum:host` is a leaf package: the host `event` variant carries an -//! intent-status transition as opaque bytes, so the core world resolves -//! against `wit/nexum-host` alone. The videre vocabulary generates in the -//! adapter bindgen below, and the client bindgen remaps onto it with -//! `with`, so one Rust type serves the registry and the adapter face -//! alike. `PartialEq` is derived so the registry can compare a polled -//! status against the last delivered one. +//! `nexum:host` is a leaf package: the host `event` variant carries a +//! status transition as opaque bytes, so the core world resolves against +//! `wit/nexum-host` alone. An extension's bindgen remaps onto the shared +//! interfaces here with `with`, so the `Host` impls and the `fault` type +//! its components see are the very ones the core host constructs. +//! `PartialEq` is derived so extension services can compare event payloads. wasmtime::component::bindgen!({ path: ["../../wit/nexum-host"], @@ -24,233 +23,3 @@ wasmtime::component::bindgen!({ exports: { default: async }, additional_derives: [PartialEq], }); - -/// WIT bindings for the second component kind: the -/// `videre:venue/venue-adapter` world. An adapter imports only the scoped -/// transport it needs (chain and messaging; outbound HTTP is wasi:http, -/// linked and allowlisted separately as for event-module) and exports the -/// `videre:venue/adapter` face plus `init`. The shared `nexum:host` -/// interfaces are reused from the `event-module` bindings above via -/// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type -/// an adapter sees are the very ones the core host constructs. The -/// `videre:types` and `videre:value-flow` types generate here (the leaf -/// host world no longer reaches them) and the client bindgen below remaps -/// onto them. -mod venue_adapter { - wasmtime::component::bindgen!({ - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - world: "videre:venue/venue-adapter", - imports: { default: async }, - exports: { default: async }, - with: { - "nexum:host/types": super::nexum::host::types, - "nexum:host/chain": super::nexum::host::chain, - "nexum:host/messaging": super::nexum::host::messaging, - }, - additional_derives: [PartialEq], - }); -} - -pub use venue_adapter::VenueAdapter; - -/// The keeper-facing `videre:venue/client` import bound host-side. The -/// world imports the interface a module calls; the videre types it uses -/// are reused from the adapter bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the registry hands back to a module -/// are the very ones an adapter's `submit` produced - no lift between two -/// structurally identical copies. Async, because the `Host` impl awaits the -/// per-adapter mutex and the adapter's own async guest calls. -mod client_host { - wasmtime::component::bindgen!({ - inline: " - package videre:client-host; - world client-host { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - imports: { default: async }, - with: { - "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, - "videre:types/types": super::venue_adapter::videre::types::types, - }, - }); -} - -/// The host-bound client interface: the `Host` trait the registry implements -/// and the `add_to_linker` the module linker calls. -pub use client_host::videre::venue::client; -/// The registry-observed status transition delivered through the `event` -/// variant, re-exported at the plain spelling the registry names. -pub use nexum::host::types::IntentStatusUpdate; -/// The shared intent ontology, re-exported at the plain spellings the -/// registry and the `client::Host` impl name. -pub use venue_adapter::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, -}; -/// The value-flow vocabulary the header is expressed in. -pub use venue_adapter::videre::value_flow::types as value_flow; - -/// Bindgen smoke for the `videre:value-flow` types package, compiled under -/// test through a throwaway world that imports the interface. Its value is -/// the identifier-hygiene gate: the test names every generated type, -/// variant, and field by its plain Rust spelling, so a WIT id that collided -/// with a Rust keyword would surface as an `r#` escape and fail to compile -/// here rather than in a downstream binding. -#[cfg(test)] -mod value_flow_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:value-flow-smoke; - world smoke { - import videre:value-flow/types@0.1.0; - } - ", - path: ["../../wit/videre-value-flow"], - }); - - #[test] - fn identifiers_bind_unescaped() { - use videre::value_flow::types::{Asset, AssetAmount, Erc20}; - - let erc20 = Erc20 { - token: vec![0u8; 20], - }; - let _ = Asset::Native; - let asset = Asset::Erc20(erc20); - - let amount = AssetAmount { - asset, - amount: Vec::new(), - }; - assert!(amount.amount.is_empty()); - } -} - -/// Bindgen smoke for the `videre:types` and `videre:venue` packages, -/// mirroring the value-flow smoke above: a throwaway world imports the -/// client interface and, transitively, the types interface and its -/// value-flow dependency. The test names every generated type, case, and -/// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the four function signatures, so a keyword collision or an accidental -/// signature change fails this build rather than a downstream binding. -#[cfg(test)] -mod client_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:client-smoke; - world smoke { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - }); - - use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, - }; - use videre::value_flow::types::{Asset, AssetAmount}; - - struct DummyClient; - - impl videre::venue::client::Host for DummyClient { - fn quote(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn submit(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn status( - &mut self, - _venue: String, - _receipt: Vec, - ) -> Result { - Err(VenueError::UnknownVenue) - } - - fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { - Err(VenueError::UnknownVenue) - } - } - - fn amount(bytes: Vec) -> AssetAmount { - AssetAmount { - asset: Asset::Native, - amount: bytes, - } - } - - #[test] - fn identifiers_bind_unescaped() { - use videre::venue::client::Host; - - let _ = AuthScheme::Eip1271; - let _ = AuthScheme::Eip712; - - let header = IntentHeader { - gives: amount(vec![1]), - wants: amount(Vec::new()), - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip712, - }; - assert!(header.wants.amount.is_empty()); - - let _ = IntentStatus::Pending; - let _ = IntentStatus::Open; - let _ = IntentStatus::Fulfilled; - let _ = IntentStatus::Cancelled; - let _ = IntentStatus::Expired; - - let tx = UnsignedTx { - chain: 1, - to: Vec::new(), - value: Vec::new(), - data: Vec::new(), - }; - let _ = SubmitOutcome::Accepted(Vec::new()); - let _ = SubmitOutcome::RequiresSigning(tx); - - let quotation = Quotation { - gives: amount(vec![1]), - wants: amount(Vec::new()), - fee: amount(Vec::new()), - valid_until_ms: 0, - }; - assert!(quotation.fee.amount.is_empty()); - - let _ = VenueError::UnknownVenue; - let _ = VenueError::InvalidBody(String::new()); - let _ = VenueError::Unsupported; - let _ = VenueError::Denied(String::new()); - let _ = VenueError::RateLimited(RateLimit { - retry_after_ms: Some(250), - }); - let _ = VenueError::Unavailable(String::new()); - let _ = VenueError::Timeout; - - let mut client = DummyClient; - assert!(client.quote(String::new(), Vec::new()).is_err()); - assert!(client.submit(String::new(), Vec::new()).is_err()); - assert!(client.status(String::new(), Vec::new()).is_err()); - assert!(client.cancel(String::new(), Vec::new()).is_err()); - } -} diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 4e0c3c18..9ae57a94 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -3,7 +3,7 @@ //! //! Parameterised over the [`RuntimeTypes`] lattice. The composition root //! builds the concrete [`Components`] and the extension list (including any -//! domain extension such as cow-api) and hands them here; this thin wrapper +//! domain extension) and hands them here; this thin wrapper //! forwards to the [`builder`](crate::builder) launcher and blocks until the //! event loop returns. A launcher that wants the //! [`RuntimeHandle`](crate::builder::RuntimeHandle) back drives diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 7c836f07..47145902 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -32,7 +32,7 @@ use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, }; -use crate::host::extension::Extension; +use crate::host::extension::{EventSources, Extension}; use crate::host::logs::LogPipeline; use crate::preset::Runtime; use crate::runtime::event_loop; @@ -268,19 +268,29 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // the components. let logs = components.logs.clone(); let chain_log_subs = supervisor.chain_log_subscriptions(); - // Status polling runs only when it can produce something a module - // will see: at least one intent-status subscriber, a registered - // venue-registry service, and at least one installed adapter to poll. - let status_registry = supervisor - .has_intent_status_subscribers() - .then(|| supervisor.venue_registry()) - .flatten() - .filter(|registry| registry.venue_count() > 0); - let poll_statuses = status_registry.is_some(); + // Extension event sources open only for subscription kinds some + // loaded module declares; each extension gates further on its own + // service state and returns no stream when it has nothing to + // observe. + let subscribed = supervisor.extension_subscription_kinds(); + let mut reconnect_tasks = TaskSet::new(); + let mut extension_streams = Vec::new(); + { + let mut sources = EventSources::new( + engine_cfg, + supervisor.services(), + &subscribed, + &executor, + &mut reconnect_tasks, + ); + for ext in &extensions { + extension_streams.extend(ext.events(&mut sources)?); + } + } // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. - if block_chains.is_empty() && chain_log_subs.is_empty() && !poll_statuses { + if block_chains.is_empty() && chain_log_subs.is_empty() && extension_streams.is_empty() { if supervisor.dead_modules_hold_subscriptions() { anyhow::bail!( "every declared [[subscription]] belongs to an init-failed module - \ @@ -301,7 +311,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // Open per-chain block subscriptions + per-module chain-log // subscriptions through the executor, then drive them in the event // loop until shutdown. - let mut reconnect_tasks = TaskSet::new(); let block_streams = event_loop::open_block_streams( &components.chain, &block_chains, @@ -314,15 +323,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &executor, &mut reconnect_tasks, ); - let intent_status_stream = status_registry.map(|registry| { - event_loop::open_intent_status_stream( - registry, - engine_cfg.limits.status_poll_interval(), - &executor, - &mut reconnect_tasks, - ) - }); - // The event-loop task holds the graceful guard until `run` returns // (after its final dispatch and cursor commit); shutdown ends the // loop between dispatches rather than cancelling it, so the drain @@ -333,7 +333,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, - intent_status_stream, + extension_streams, reconnect_tasks, graceful.into_future(), ) @@ -447,7 +447,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { data_dir: &data_dir, executor: &executor, }; - let mut extensions = self.preset.extensions(); + let mut extensions = self.preset.extensions(self.config); extensions.extend(self.extensions); // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is // consumed by the launch call, so both must stay in scope for that call. @@ -689,7 +689,7 @@ mod tests { Vec::new() } - fn extensions(&self) -> Vec>> { + fn extensions(&self, _config: &EngineConfig) -> Vec>> { vec![Arc::new(CountingExt { namespace: "alpha", prefix: "alpha:ext/", diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 7c75f301..5a2ea0a1 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,15 +26,77 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; -use crate::host::venue_registry::{ - DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, DEFAULT_WATCH_EXPIRY, - DEFAULT_WATCH_MAX_ENTRIES, SubmitQuota, WatchLimit, -}; use crate::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; +/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. +pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; +/// Default sliding window the per-caller submission budget is counted over. +pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Default cap on receipts under status watch at once. +pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; +/// Default lifetime of one status watch before it is evicted unreported. +pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); + +/// Per-caller submission quota toward installed providers. Both a +/// forwarded submission and a charged decode failure consume one unit; +/// the window slides so a caller's budget refills as old charges age out. +/// Resolved from `[limits.quota]`; the extension service that meters +/// callers consumes it. +#[derive(Debug, Clone, Copy)] +pub struct SubmitQuota { + /// Maximum charges a single caller may accrue within `window`. + pub max_charges: u32, + /// Sliding window the charges are counted across. + pub window: Duration, +} + +impl SubmitQuota { + /// Pair a budget with the window it is counted over. + pub const fn new(max_charges: u32, window: Duration) -> Self { + Self { + max_charges, + window, + } + } +} + +impl Default for SubmitQuota { + fn default() -> Self { + Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) + } +} + +/// Bounds on a provider status-watch set. The cap bounds the per-cadence +/// poll fan-out; the expiry evicts a watch whose provider has gone silent +/// for a whole window. Resolved from `[limits.watch]`. +#[derive(Debug, Clone, Copy)] +pub struct WatchLimit { + /// Maximum receipts under status watch at once. + pub max_entries: usize, + /// How long a watch survives without a successful poll before it is + /// evicted unreported. + pub expiry: Duration, +} + +impl WatchLimit { + /// Pair a cap with the per-entry expiry. + pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self { + max_entries, + expiry, + } + } +} + +impl Default for WatchLimit { + fn default() -> Self { + Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) + } +} + /// Errors surfaced by [`load_or_default`]. /// /// Library-side modules must not propagate `anyhow::Error`; the rust @@ -89,11 +151,11 @@ pub struct EngineConfig { /// `docs/03-module-discovery.md`. #[serde(default)] pub modules: Vec, - /// Venue adapters the supervisor should boot alongside the modules. - /// Each entry resolves a `(component.wasm, module.toml)` pair like a - /// module, but the operator scopes its transport here rather than in - /// the adapter's own manifest: the installer of a venue adapter, not - /// the adapter author, decides which hosts and messaging topics it may + /// Provider components the supervisor should boot alongside the + /// modules. Each entry resolves a `(component.wasm, module.toml)` pair + /// like a module, but the operator scopes its transport here rather + /// than in the provider's own manifest: the installer of a provider, + /// not its author, decides which hosts and messaging topics it may /// reach. #[serde(default)] pub adapters: Vec, @@ -287,9 +349,9 @@ const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; /// history for diagnosis without unbounded growth. const DEFAULT_LOG_RUNS_RETAINED: usize = 16; -/// Default cadence for registry-driven intent status polling (5 s). Fast -/// enough that a settling intent is observed within a block time or two, -/// slow enough that per-receipt venue calls stay negligible. +/// Default cadence for provider status polling (5 s). Fast enough that a +/// settling submission is observed within a block time or two, slow +/// enough that per-receipt provider calls stay negligible. const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); /// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: @@ -354,10 +416,10 @@ pub struct ModuleLimits { /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, - /// Per-caller intent submission quota. + /// Per-caller provider submission quota. #[serde(default)] pub quota: QuotaLimitsSection, - /// Router-driven intent status polling cadence. + /// Provider status polling cadence. #[serde(default)] pub status_poll: StatusPollSection, /// Status-watch set bounds. @@ -494,9 +556,9 @@ impl ModuleLimits { } /// Resolved per-caller submission quota (overrides or defaults). A zero - /// `max_charges` is saturated up to 1 by the registry builder, so a - /// misconfigured budget still admits one submission rather than bricking - /// every venue. + /// `max_charges` is saturated up to 1 by the consuming service, so a + /// misconfigured budget still admits one submission rather than + /// bricking every provider. pub fn quota(&self) -> SubmitQuota { SubmitQuota::new( self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), @@ -610,13 +672,13 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } -/// `[limits.quota]` per-caller intent submission budget. Both optional; -/// omitted values resolve to the registry defaults via [`ModuleLimits::quota`]. +/// `[limits.quota]` per-caller provider submission budget. Both optional; +/// omitted values resolve to the defaults via [`ModuleLimits::quota`]. /// /// A caller (a strategy module, keyed by its namespace) may accrue at most /// `max_charges` submissions within a sliding `window_secs`; a decode failure /// charged back to the caller counts the same, so a module feeding garbage -/// bodies exhausts its own budget rather than the adapter's fuel. +/// bodies exhausts its own budget rather than the provider's fuel. #[derive(Debug, Default, Deserialize)] pub struct QuotaLimitsSection { /// Maximum submissions (plus charged decode failures) per caller in the @@ -626,13 +688,13 @@ pub struct QuotaLimitsSection { pub window_secs: Option, } -/// `[limits.status_poll]` intent status polling cadence. Optional; an +/// `[limits.status_poll]` provider status polling cadence. Optional; an /// omitted value resolves to the built-in default and a degenerate zero /// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. /// -/// The cadence is how often the registry polls each installed adapter's -/// `status` export for the receipts it watches; only observed transitions -/// fan out as `intent-status` events. +/// The cadence is how often the consuming service polls each installed +/// provider's `status` export for the receipts it watches; only observed +/// transitions fan out as events. #[derive(Debug, Default, Deserialize)] pub struct StatusPollSection { /// Milliseconds between status poll sweeps. @@ -640,13 +702,13 @@ pub struct StatusPollSection { } /// `[limits.watch]` status-watch set bounds. Both optional; omitted -/// values resolve to the registry defaults via [`ModuleLimits::watch`] -/// and degenerate zeroes saturate up to a usable minimum. +/// values resolve to the defaults via [`ModuleLimits::watch`] and +/// degenerate zeroes saturate up to a usable minimum. /// -/// The registry watches each accepted receipt until a terminal status: -/// the cap bounds the per-cadence poll fan-out, and the expiry evicts a -/// watch whose venue never reports one. At the cap a new watch is -/// refused and logged; live watches are never dropped. +/// The consuming service watches each accepted receipt until a terminal +/// status: the cap bounds the per-cadence poll fan-out, and the expiry +/// evicts a watch whose provider never reports one. At the cap a new +/// watch is refused and logged; live watches are never dropped. #[derive(Debug, Default, Deserialize)] pub struct WatchLimitsSection { /// Maximum receipts under status watch at once. @@ -1078,9 +1140,9 @@ window_secs = 0 let cfg: EngineConfig = toml::from_str( r#" [[adapters]] -path = "adapters/cow/cow_adapter.wasm" -http_allow = ["api.cow.fi", "*.cow.fi"] -messaging_topics = ["/nexum/1/cow-orders/proto"] +path = "providers/acme/acme_provider.wasm" +http_allow = ["api.acme.example", "*.acme.example"] +messaging_topics = ["/nexum/1/acme-orders/proto"] [[adapters]] path = "adapters/bare/bare.wasm" @@ -1090,10 +1152,13 @@ manifest = "adapters/bare/module.toml" .expect("adapters parse"); assert_eq!(cfg.adapters.len(), 2); let first = &cfg.adapters[0]; - assert_eq!(first.path, PathBuf::from("adapters/cow/cow_adapter.wasm")); + assert_eq!( + first.path, + PathBuf::from("providers/acme/acme_provider.wasm") + ); assert!(first.manifest.is_none(), "manifest defaults to sibling"); - assert_eq!(first.http_allow, vec!["api.cow.fi", "*.cow.fi"]); - assert_eq!(first.messaging_topics, vec!["/nexum/1/cow-orders/proto"]); + assert_eq!(first.http_allow, vec!["api.acme.example", "*.acme.example"]); + assert_eq!(first.messaging_topics, vec!["/nexum/1/acme-orders/proto"]); let second = &cfg.adapters[1]; assert_eq!( second.manifest.as_deref(), diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index a96cbeb1..0540e4d4 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -5,7 +5,7 @@ //! Time, randomness, and outbound HTTP are deliberately not members: all //! are WASI concerns serviced per store (WasiCtxBuilder for clocks and //! randomness, wasi:http behind the allowlist gate), not host backends. -//! Domain backends such as cow-api are not core seams: they live behind +//! Domain backends are not core seams: they live behind //! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. use crate::host::component::{ChainProvider, StateStore}; diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 8bdb4f9b..65f0c303 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -49,7 +49,7 @@ pub(crate) fn fault_message(fault: &Fault) -> &str { /// A structured JSON-RPC `ErrorResp` (the node returned a `code`, /// typically `-32000` for an `eth_call` revert) becomes a /// [`ChainError::Rpc`] carrying that code and any decoded revert bytes, -/// so the SDK revert classifier can dispatch the ComposableCoW +/// so an SDK revert classifier can dispatch the revert /// envelopes. Everything else - transport failures, an unknown chain, /// bad params - becomes a shared [`Fault`]. impl From for ChainError { diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 00d82442..dfa05f7f 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,16 +1,22 @@ //! The extension seam: what one extension contributes to the host - a //! namespace, a capability namespace, a linker hook, an optional host -//! service, and an optional provider kind. Assembled at the composition -//! root and threaded into every module linker. +//! service, an optional provider kind, and optional event sources. +//! Assembled at the composition root and threaded into every module +//! linker. use std::any::Any; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::pin::Pin; use std::sync::Arc; use async_trait::async_trait; +use futures::Stream; +use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; use wasmtime::Store; use wasmtime::component::{Component, Linker}; +use crate::bindings::nexum::host::types::Event; +use crate::engine_config::EngineConfig; use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; @@ -43,6 +49,78 @@ pub trait Extension: Send + Sync + 'static { fn provider(&self) -> Option>> { None } + + /// Manifest subscription kinds this extension's event sources emit. + /// A `[[subscription]]` entry of any other non-core kind is refused + /// at boot. + fn subscriptions(&self) -> &'static [&'static str] { + &[] + } + + /// Open the extension's event sources once the engine is booted. The + /// event loop merges the returned streams and dispatches each item to + /// the modules its kind and attributes admit. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + let _ = sources; + Ok(Vec::new()) + } +} + +/// One extension-observed event: dispatched to every module holding a +/// `[[subscription]]` of `kind` whose filters all match `attrs`. +pub struct ExtensionEvent { + /// Manifest subscription kind that routes this event. + pub kind: &'static str, + /// Routing attributes a subscription's filters match against. + pub attrs: Vec<(&'static str, String)>, + /// The host event delivered to each matching module. + pub event: Event, +} + +/// A stream of extension events the event loop merges and drives. +pub type ExtensionEventStream = Pin + Send>>; + +/// Ambient launch inputs for [`Extension::events`]: the loaded config, the +/// booted service map, the subscription kinds at least one module declares, +/// and the spawn surface for source tasks. +pub struct EventSources<'a> { + /// The loaded engine config. + pub config: &'a EngineConfig, + /// Extension-owned services, as booted. + pub services: &'a HostServices, + /// Extension subscription kinds declared by at least one module. + pub subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, +} + +impl<'a> EventSources<'a> { + /// Bundle the launch inputs for one [`Extension::events`] pass. + pub fn new( + config: &'a EngineConfig, + services: &'a HostServices, + subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, + ) -> Self { + Self { + config, + services, + subscribed, + executor, + tasks, + } + } + + /// Spawn one event-source task through the engine's executor. The task + /// must end when its stream's receiver drops; the engine drains it on + /// shutdown. + pub fn spawn(&mut self, task: impl Future + Send + 'static) { + self.tasks.push(self.executor.spawn(async move { + task.await; + TaskExit::ReceiverGone + })); + } } /// A type-erased host service an extension owns. Held per namespace on @@ -212,13 +290,13 @@ mod tests { #[test] fn get_downcasts_by_namespace() { let services = - HostServices::from_extensions(&[ext("videre", Arc::new(Registry(7)))]).expect("build"); + HostServices::from_extensions(&[ext("acme", Arc::new(Registry(7)))]).expect("build"); - let registry = services.get::("videre").expect("registered"); + let registry = services.get::("acme").expect("registered"); assert_eq!(registry.0, 7); - assert!(services.get::("videre").is_none()); + assert!(services.get::("acme").is_none()); assert!(services.get::("absent").is_none()); - assert!(services.raw("videre").is_some()); + assert!(services.raw("acme").is_some()); } /// A serviceless extension contributes nothing to the map. @@ -236,10 +314,10 @@ mod tests { #[test] fn duplicate_namespace_is_refused() { let err = HostServices::from_extensions(&[ - ext("videre", Arc::new(Registry(1))), - ext("videre", Arc::new(Clockwork)), + ext("acme", Arc::new(Registry(1))), + ext("acme", Arc::new(Clockwork)), ]) .expect_err("duplicate namespace"); - assert!(err.to_string().contains("videre"), "{err}"); + assert!(err.to_string().contains("acme"), "{err}"); } } diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index ec74e6c8..2624457e 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -268,26 +268,53 @@ mod tests { #[test] fn exact_host_passes() { - assert!(admit(&uri("https://api.cow.fi/v1/x"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("http://api.cow.fi/"), &allow(&["api.cow.fi"])).is_ok()); + assert!( + admit( + &uri("https://api.acme.example/v1/x"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("http://api.acme.example/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); } #[test] fn off_list_host_is_denied() { - assert!(denied("https://evil.example/", &["api.cow.fi"])); - assert!(denied("https://api.cow.fi.evil.example/", &["api.cow.fi"])); + assert!(denied("https://evil.example/", &["api.acme.example"])); + assert!(denied( + "https://api.acme.example.evil.example/", + &["api.acme.example"] + )); } #[test] fn empty_allowlist_denies_everything() { - assert!(denied("https://api.cow.fi/", &[])); + assert!(denied("https://api.acme.example/", &[])); assert!(denied("http://127.0.0.1/", &[])); } #[test] fn matching_is_case_insensitive() { - assert!(admit(&uri("https://API.COW.FI/"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("https://api.cow.fi/"), &allow(&["API.COW.FI"])).is_ok()); + assert!( + admit( + &uri("https://API.ACME.EXAMPLE/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("https://api.acme.example/"), + &allow(&["API.ACME.EXAMPLE"]) + ) + .is_ok() + ); } #[test] @@ -301,7 +328,10 @@ mod tests { #[test] fn exact_entry_does_not_match_subdomains() { - assert!(denied("https://sub.api.cow.fi/", &["api.cow.fi"])); + assert!(denied( + "https://sub.api.acme.example/", + &["api.acme.example"] + )); } #[test] @@ -321,13 +351,16 @@ mod tests { #[test] fn ports_do_not_affect_matching() { - let list = allow(&["api.cow.fi"]); - assert!(admit(&uri("https://api.cow.fi:8443/v1"), &list).is_ok()); - assert!(admit(&uri("http://api.cow.fi:80/v1"), &list).is_ok()); - assert!(denied("https://evil.example:443/", &["api.cow.fi"])); + let list = allow(&["api.acme.example"]); + assert!(admit(&uri("https://api.acme.example:8443/v1"), &list).is_ok()); + assert!(admit(&uri("http://api.acme.example:80/v1"), &list).is_ok()); + assert!(denied("https://evil.example:443/", &["api.acme.example"])); // A port spelled in the allowlist entry never matches: entries // are hosts, not authorities. - assert!(denied("https://api.cow.fi:8443/", &["api.cow.fi:8443"])); + assert!(denied( + "https://api.acme.example:8443/", + &["api.acme.example:8443"] + )); } // ----------------- SSRF-style bypass regressions (#57) --------- @@ -449,14 +482,14 @@ mod tests { for scheme in ["http", "https"] { assert!( admit( - &uri(&format!("{scheme}://api.cow.fi/")), - &allow(&["api.cow.fi"]) + &uri(&format!("{scheme}://api.acme.example/")), + &allow(&["api.acme.example"]) ) .is_ok() ); assert!(denied( &format!("{scheme}://evil.example/"), - &["api.cow.fi"] + &["api.acme.example"] )); } } @@ -464,7 +497,7 @@ mod tests { #[test] fn uri_without_authority_is_invalid_not_denied() { assert!(matches!( - admit(&uri("/relative/path"), &allow(&["api.cow.fi"])), + admit(&uri("/relative/path"), &allow(&["api.acme.example"])), Err(ErrorCode::HttpRequestUriInvalid) )); } @@ -491,7 +524,7 @@ mod tests { #[tokio::test] async fn send_request_denies_off_list_host_with_http_request_denied() { - let mut gate = HttpGate::new("test-module", allow(&["api.cow.fi"]), limits()); + let mut gate = HttpGate::new("test-module", allow(&["api.acme.example"]), limits()); let Err(err) = gate.send_request(request("http://evil.example/x"), config()) else { panic!("off-list host must be denied"); }; diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index bd450cb3..4cf45da5 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,7 +1,7 @@ //! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so //! `publish` reports `unsupported` and `query` returns empty, the same //! posture as `identity::accounts`. The per-store topic scope is enforced -//! ahead of that stub: a venue adapter carrying a +//! ahead of that stub: a provider carrying a //! `[[adapters]].messaging_topics` grant may only publish within it, so //! the egress boundary is live even though delivery is not. @@ -15,7 +15,7 @@ use crate::host::state::HostState; /// when it equals a scope entry or descends from one read as a path prefix /// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is /// the `/` path separator, so a grant never leaks into a longer sibling -/// segment (`/nexum/1/cow` does not admit `/nexum/1/cow-orders/...`). +/// segment (`/nexum/1/acme` does not admit `/nexum/1/acme-orders/...`). fn topic_in_scope(topic: &str, scope: &[String]) -> bool { if scope.is_empty() { return true; @@ -63,27 +63,27 @@ mod tests { #[test] fn exact_topic_is_admitted() { - let scope = vec!["/nexum/1/cow-orders/proto".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme-orders/proto".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); } #[test] fn prefix_scope_admits_the_family_but_not_a_sibling() { let scope = vec!["/nexum/1/".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); // A sibling namespace stays out. - assert!(!topic_in_scope("/nexum/2/cow-orders/proto", &scope)); + assert!(!topic_in_scope("/nexum/2/acme-orders/proto", &scope)); } #[test] fn prefix_boundary_is_a_path_segment_not_a_substring() { // A scope entry without a trailing slash still bounds on the path // separator, so it cannot leak into a longer sibling segment. - let scope = vec!["/nexum/1/cow".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow", &scope)); - assert!(topic_in_scope("/nexum/1/cow/orders", &scope)); - assert!(!topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme", &scope)); + assert!(topic_in_scope("/nexum/1/acme/orders", &scope)); + assert!(!topic_in_scope("/nexum/1/acme-orders/proto", &scope)); } } diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index 40b65ade..2247ed60 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -13,4 +13,3 @@ mod logging; mod messaging; mod remote_store; mod types; -mod venue_client; diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index ac9e0634..a2842435 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -15,10 +15,11 @@ //! - `impls` (private): the bindgen-side trait impls, one file per core //! WIT interface, that dispatch to the backends above. //! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. -//! - [`extension`]: the extension seam (linker hook + capability -//! namespace) an extension is wired in through at the composition root. -//! Domain extensions such as cow-api live in their own crates and plug -//! in through this seam rather than being hard-linked into the core host. +//! - [`extension`]: the extension seam (linker hook, capability +//! namespace, service, provider kind, event sources) an extension is +//! wired in through at the composition root. Domain extensions live in +//! their own crates and plug in through this seam rather than being +//! hard-linked into the core host. //! - [`actor`]: the supervised host-actor primitive provider instances //! run behind (refuel, trap projection, serialising slot). //! - [`http`]: the wasi:http outgoing gate enforcing the per-module @@ -36,4 +37,3 @@ pub mod local_store_redb; pub mod logs; pub mod provider_pool; pub mod state; -pub mod venue_registry; diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index 23230d4f..55d5e912 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -428,7 +428,7 @@ pub enum ProviderError { /// Decoded `ErrorResp.data` payload - for `eth_call` reverts /// this is the abi-encoded revert body, hex-decoded from the /// upstream JSON string once here (consumed directly by - /// `shepherd_sdk::cow::decode_revert`). `None` when the failure + /// an SDK revert decoder). `None` when the failure /// was transport-level or the payload was not a hex string. data: Option>, /// Transport-side typed error. diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index b1b103cb..2023baf4 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -30,8 +30,8 @@ pub struct HostState { pub http_gate: HttpGate, /// Messaging content topics this store may publish to. Empty means /// unscoped (the module default and current messaging posture); a - /// venue adapter carries its `[[adapters]].messaging_topics` grant - /// here, so an out-of-scope publish is refused before it reaches the + /// provider carries its `[[adapters]].messaging_topics` grant here, + /// so an out-of-scope publish is refused before it reaches the /// backend. pub messaging_topics: Vec, /// Identity of this store's run: module namespace plus the restart diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 8ad2b4d3..dc6edeb4 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -24,7 +24,7 @@ use super::types::{CORE_CAPABILITIES, LoadedManifest}; /// One WIT namespace prefix plus the interface names under it that count as /// capabilities. Core registers `nexum:host/`; an extension registers its -/// own (e.g. `shepherd:cow/`). +/// own. #[derive(Clone, Copy)] pub struct NamespaceCaps { /// Interface-name prefix, e.g. `"nexum:host/"`. @@ -39,20 +39,6 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// Capability names under the `videre:venue/` package a module may import. -/// Only the keeper-facing `client` interface is a capability; the -/// `videre:types` and `videre:value-flow` packages are type-only and need -/// no declaration. -pub const VENUE_CAPABILITIES: &[&str] = &["client"]; - -/// The venue namespace: the `videre:venue/client` import is linked into -/// every module linker, so a module that submits intents declares the -/// `client` capability the same way it declares a `nexum:host/` one. -pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { - prefix: "videre:venue/", - ifaces: VENUE_CAPABILITIES, -}; - /// The interfaces a provider world links: the scoped transport only. A /// provider has no local-store, remote-store, identity, or logging - it /// moves bytes to and from its counterparty and nothing else. `http` is @@ -127,11 +113,10 @@ impl Default for CapabilityRegistry { } impl CapabilityRegistry { - /// The registry with the core `nexum:host/` namespace plus the - /// keeper-facing `videre:venue/client` import every module linker carries. + /// The registry with the core `nexum:host/` namespace. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE, VENUE_NAMESPACE], + namespaces: vec![CORE_NAMESPACE], } } @@ -181,7 +166,7 @@ impl CapabilityRegistry { /// /// Examples: /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` - /// - `"shepherd:cow/cow-api@0.1.0"` -> `Some("cow-api")` once the cow + /// - `"test:acme/acme-api@0.1.0"` -> `Some("acme-api")` once that /// namespace is registered /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) @@ -270,13 +255,13 @@ mod tests { use super::*; use crate::manifest::types::{CapabilitiesSection, Manifest}; - /// A registry with the cow extension namespace registered, mirroring - /// what the composition root assembles. - fn registry_with_cow() -> CapabilityRegistry { + /// A registry with one extension namespace registered, mirroring + /// what a composition root assembles. + fn registry_with_ext() -> CapabilityRegistry { let mut r = CapabilityRegistry::core(); r.register(NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], + prefix: "test:acme/", + ifaces: &["acme-api"], }); r } @@ -315,35 +300,21 @@ mod tests { } #[test] - fn wit_import_to_cap_shepherd_cow_needs_registration() { - // Core registry does not recognise the cow namespace. + fn wit_import_to_cap_extension_needs_registration() { + // Core registry does not recognise an extension namespace. let core = CapabilityRegistry::core(); - assert_eq!(core.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), None); + assert_eq!(core.wit_import_to_cap("test:acme/acme-api@0.1.0"), None); // Once registered, it resolves. - let r = registry_with_cow(); - assert_eq!( - r.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), - Some("cow-api") - ); - } - - #[test] - fn venue_client_is_a_core_capability_but_videre_types_is_not() { - let r = CapabilityRegistry::core(); + let r = registry_with_ext(); assert_eq!( - r.wit_import_to_cap("videre:venue/client@0.1.0"), - Some("client") + r.wit_import_to_cap("test:acme/acme-api@0.1.0"), + Some("acme-api") ); - assert!(r.is_known("client")); - // The type-only interfaces are not capabilities and need no - // declaration. - assert_eq!(r.wit_import_to_cap("videre:types/types@0.1.0"), None); - assert_eq!(r.wit_import_to_cap("videre:value-flow/types@0.1.0"), None); } #[test] fn wit_import_to_cap_non_http_wasi_is_none() { - let r = registry_with_cow(); + let r = registry_with_ext(); assert_eq!(r.wit_import_to_cap("wasi:io/streams@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:sockets/tcp@0.2.0"), None); @@ -377,20 +348,20 @@ mod tests { // 0.1-fallback: no capabilities section -> all imports allowed let loaded = manifest_no_caps(); let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn enforce_passes_when_all_imports_declared() { - let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); + let loaded = manifest_with_caps(&["chain", "acme-api"], &["http"]); let imports = [ "nexum:host/chain@0.1.0", - "shepherd:cow/cow-api@0.1.0", + "test:acme/acme-api@0.1.0", "wasi:http/outgoing-handler@0.2.12", "wasi:io/streams@0.2.0", // non-http wasi is always skipped ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -401,7 +372,7 @@ mod tests { "nexum:host/chain@0.1.0", "wasi:http/outgoing-handler@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -419,7 +390,7 @@ mod tests { "wasi:http/outgoing-handler@0.2.12", "wasi:http/types@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } } @@ -429,7 +400,7 @@ mod tests { let loaded = manifest_with_caps(&["chain"], &[]); // module imports remote-store but didn't declare it let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -441,7 +412,7 @@ mod tests { fn enforce_optional_caps_are_also_allowed() { let loaded = manifest_with_caps(&["chain"], &["remote-store"]); let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -501,14 +472,14 @@ mod tests { "wasi:cli/terminal-stdout@0.2.6", "wasi:cli/environment@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn undeclared_gated_wasi_is_refused() { let loaded = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); for (import, cap) in [ ("wasi:sockets/tcp@0.2.6", "wasi-sockets"), ("wasi:filesystem/types@0.2.6", "wasi-filesystem"), @@ -531,14 +502,14 @@ mod tests { "wasi:filesystem/types@0.2.6", "wasi:filesystem/preopens@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn declaring_one_gated_cap_does_not_grant_another() { let loaded = manifest_with_caps(&["wasi-filesystem"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["wasi:filesystem/types@0.2.6"].into_iter(), &r).is_ok() ); @@ -550,7 +521,7 @@ mod tests { // Even with an unrelated gated cap declared, an unrecognised wasi: // namespace is denied outright. let loaded = manifest_with_caps(&["wasi-sockets"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, ["wasi:nn/tensor@0.2.0"].into_iter(), &r).unwrap_err(); assert!(matches!(err, CapabilityError::UnknownWasi { .. })); @@ -560,7 +531,7 @@ mod tests { fn wasi_gate_ignores_version_suffix() { let declared = manifest_with_caps(&["wasi-sockets"], &[]); let none = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&declared, ["wasi:sockets/tcp"].into_iter(), &r).is_ok()); assert!( enforce_capabilities(&declared, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_ok() @@ -573,7 +544,7 @@ mod tests { // No [capabilities] section -> 0.1-fallback: registry imports pass, // but the WASI surface is still gated fail-closed. let loaded = manifest_no_caps(); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["nexum:host/remote-store@0.1.0"].into_iter(), &r) .is_ok() @@ -588,7 +559,7 @@ mod tests { #[test] fn wasi_capability_names_are_known() { - let r = registry_with_cow(); + let r = registry_with_ext(); for cap in ["wasi-sockets", "wasi-filesystem"] { assert!(r.is_known(cap), "{cap} missing from known set"); assert!(r.known_names().split(", ").any(|n| n == cap)); diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 6ad22674..132dd838 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -172,54 +172,66 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } #[test] - fn load_rejects_the_retired_log_kind() { + fn load_parses_the_retired_log_kind_as_an_extension_kind() { // The chain-event kind is `chain-log`; a stale `kind = "log"` - // fails to parse with an unknown-variant error naming the valid - // set so a not-yet-migrated manifest surfaces clearly at load. + // parses as an extension kind and boot refuses it against the + // extension vocabulary, so a not-yet-migrated manifest still + // surfaces clearly rather than silently dropping events. let toml = r#" [module] name = "stale" [[subscription]] kind = "log" -chain_id = 1 +chain_id = "1" "#; - let err = toml::from_str::(toml).expect_err("stale kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("chain-log"), - "error names the valid set: {msg}" - ); - assert!( - !msg.contains("unknown field"), - "kind is the discriminator: {msg}" - ); + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Extension { kind, .. } if kind == "log" + )); } #[test] - fn load_parses_intent_status_subscription() { + fn load_parses_extension_subscriptions_with_string_filters() { let toml = r#" [module] name = "watcher" [[subscription]] -kind = "intent-status" +kind = "acme-status" [[subscription]] -kind = "intent-status" -venue = "cow" +kind = "acme-status" +scope = "primary" "#; let manifest: Manifest = toml::from_str(toml).expect("parse"); assert!(matches!( &manifest.subscriptions[0], - Subscription::IntentStatus { venue: None } + Subscription::Extension { kind, filters } if kind == "acme-status" && filters.is_empty() )); assert!(matches!( &manifest.subscriptions[1], - Subscription::IntentStatus { venue: Some(v) } if v == "cow" + Subscription::Extension { kind, filters } + if kind == "acme-status" && filters.get("scope").is_some_and(|v| v == "primary") )); } + /// A non-string filter value on an extension kind is refused at parse. + #[test] + fn load_rejects_a_non_string_extension_filter() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "acme-status" +scope = 7 +"#; + let err = toml::from_str::(toml).expect_err("non-string filter"); + assert!(err.to_string().contains("must be a string"), "{err}"); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" @@ -313,14 +325,14 @@ name = "plain" let manifest: Manifest = toml::from_str( r#" [module] -name = "cow" -kind = "venue-adapter" +name = "acme" +kind = "acme-provider" "#, ) .expect("parse"); assert_eq!( manifest.module.kind, - ComponentKind::Provider("venue-adapter".to_owned()), + ComponentKind::Provider("acme-provider".to_owned()), ); } @@ -395,9 +407,9 @@ max_state_bytes = 52428800 #[test] fn host_allowed_exact_and_wildcard() { - let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; - assert!(host_allowed("api.cow.fi", &allow)); - assert!(!host_allowed("evil.api.cow.fi", &allow)); + let allow = vec!["api.acme.example".to_string(), "*.discord.com".to_string()]; + assert!(host_allowed("api.acme.example", &allow)); + assert!(!host_allowed("evil.api.acme.example", &allow)); assert!(host_allowed("foo.discord.com", &allow)); assert!(host_allowed("a.b.discord.com", &allow)); assert!(!host_allowed("discord.com", &allow)); @@ -406,17 +418,20 @@ max_state_bytes = 52428800 #[test] fn host_allowed_is_case_insensitive_both_ways() { - let upper = vec!["API.COW.FI".to_string()]; - let lower = vec!["api.cow.fi".to_string()]; - assert!(host_allowed("api.cow.fi", &upper)); - assert!(host_allowed("Api.Cow.Fi", &lower)); + let upper = vec!["API.ACME.EXAMPLE".to_string()]; + let lower = vec!["api.acme.example".to_string()]; + assert!(host_allowed("api.acme.example", &upper)); + assert!(host_allowed("Api.Acme.Example", &lower)); } #[test] fn host_allowed_matches_hosts_not_authorities() { // Entries are bare hosts; a port or userinfo in a pattern can // never match a host string. - let allow = vec!["api.cow.fi:8443".to_string(), "u@api.cow.fi".to_string()]; - assert!(!host_allowed("api.cow.fi", &allow)); + let allow = vec![ + "api.acme.example:8443".to_string(), + "u@api.acme.example".to_string(), + ]; + assert!(!host_allowed("api.acme.example", &allow)); } } diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index dbaf774a..c13ca680 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,17 +4,18 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::collections::BTreeMap; use std::fmt; use serde::Deserialize; +use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` /// world links into every module linker. The `http` capability is not a /// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled -/// separately by the registry. Domain-extension capabilities (e.g. -/// cow-api) are not listed here; each extension contributes its own -/// namespace to the [`super::capabilities::CapabilityRegistry`] at the -/// composition root. +/// separately by the registry. Domain-extension capabilities are not +/// listed here; each extension contributes its own namespace to the +/// [`super::capabilities::CapabilityRegistry`] at the composition root. pub const CORE_CAPABILITIES: &[&str] = &[ "chain", "identity", @@ -43,10 +44,11 @@ pub struct Manifest { /// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. Unknown kinds are surfaced -/// at load time so a typo does not silently disable an event source. -#[derive(Debug, Deserialize, Clone)] -#[serde(tag = "kind", rename_all = "lowercase")] +/// validated per-kind by the supervisor. A kind outside the core set +/// parses as [`Subscription::Extension`] and is validated at boot +/// against the kinds the wired extensions declare, so a typo still +/// fails loudly rather than silently disabling an event source. +#[derive(Debug, Clone)] pub enum Subscription { /// New-block events. Fan-out is shared per chain - the /// supervisor opens one subscription per chain id and routes to @@ -59,17 +61,14 @@ pub enum Subscription { /// per-module - the supervisor opens one subscription per /// `[[subscription]]` entry and tags emitted events with the /// owning module. - #[serde(rename = "chain-log")] ChainLog { /// EVM chain id. chain_id: u64, /// Contract address as `0x`-prefixed 20-byte hex. Optional. - #[serde(default)] address: Option, /// Topic-0 of the event the module wants to consume. `0x`- /// prefixed 32-byte hex. Optional - when absent the /// subscription matches every event from the address(es). - #[serde(default)] event_signature: Option, /// Resume across engine restarts. When `true` the host persists a /// durable per-subscription cursor and re-opens the log poller @@ -77,13 +76,11 @@ pub enum Subscription { /// current head. Delivery is then at-least-once, so the module must /// tolerate redelivery (the keeper idempotency journal already /// dedups it). - #[serde(default)] resume: bool, /// Optional cap on how far back a `resume` subscription will /// backfill, in blocks. `None` (the default) backfills the entire /// gap with no loss; set it only for a consumer that explicitly /// tolerates dropping the oldest missed blocks. - #[serde(default)] max_lookback: Option, }, /// Cron-scheduled tick. 0.2 parses but does not dispatch; the @@ -95,19 +92,96 @@ pub enum Subscription { #[allow(dead_code)] schedule: String, }, - /// Router-polled intent status transitions, delivered as - /// `intent-status` events. Fan-out is shared: the router polls each - /// installed adapter once per cadence and every subscribed module - /// receives the transition, filtered by `venue` when set. - #[serde(rename = "intent-status")] - IntentStatus { - /// Restrict delivery to transitions from this venue id. - /// Absent means transitions from every venue. + /// An extension-owned event kind. Every non-`kind` key is a string + /// filter matched against the event's routing attributes: an event + /// is delivered when its kind matches and every filter pair is + /// present in the event's attributes. + Extension { + /// The extension-declared subscription kind. + kind: String, + /// Attribute filters; empty admits every event of the kind. + filters: BTreeMap, + }, +} + +/// The core subscription kinds, parsed by shape. Any other kind falls +/// through to [`Subscription::Extension`]. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum CoreSubscription { + Block { + chain_id: u64, + }, + #[serde(rename = "chain-log")] + ChainLog { + chain_id: u64, + #[serde(default)] + address: Option, + #[serde(default)] + event_signature: Option, + #[serde(default)] + resume: bool, #[serde(default)] - venue: Option, + max_lookback: Option, + }, + Cron { + schedule: String, }, } +impl From for Subscription { + fn from(sub: CoreSubscription) -> Self { + match sub { + CoreSubscription::Block { chain_id } => Self::Block { chain_id }, + CoreSubscription::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + } => Self::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + }, + CoreSubscription::Cron { schedule } => Self::Cron { schedule }, + } + } +} + +impl<'de> Deserialize<'de> for Subscription { + fn deserialize>(deserializer: D) -> Result { + let table = toml::Table::deserialize(deserializer)?; + let Some(kind) = table.get("kind").and_then(toml::Value::as_str) else { + return Err(D::Error::missing_field("kind")); + }; + match kind { + "block" | "chain-log" | "cron" => toml::Value::Table(table.clone()) + .try_into::() + .map(Into::into) + .map_err(D::Error::custom), + _ => { + let kind = kind.to_owned(); + let mut filters = BTreeMap::new(); + for (key, value) in table { + if key == "kind" { + continue; + } + let Some(value) = value.as_str() else { + return Err(D::Error::custom(format!( + "subscription filter `{key}` must be a string" + ))); + }; + filters.insert(key, value.to_owned()); + } + Ok(Self::Extension { kind, filters }) + } + } + } +} + #[derive(Debug, Deserialize, Default)] #[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. pub struct ModuleSection { diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 17044af2..9ec4dd64 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use crate::addons::{AddOns, PrometheusAddOn}; +use crate::engine_config::EngineConfig; use crate::host::component::{ ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, @@ -55,10 +56,13 @@ pub trait Runtime { /// The cross-cutting add-ons installed before the engine boots. fn add_ons(&self) -> AddOns; - /// The linker extensions the preset launches with. None by default; + /// The extensions the preset launches with, derived from the loaded + /// config so an extension can carry config-resolved policy. None by + /// default; /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) /// appends on top. - fn extensions(&self) -> Vec>> { + fn extensions(&self, config: &EngineConfig) -> Vec>> { + let _ = config; Vec::new() } } diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index a63526df..3f1aa287 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,8 +40,8 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; +use crate::host::extension::{ExtensionEvent, ExtensionEventStream}; use crate::host::provider_pool::ProviderError; -use crate::host::venue_registry::VenueRegistry; use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; @@ -147,40 +147,6 @@ where streams } -/// Registry-driven intent status polling: one task that, on every cadence -/// tick, polls each installed adapter's status export through the shared -/// [`VenueRegistry`] and forwards the observed transitions. The task is -/// spawned via `executor` into `tasks` like the reconnect tasks and exits -/// cleanly when the loop's receiver drops. -pub fn open_intent_status_stream( - registry: VenueRegistry, - cadence: Duration, - executor: &TaskExecutor, - tasks: &mut TaskSet, -) -> IntentStatusStream { - let (tx, rx) = mpsc::channel::(RECONNECT_CHANNEL_BUF); - tasks.push(executor.spawn(Box::pin(status_poll_task(registry, cadence, tx)))); - Box::pin(receiver_stream(rx)) -} - -/// Poll loop behind [`open_intent_status_stream`]. Sleeps the cadence -/// first so the engine's boot dispatch settles before the first poll. -async fn status_poll_task( - registry: VenueRegistry, - cadence: Duration, - tx: mpsc::Sender, -) -> TaskExit { - loop { - tokio::time::sleep(cadence).await; - for update in registry.poll_status_transitions().await { - if tx.send(update).await.is_err() { - // Receiver dropped -> engine shutting down. - return TaskExit::ReceiverGone; - } - } - } -} - /// Wrap an `mpsc::Receiver` as a `Stream` using /// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just /// for `ReceiverStream`. @@ -492,12 +458,6 @@ pub type TaggedChainLog = Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; pub type TaggedChainLogStream = std::pin::Pin + Send>>; -/// Router-observed intent status transitions, fanned to subscribers by the -/// event loop. Infallible items: poll failures are retried inside the poll -/// task on the next cadence rather than surfaced here. -pub type IntentStatusStream = - std::pin::Pin + Send>>; - /// Drive the supervisor with events until `shutdown` resolves. /// /// Graceful shutdown: the dispatch path is structured so @@ -516,7 +476,7 @@ pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, chain_log_streams: Vec, - intent_status_stream: Option, + extension_streams: Vec, tasks: TaskSet, shutdown: impl std::future::Future + Send, ) -> (u64, u64) { @@ -538,14 +498,15 @@ pub async fn run( } else { select_all(chain_log_streams).boxed() }; - let mut intent_statuses: BoxStream<'_, _> = match intent_status_stream { - Some(stream) => stream, - None => futures::stream::pending().boxed(), + let mut extension_events: BoxStream<'_, _> = if extension_streams.is_empty() { + futures::stream::pending().boxed() + } else { + select_all(extension_streams).boxed() }; let mut shutdown = Box::pin(shutdown); let mut dispatched_blocks: u64 = 0; let mut dispatched_chain_logs: u64 = 0; - let mut dispatched_intent_statuses: u64 = 0; + let mut dispatched_extension_events: u64 = 0; let started = Instant::now(); loop { // Phase 1: pick the next event OR observe shutdown. The @@ -562,7 +523,7 @@ pub async fn run( Box, Option>, ), - IntentStatus(nexum::host::types::IntentStatusUpdate), + Extension(ExtensionEvent), // Carries the drain guard `shutdown` yielded. Shutdown(G), StreamPanic(&'static str), @@ -593,10 +554,10 @@ pub async fn run( } None => NextEvent::StreamPanic("chain-log"), }, - next = intent_statuses.next() => match next { - Some(update) => NextEvent::IntentStatus(update), - // The poll task loops forever; `None` means it exited. - None => NextEvent::StreamPanic("intent-status"), + next = extension_events.next() => match next { + Some(event) => NextEvent::Extension(event), + // Extension source tasks loop forever; `None` means one exited. + None => NextEvent::StreamPanic("extension-event"), }, }; @@ -611,9 +572,9 @@ pub async fn run( .await; dispatched_chain_logs += 1; } - NextEvent::IntentStatus(update) => { - supervisor.dispatch_intent_status(update).await; - dispatched_intent_statuses += 1; + NextEvent::Extension(event) => { + supervisor.dispatch_extension_event(event).await; + dispatched_extension_events += 1; } NextEvent::Shutdown(guard) => { // Drop the stream-end receivers so the reconnect @@ -622,12 +583,12 @@ pub async fn run( // finish before returning. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; info!( dispatched_blocks, dispatched_chain_logs, - dispatched_intent_statuses, + dispatched_extension_events, uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); @@ -640,7 +601,7 @@ pub async fn run( // exited (panic or channel closed). Bail loudly. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; warn!( kind, diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs index 70998a6e..bfc1a427 100644 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ b/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -31,7 +31,7 @@ use std::time::Duration; /// Aggressive enough to catch a deterministically broken module /// without waiting out the full exponential backoff (the 5th trap /// happens at ~31 s into the schedule: 1+2+4+8+16 s); lenient -/// enough that a one-off RPC blip during a real cow-api submit does +/// enough that a one-off RPC blip during a real extension submit does /// not get a module quarantined. pub const POISON_MAX_FAILURES: u32 = 5; pub const POISON_WINDOW: Duration = Duration::from_secs(600); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 029fa1e3..102dd196 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -18,11 +18,10 @@ //! `next_attempt = None` and never get scheduled - the init failure //! is treated as a manifest / config bug, not a transient. //! -//! Providers (venue adapters) ride the same sweeps: a trap inside a -//! routed call flips the [`Liveness`] their actor shares with the -//! supervisor, the venue resolves to `unavailable` (not -//! `unknown-venue`) while dead, and the sweep reinstalls the provider -//! after the same backoff and poison policies. +//! Providers ride the same sweeps: a trap inside a routed call flips +//! the [`Liveness`] their actor shares with the supervisor, the owning +//! service reports the instance unavailable while dead, and the sweep +//! reinstalls the provider after the same backoff and poison policies. //! //! Multi-chain isolation: `dispatch_block(block)` walks //! every module but only enters those whose subscriptions match @@ -32,7 +31,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -51,6 +50,7 @@ use crate::engine_config::{ }; use crate::host::actor::Liveness; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; +use crate::host::extension::ExtensionEvent; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, }; @@ -61,7 +61,6 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::host::venue_registry::{VenueAdapterKind, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; @@ -71,8 +70,8 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// Providers (venue adapters) loaded at boot, whether or not `init` - /// succeeded. Swept for restart and poison alongside the modules. + /// Providers loaded at boot, whether or not `init` succeeded. Swept + /// for restart and poison alongside the modules. providers: Vec, /// Registered provider kinds paired with their services, kept for the /// provider restart sweep to reinstall through. @@ -261,11 +260,12 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// One loaded provider (venue adapter). Mirrors [`LoadedModule`]'s restart -/// and poison bookkeeping; liveness is shared with the installed actor, -/// which marks it dead on a trap, and read back by the sweep. +/// One loaded provider. Mirrors [`LoadedModule`]'s restart and poison +/// bookkeeping; liveness is shared with the installed actor, which marks +/// it dead on a trap, and read back by the sweep. struct LoadedProvider { - /// The provider's namespace: its manifest name, and its venue id. + /// The provider's namespace: its manifest name, and the id its kind + /// installs it under. name: String, /// Registered kind spelling the restart sweep reinstalls through. kind: &'static str, @@ -326,6 +326,17 @@ fn provider_kinds( Ok(kinds) } +/// The union of subscription kinds the wired extensions declare; a +/// manifest subscription of any other non-core kind fails the load. +fn extension_subscription_vocabulary( + extensions: &[Arc>], +) -> BTreeSet<&'static str> { + extensions + .iter() + .flat_map(|ext| ext.subscriptions().iter().copied()) + .collect() +} + /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -357,22 +368,12 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - // The venue registry rides the generic service map under the videre - // namespace, seeded here while it lives in-core; the videre - // extension takes it over. Same for the venue-adapter kind row. - let venue_service: Arc = Arc::new( - VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()) - .build(), - ); - let services = HostServices::from_extensions(extensions)? - .with_service(VenueRegistry::NAMESPACE, Arc::clone(&venue_service))?; + let services = HostServices::from_extensions(extensions)?; // Provider kinds the boot loop resolves manifest kinds against. - let mut kinds = provider_kinds(extensions, &services)?; - register_kind(&mut kinds, Box::new(VenueAdapterKind), venue_service)?; - // Providers boot first into the shared registry handle, so every - // module store built below already routes to the installed venues. - // Providers link only their kind's scoped imports. + let kinds = provider_kinds(extensions, &services)?; + // Providers boot first into their extension-owned services, so + // every module store built below already routes to the installed + // instances. Providers link only their kind's scoped imports. let provider_registry = CapabilityRegistry::provider(); let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); for entry in &engine_cfg.adapters { @@ -390,6 +391,7 @@ impl Supervisor { providers.push(loaded); } + let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { let loaded = Self::load_one( @@ -401,6 +403,7 @@ impl Supervisor { ®istry, clocks.as_ref(), services.clone(), + &extension_kinds, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -451,9 +454,9 @@ impl Supervisor { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), }; - // The single-module override path serves `just run`; adapters are - // configured through `engine.toml`, so no registry service is - // published and every client call resolves to `unknown-venue`. + // The single-module override path serves `just run`; providers + // are configured through `engine.toml`, so none boot here. + let extension_kinds = extension_subscription_vocabulary(extensions); let loaded = Self::load_one( engine, linker, @@ -463,6 +466,7 @@ impl Supervisor { ®istry, clocks.as_ref(), services.clone(), + &extension_kinds, ) .await?; Ok(Self { @@ -574,6 +578,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, services: HostServices, + extension_kinds: &BTreeSet<&'static str>, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -630,8 +635,8 @@ impl Supervisor { run.clone(), loaded_manifest.http_allowlist.clone(), limits_cfg.http(), - // Event modules are unscoped for messaging; only venue - // adapters carry a topic grant. + // Event modules are unscoped for messaging; only providers + // carry a topic grant. Vec::new(), memory, fuel, @@ -692,13 +697,23 @@ impl Supervisor { // Surface any `[[subscription]]` entries the host cannot // service yet, so an operator running 0.2 against a 0.3 - // manifest does not silently drop events. + // manifest does not silently drop events, and refuse an + // extension kind no wired extension declares. for sub in &loaded_manifest.manifest.subscriptions { - if matches!(sub, Subscription::Cron { .. }) { - warn!( + match sub { + Subscription::Cron { .. } => warn!( module = %module_namespace, "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", - ); + ), + Subscription::Extension { kind, .. } + if !extension_kinds.contains(kind.as_str()) => + { + return Err(anyhow!( + "module {module_namespace} subscribes to unknown event kind {kind}; \ + no wired extension declares it" + )); + } + _ => {} } } @@ -892,7 +907,7 @@ impl Supervisor { self.modules.len() } - /// Number of venue adapters loaded at boot, alive or not. + /// Number of providers loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { self.providers.len() } @@ -1230,15 +1245,12 @@ impl Supervisor { ok } - /// Dispatch a registry-observed intent status transition to every module - /// subscribed to `intent-status` events whose venue filter admits the - /// update's venue. Returns the number of modules invoked. Mirrors + /// Dispatch one extension-observed event to every module holding a + /// subscription of its kind whose filters all match the event's + /// attributes. Returns the number of modules invoked. Mirrors /// `dispatch_block`: dead modules past their backoff are restarted /// first, poisoned modules are skipped. - pub async fn dispatch_intent_status( - &mut self, - update: nexum::host::types::IntentStatusUpdate, - ) -> usize { + pub async fn dispatch_extension_event(&mut self, event: ExtensionEvent) -> usize { let now = std::time::Instant::now(); let restart_candidates: Vec = (0..self.modules.len()) .filter(|&i| { @@ -1260,19 +1272,20 @@ impl Supervisor { m.subscriptions.iter().any(|s| { matches!( s, - Subscription::IntentStatus { venue } - if venue.as_deref().is_none_or(|v| v == update.venue) + Subscription::Extension { kind, filters } + if kind == event.kind && filters.iter().all(|(fk, fv)| { + event.attrs.iter().any(|(ak, av)| ak == fk && av == fv) + }) ) }) }) .collect(); - let event = nexum::host::types::Event::IntentStatus(update); let mut dispatched = 0; for idx in candidate_indices { - // Status transitions are venue-scoped, not chain-scoped: the - // telemetry chain id and block number carry the 0 sentinel. + // Extension events are not chain-scoped: the telemetry chain + // id and block number carry the 0 sentinel. if matches!( - self.dispatch_to(idx, 0, "intent-status", 0, &event).await, + self.dispatch_to(idx, 0, event.kind, 0, &event.event).await, DispatchOutcome::Ok, ) { dispatched += 1; @@ -1281,23 +1294,25 @@ impl Supervisor { dispatched } - /// Whether any loaded module subscribes to `intent-status` events. - /// The launcher polls adapter statuses only when this holds: with no - /// subscriber every transition would be dropped on arrival. - pub fn has_intent_status_subscribers(&self) -> bool { - self.modules.iter().any(|m| { - m.subscriptions - .iter() - .any(|s| matches!(s, Subscription::IntentStatus { .. })) - }) + /// The extension subscription kinds at least one loaded module + /// declares. An extension opens an event source only when its kind + /// appears here: with no subscriber every event would be dropped on + /// arrival. + pub fn extension_subscription_kinds(&self) -> BTreeSet { + self.modules + .iter() + .flat_map(|m| m.subscriptions.iter()) + .filter_map(|s| match s { + Subscription::Extension { kind, .. } => Some(kind.clone()), + _ => None, + }) + .collect() } - /// The venue registry published under the videre service namespace, - /// when one is. Shared by every module store through the service map. - pub fn venue_registry(&self) -> Option { - self.services - .get::(VenueRegistry::NAMESPACE) - .map(|registry| (*registry).clone()) + /// The extension-owned services, as booted. Shared by every module + /// store through the service map. + pub fn services(&self) -> &HostServices { + &self.services } /// Shared per-module dispatch path: refuel, call `on_event`, and @@ -1658,13 +1673,6 @@ pub fn build_linker( ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; - // The venue client import is linked into every module linker; it - // dispatches to the shared registry carried in each store's `HostState`. - // Modules that do not import it are unaffected. - crate::bindings::client::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; // wasi:http only; the p2 call above already covers the shared // wasi:io/wasi:clocks interfaces. diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 0bf06086..6396ffe0 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -72,7 +72,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { &mut supervisor, Vec::new(), Vec::new(), - None, + Vec::new(), nexum_tasks::TaskSet::new(), shutdown, ) @@ -145,7 +145,7 @@ async fn run_delivers_block_and_chain_log_events_without_starvation() { &mut supervisor, block_streams, chain_log_streams, - None, + Vec::new(), tasks, shutdown, ), @@ -202,7 +202,7 @@ async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { &mut supervisor, block_streams, vec![], - None, + Vec::new(), tasks, shutdown, ), @@ -234,77 +234,6 @@ fn example_module_toml() -> PathBuf { .join("modules/example/module.toml") } -fn echo_venue_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-venue/module.toml") -} - -fn echo_client_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-client/module.toml") -} - -/// Path to the pre-built reference venue adapter. Built by -/// `just build-venue`; the import-pinning test skips when absent. -fn echo_venue_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_venue.wasm") -} - -/// Returns `None` and prints a skip message if the venue fixture isn't -/// built. -fn echo_venue_wasm_or_skip() -> Option { - let p = echo_venue_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-venue` to enable the venue import test", - p.display() - ); - None - } -} - -/// Path to the pre-built echo-client module, the strategy half of the echo -/// pair. Built by `just build-echo-client`; the round-trip test skips when -/// absent. -fn echo_client_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_client.wasm") -} - -/// Returns `None` and prints a skip message if the echo-client fixture -/// isn't built. -fn echo_client_wasm_or_skip() -> Option { - let p = echo_client_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-echo-client` to enable the echo round-trip test", - p.display() - ); - None - } -} - /// Returns `None` and prints a skip message if the fixture isn't built. fn example_wasm_or_skip() -> Option { let p = example_wasm(); @@ -433,54 +362,12 @@ fn e2e_example_component_imports_equal_declared_capabilities() { "imports were: {imports:?}" ); - // No extension interface leaks in either: the blanket cow world is - // gone from modules that never declared it. + // No extension interface leaks in either: the per-module world holds + // exactly what the manifest declared. assert!( imports .iter() - .all(|name| !name.starts_with("shepherd:cow/")), - "imports were: {imports:?}" - ); -} - -/// The per-component venue-adapter world contract: an adapter built -/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped -/// transport its manifest declares (`chain`), by construction of the -/// emitted world. The venue side never depended on toolchain elision; -/// this pins that it does not regress to it. -#[test] -fn e2e_echo_venue_component_imports_equal_declared_capabilities() { - let Some(wasm) = echo_venue_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); - let imports: Vec = component - .component_type() - .imports(&engine) - .map(|(name, _)| name.to_owned()) - .collect(); - - // Capability-bearing imports resolve to exactly the declared set. - let registry = CapabilityRegistry::core(); - let caps: std::collections::BTreeSet<&str> = imports - .iter() - .filter_map(|name| registry.wit_import_to_cap(name)) - .collect(); - assert_eq!( - caps, - std::collections::BTreeSet::from(["chain"]), - "imports were: {imports:?}" - ); - - // No host key-material or persistence interface leaks in: an adapter - // structurally cannot reach messaging it never declared, local-store, - // identity, or logging. - assert!( - imports.iter().all(|name| !name.contains("messaging") - && !name.contains("local-store") - && !name.contains("identity") - && !name.contains("logging")), + .all(|name| name.starts_with("nexum:host/") || name.starts_with("wasi:")), "imports were: {imports:?}" ); } @@ -540,415 +427,6 @@ chain_id = 1 assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); } -// ── intent-status subscription E2E ──────────────────────────────────── - -/// A scripted venue adapter for the registry: accepts every submission with -/// a fixed receipt and serves statuses front-first from a script, falling -/// back to `open` once drained. -struct ScriptedAdapter { - statuses: std::collections::VecDeque, -} - -impl ScriptedAdapter { - fn new(statuses: impl IntoIterator) -> Self { - Self { - statuses: statuses.into_iter().collect(), - } - } -} - -impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { - fn derive_header<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::IntentHeader { - gives: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: vec![1], - }, - wants: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - settlement: crate::bindings::Settlement { chain: 1 }, - authorisation: crate::bindings::AuthScheme::Eip712, - }) - }) - } - - fn quote<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::Quotation { - gives: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: vec![1], - }, - wants: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - fee: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - valid_until_ms: 0, - }) - }) - } - - fn submit<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::SubmitOutcome::Accepted( - b"receipt".to_vec(), - )) - }) - } - - fn status( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture< - '_, - Result, - > { - Box::pin(async move { - Ok(self - .statuses - .pop_front() - .unwrap_or(crate::bindings::IntentStatus::Open)) - }) - } - - fn cancel( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture<'_, Result<(), crate::bindings::VenueError>> { - Box::pin(async move { Ok(()) }) - } -} - -/// Build a registry with one scripted adapter installed under `cow`. -fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { - let registry = crate::host::venue_registry::VenueRegistryBuilder::new( - crate::host::venue_registry::SubmitQuota::default(), - ) - .build(); - registry - .install( - crate::host::venue_registry::VenueId::from("cow"), - crate::host::actor::Liveness::default(), - adapter, - ) - .expect("install"); - registry -} - -/// Write a manifest subscribing the example module to intent-status -/// events from the `cow` venue. -fn intent_status_manifest(dir: &Path) -> PathBuf { - let manifest = dir.join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "intent-status" -venue = "cow" -"#, - ) - .unwrap(); - manifest -} - -/// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the registry observed by polling the adapter's status -/// export, and a transition from a venue outside its filter is not -/// delivered. -#[tokio::test] -async fn e2e_intent_status_subscription_receives_polled_transitions() { - use crate::bindings::IntentStatus; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - assert!(supervisor.has_intent_status_subscribers()); - - // The registry watches the receipt of an accepted submission and polls - // the adapter's status export; each poll here observes a transition. - let registry = scripted_registry(ScriptedAdapter::new([ - IntentStatus::Pending, - IntentStatus::Fulfilled, - ])); - registry - .submit( - "test-caller", - &crate::host::venue_registry::VenueId::from("cow"), - b"body".to_vec(), - ) - .await - .expect("submit"); - - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // A venue outside the module's filter is not delivered. - let foreign = crate::bindings::IntentStatusUpdate { - venue: "other".to_owned(), - receipt: b"receipt".to_vec(), - status: nexum_status_body::StatusBody { - status: nexum_status_body::IntentStatus::Open, - proof: None, - reason: None, - } - .encode() - .expect("encode"), - }; - assert_eq!(supervisor.dispatch_intent_status(foreign).await, 0); -} - -/// The event-loop wiring: the poll task's stream drives the supervisor, -/// and the module's handler observably ran (its log line is retained). -#[tokio::test] -async fn e2e_intent_status_flows_through_the_event_loop() { - use std::time::Duration; - - use nexum_tasks::{TaskManager, TaskSet}; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let logs = components.logs.clone(); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - let registry = scripted_registry(ScriptedAdapter::new([])); - registry - .submit( - "test-caller", - &crate::host::venue_registry::VenueId::from("cow"), - b"body".to_vec(), - ) - .await - .expect("submit"); - - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - let stream = crate::runtime::event_loop::open_intent_status_stream( - registry, - Duration::from_millis(10), - &executor, - &mut tasks, - ); - crate::runtime::event_loop::run( - &mut supervisor, - Vec::new(), - Vec::new(), - Some(stream), - tasks, - tokio::time::sleep(Duration::from_millis(300)), - ) - .await; - - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - let runs = logs.list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded for the example module"); - let page = logs.read(&runs[0].run, 0); - assert!( - page.records - .iter() - .any(|r| r.message.contains("intent status update from venue cow")), - "the module's on_intent_status handler ran; records were: {:?}", - page.records - .iter() - .map(|r| r.message.as_str()) - .collect::>(), - ); -} - -/// The first-train acceptance path, end to end over two real components: -/// the echo-client module submits through `videre:venue/client`, the host -/// registry forwards to the installed echo-venue adapter, and the module -/// receives the fulfilled `intent-status` the registry polls back. Proves -/// the intent core round-trips module -> host registry -> venue adapter -/// with no -/// scripted stand-ins on either side. -#[tokio::test] -async fn e2e_echo_module_registry_adapter_round_trip() { - use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; - use crate::host::component::ChainMethod; - use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; - - let (Some(adapter_wasm), Some(module_wasm)) = - (echo_venue_wasm_or_skip(), echo_client_wasm_or_skip()) - else { - return; - }; - - // The adapter reads eth_blockNumber on submit to justify its `chain` - // grant; program the mock so that read succeeds. The response body is - // discarded by the adapter, so any Ok value serves. - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - let components = crate::test_utils::mock_components_from(chain, MockStateStore::new()); - let logs = components.logs.clone(); - - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(echo_venue_module_toml()), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(echo_client_module_toml()), - }], - ..Default::default() - }; - - let mut supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) - .await - .expect("boot"); - assert_eq!( - supervisor.adapter_alive_count(), - 1, - "echo-venue is routable" - ); - assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); - assert!(supervisor.has_intent_status_subscribers()); - - // A block drives the module's on_block, which submits to the echo venue - // through the shared registry; the registry watches the accepted receipt. - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - assert_eq!(supervisor.dispatch_block(block).await, 1); - - // Poll the registry the module submitted through and fan its transitions - // back to the module. echo-venue settles instantly, so the first poll - // reports a terminal status and the watch is pruned. - let registry = supervisor.venue_registry().expect("registry service"); - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - assert_eq!(update.venue, "echo-venue"); - let body = - nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); - assert_eq!( - body.status, - nexum_status_body::IntentStatus::Fulfilled, - "echo settles instantly", - ); - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!( - delivered, 1, - "one terminal status delivered to the subscriber" - ); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // The module observably completed the round trip: it quoted, it - // submitted, and it received the settled status from the echo venue. - let runs = logs.list_runs("echo-client"); - assert_eq!(runs.len(), 1, "one run recorded for echo-client"); - let page = logs.read(&runs[0].run, 0); - let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert!( - messages - .iter() - .any(|m| m.contains("quoted") && m.contains("echo-venue")), - "module quoted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("submitted") && m.contains("echo-venue")), - "module submitted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("intent status from venue echo-venue")), - "module received the settled status; records were: {messages:?}", - ); -} - /// A `ManualClock` override threads through `boot_single` onto the module /// store and is behaviour-neutral: the module boots, dispatches a block, and /// stays alive exactly as it does on the ambient clock. Locks the plumbing so @@ -1114,53 +592,6 @@ async fn boot_production_module( .expect("boot_single") } -/// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the -/// capability registry. The paired linker-hook + capability-namespace -/// registration is what makes the same module boot once the cow extension -/// is wired at the composition root; drop the pairing and boot fails. The -/// positive direction (boots WITH the cow extension) is covered by the -/// extension crate that owns the backend. -#[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - // Core-only: no cow linker hook, no cow capability namespace. - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - let (_dir, store) = temp_local_store(); - let components = test_components(store); - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await; - - let err = result - .err() - .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. - let chain = format!("{err:#}"); - assert!( - chain.contains(r#"unknown capability "cow-api""#), - "expected the cow-api unknown-capability failure, got: {chain}", - ); -} - #[tokio::test] async fn e2e_price_alert_block_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -2828,44 +2259,97 @@ fn chainlog_cursor_key_differs_by_each_input() { ); } -// ── venue-adapter boot ──────────────────────────────────────────────── +// ── provider boot gating ────────────────────────────────────────────── -/// The venue-adapter provider linker binds only the scoped transport -/// (chain, messaging, wasi base, allowlisted http) and withholds the -/// core-only interfaces. Assembling it proves the scope wires without a -/// duplicate-definition clash between the shared `nexum:host` interfaces. -#[tokio::test] -async fn provider_linker_assembles_with_scoped_transport() { - let engine = make_wasmtime_engine(); - crate::supervisor::build_provider_linker::( - &engine, - &crate::host::venue_registry::VenueAdapterKind, - ) - .expect("provider linker assembles"); +/// A stub extension registering the `acme-adapter` provider kind behind a +/// unit service, so the boot-gate tests exercise the generic kind loop +/// without a real provider component. +struct AcmeService; +impl crate::host::extension::HostService for AcmeService {} + +struct AcmeKind; + +#[async_trait::async_trait] +impl ProviderKind for AcmeKind { + fn kind(&self) -> &'static str { + "acme-adapter" + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn install( + &self, + _instance: ProviderInstance<'_, crate::test_utils::MockTypes>, + _service: &Arc, + ) -> anyhow::Result { + Ok(Installed::Live) + } +} + +struct AcmeExtension; + +impl Extension for AcmeExtension { + fn namespace(&self) -> &'static str { + "acme" + } + + fn capabilities(&self) -> manifest::NamespaceCaps { + manifest::NamespaceCaps { + prefix: "test:acme/", + ifaces: &[], + } + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::new(AcmeService)) + } + + fn provider(&self) -> Option>> { + Some(Box::new(AcmeKind)) + } } -/// The module-kind discriminator gates the adapter load path: an +/// The stub extension set registering the `acme-adapter` kind. +fn acme_extensions() -> Vec>> { + vec![Arc::new(AcmeExtension)] +} + +/// The module-kind discriminator gates the provider load path: an /// `[[adapters]]` entry whose manifest is (or defaults to) an event-module -/// is rejected before instantiation with a message naming the required -/// kind. +/// is rejected before instantiation with a message naming the registered +/// kinds. #[tokio::test] -async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { +async fn boot_rejects_provider_whose_manifest_is_an_event_module() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"event-module\"\n", + "[module]\nname = \"acme\"\nkind = \"event-module\"\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("cow.wasm"), + path: dir.path().join("acme.wasm"), manifest: Some(manifest), http_allow: Vec::new(), messaging_topics: Vec::new(), @@ -2873,14 +2357,15 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("venue-adapter"), - "the kind gate names the required kind: {msg}", + msg.contains("acme-adapter"), + "the kind gate names the registered kinds: {msg}", ); } @@ -2890,8 +2375,10 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { async fn boot_rejects_an_unregistered_provider_kind() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); @@ -2908,54 +2395,58 @@ async fn boot_rejects_an_unregistered_provider_kind() { ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("an unregistered provider kind must be refused"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("unregistered provider kind gadget") && msg.contains("venue-adapter"), + msg.contains("unregistered provider kind gadget") && msg.contains("acme-adapter"), "the refusal names the unknown spelling and the registered kinds: {msg}", ); } -/// A venue-adapter manifest clears the discriminator; boot then reaches the +/// A registered kind clears the discriminator; boot then reaches the /// compile step and fails only because the referenced wasm is absent. This -/// proves the discriminator routed the entry to the adapter load path +/// proves the discriminator routed the entry to the provider load path /// rather than rejecting it on kind. #[tokio::test] -async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { +async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"venue-adapter\"\n\n\ + "[module]\nname = \"acme\"\nkind = \"acme-adapter\"\n\n\ [capabilities]\nrequired = [\"chain\"]\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("missing-cow.wasm"), + path: dir.path().join("missing-acme.wasm"), manifest: Some(manifest), - http_allow: vec!["api.cow.fi".into()], - messaging_topics: vec!["/nexum/1/cow-orders/proto".into()], + http_allow: vec!["api.acme.example".into()], + messaging_topics: vec!["/nexum/1/acme-orders/proto".into()], }], ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("absent adapter wasm must fail the compile step"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("absent provider wasm must fail the compile step"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("compile") || msg.contains("missing-cow"), + msg.contains("compile") || msg.contains("missing-acme"), "boot reached the compile step past the kind gate: {msg}", ); assert!( @@ -2964,163 +2455,53 @@ async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { ); } -// ── venue-adapter trap recovery ─────────────────────────────────────── - -/// Boot one flaky-venue adapter over the mock chain, whose head starts at -/// the fixture's poison sentinel. Returns the chain handle so the test can -/// let the venue recover. -async fn boot_flaky_venue( - adapter_wasm: PathBuf, - limits: crate::engine_config::ModuleLimits, -) -> ( - Supervisor, - crate::test_utils::MockChainProvider, -) { - use crate::engine_config::AdapterEntry; - use crate::host::component::ChainMethod; - - let chain = crate::test_utils::MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); - let components = crate::test_utils::mock_components_from( - chain.clone(), - crate::test_utils::MockStateStore::new(), - ); - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(fixture_module_toml( - "modules/fixtures/flaky-venue/module.toml", - )), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - limits, - ..Default::default() - }; - let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) - .await - .expect("boot"); - (supervisor, chain) -} - -/// A test block that drives the dispatch-time sweeps. -fn sweep_block() -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - } -} - -/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped -/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the -/// provider restart sweep reinstantiates it after backoff, after which a -/// submit succeeds again. +/// A module subscribing to an extension kind no wired extension declares +/// is refused at boot, preserving the unknown-kind fail-fast. #[tokio::test] -async fn e2e_trapped_adapter_is_swept_and_restarts() { - use crate::bindings::{SubmitOutcome, VenueError}; - use crate::host::component::ChainMethod; - use crate::host::venue_registry::VenueId; - - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { +async fn boot_refuses_an_undeclared_extension_subscription_kind() { + let Some(wasm) = example_wasm_or_skip() else { return; }; - let (mut supervisor, chain) = - boot_flaky_venue(wasm, crate::engine_config::ModuleLimits::default()).await; - assert_eq!(supervisor.adapter_count(), 1); - assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); - let registry = supervisor.venue_registry().expect("registry service"); - let venue = VenueId::from("flaky-venue"); - - // The poison head detonates submit: the guest panic traps the store - // and the shared liveness drops. - let err = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect_err("the poison head traps the adapter"); - assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "the trap drops liveness" - ); + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" - // Temporarily dead resolves distinctly from never installed. - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); - assert!(matches!( - registry - .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) - .await, - Err(VenueError::UnknownVenue) - )); - - // The venue recovers; past the 1s backoff the dispatch-time sweep - // reinstalls the adapter on a fresh store. - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); - let outcome = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect("the recovered adapter accepts"); - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); -} +[capabilities] +required = ["logging"] -/// A crash-looping adapter is quarantined by the provider poison sweep: -/// at the threshold the restarts stop, and the venue stays dead past every -/// backoff until an operator intervenes. -#[tokio::test] -async fn e2e_crash_looping_adapter_is_poisoned() { - use crate::bindings::VenueError; - use crate::engine_config::PoisonLimitsSection; - use crate::host::venue_registry::VenueId; +[[subscription]] +kind = "acme-status" +"#, + ) + .expect("write manifest"); - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { - return; - }; - let limits = ModuleLimits { - poison: PoisonLimitsSection { - max_failures: Some(2), - window_secs: Some(600), - }, - ..ModuleLimits::default() - }; - // The chain head stays at the poison sentinel for the whole test: every - // submit after a restart traps again. - let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; - let registry = supervisor.venue_registry().expect("registry service"); - let venue = VenueId::from("flaky-venue"); - - // Trap 1, then a successful restart past the 1s backoff. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); - - // Trap 2 crosses the 2-failure threshold: the sweep quarantines the - // adapter instead of scheduling another restart. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); - - // Past every backoff the poisoned adapter stays dead and unavailable. - tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "no restart while poisoned" + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let (_dir, local_store) = temp_local_store(); + let components = test_components(local_store); + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &core_extensions(), + None, + ) + .await; + let err = result + .err() + .expect("an undeclared extension subscription kind must refuse boot"); + let msg = format!("{err:#}"); + assert!( + msg.contains("unknown event kind acme-status"), + "the refusal names the kind: {msg}", ); - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); } diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 559cf28e..4612f12b 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -1,7 +1,6 @@ //! Boot-order coverage for the cow-api extension: a module that imports //! `shepherd:cow/cow-api` boots and dispatches once the extension is wired -//! at the composition root. The negative direction (fails to boot without -//! the extension) lives in the runtime's own supervisor tests. +//! at the composition root, and fails to boot without it. //! //! These exercise the real wit-bindgen + supervisor path against pre-built //! wasm artefacts and skip gracefully when the artefact is absent. @@ -218,3 +217,48 @@ async fn e2e_stop_loss_block_dispatch() { assert_eq!(dispatched, 1); assert_eq!(supervisor.alive_count(), 1); } + +/// The boot-order invariant, exercised (not merely asserted in prose): +/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT +/// boot when the cow extension is absent from the linker AND the +/// capability registry. The paired linker-hook + capability-namespace +/// registration is what makes the same module boot in the tests above; +/// drop the pairing and boot fails. +#[tokio::test] +async fn twap_monitor_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("twap-monitor") else { + return; + }; + let manifest = production_module_toml("modules/twap-monitor/module.toml"); + let engine = make_wasmtime_engine(); + // Core-only: no cow linker hook, no cow capability namespace. + let linker = build_linker::(&engine, &[]).expect("build_linker"); + let (_dir, store) = temp_local_store(); + let components = test_components(store).await; + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &[], + None, + ) + .await; + + let err = result + .err() + .expect("cow-importing module must not boot without the cow extension registered"); + // Pin the failure to its specific cause: twap-monitor declares the + // cow-api capability, which a core-only registry does not recognise + // (registering it is exactly what the cow extension does). Rules out + // an unrelated failure masquerading as the invariant. + let chain = format!("{err:#}"); + assert!( + chain.contains(r#"unknown capability "cow-api""#), + "expected the cow-api unknown-capability failure, got: {chain}", + ); +} diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml index 63e99085..2b5ed588 100644 --- a/crates/shepherd/Cargo.toml +++ b/crates/shepherd/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } shepherd-cow-host = { path = "../shepherd-cow-host" } +videre-host = { path = "../videre-host" } anyhow.workspace = true tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index 4022ff57..9a32d9fd 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -1,12 +1,14 @@ //! The `shepherd` binary: the cow composition root. Binds the reference -//! lattice with the cow-api extension payload in the `Ext` slot and hands -//! it to the generic launcher; the engine itself stays cow-free. +//! lattice with the cow-api extension payload in the `Ext` slot, registers +//! the videre venue platform, and hands it all to the generic launcher; +//! the engine itself stays venue- and cow-free. #![cfg_attr(not(test), warn(unused_crate_dependencies))] use std::sync::Arc; use nexum_runtime::addons::{AddOns, PrometheusAddOn}; +use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::{ ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, }; @@ -27,8 +29,8 @@ impl RuntimeTypes for ReferenceTypes { type Ext = ReferenceExt; } -/// The cow preset: reference backends, the cow-api extension, and the -/// Prometheus add-on. +/// The cow preset: reference backends, the videre venue platform, the +/// cow-api extension, and the Prometheus add-on. #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; @@ -49,8 +51,11 @@ impl Runtime for ShepherdRuntime { vec![Box::new(PrometheusAddOn)] } - fn extensions(&self) -> Vec>> { - vec![extension::()] + fn extensions(&self, config: &EngineConfig) -> Vec>> { + vec![ + Arc::new(videre_host::platform(config)), + extension::(), + ] } } diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml new file mode 100644 index 00000000..a2d9ae30 --- /dev/null +++ b/crates/videre-host/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "videre-host" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The videre venue platform as one nexum-runtime extension: the venue-adapter provider kind, the venue registry service, the advisory egress-guard seam, and the videre:venue/client interface." + +[lints] +workspace = true + +[dependencies] +# The runtime seam this crate plugs into; the only nexum crate edge. +nexum-runtime = { path = "../nexum-runtime" } +# Encoder for the opaque status body the host event stream carries; the +# registry lowers an adapter-reported status through it. +nexum-status-body = { path = "../nexum-status-body" } + +wasmtime.workspace = true +anyhow.workspace = true +thiserror.workspace = true +async-trait.workspace = true +futures.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +nexum-runtime = { path = "../nexum-runtime", features = ["test-utils"] } +nexum-tasks = { path = "../nexum-tasks" } +tempfile.workspace = true diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs new file mode 100644 index 00000000..c6281016 --- /dev/null +++ b/crates/videre-host/src/bindings.rs @@ -0,0 +1,231 @@ +//! WIT bindings for the venue platform, generated by +//! `wasmtime::component::bindgen!`. +//! +//! The shared `nexum:host` interfaces are reused from the runtime's +//! `event-module` bindings via `with`, so the `chain`/`messaging` `Host` +//! impls and the `fault` type an adapter sees are the very ones the core +//! host constructs. The `videre:types` and `videre:value-flow` types +//! generate in the adapter bindgen and the client bindgen remaps onto +//! them, so one Rust type serves the registry and the adapter face alike. +//! `PartialEq` is derived so the registry can compare a polled status +//! against the last delivered one. + +/// The provider face: the `videre:venue/venue-adapter` world. An adapter +/// imports only the scoped transport it needs (chain and messaging; +/// outbound HTTP is wasi:http, linked and allowlisted separately) and +/// exports the `videre:venue/adapter` face plus `init`. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + world: "videre:venue/venue-adapter", + imports: { default: async }, + exports: { default: async }, + with: { + "nexum:host/types": nexum_runtime::bindings::nexum::host::types, + "nexum:host/chain": nexum_runtime::bindings::nexum::host::chain, + "nexum:host/messaging": nexum_runtime::bindings::nexum::host::messaging, + }, + additional_derives: [PartialEq], + }); +} + +pub use venue_adapter::VenueAdapter; + +/// The keeper-facing `videre:venue/client` import bound host-side. The +/// world imports the interface a module calls; the videre types it uses +/// are reused from the adapter bindings above via `with`, so the +/// `SubmitOutcome` and `VenueError` the registry hands back to a module +/// are the very ones an adapter's `submit` produced. Async, because the +/// `Host` impl awaits the per-adapter mutex and the adapter's own async +/// guest calls. +mod client_host { + wasmtime::component::bindgen!({ + inline: " + package videre:client-host; + world client-host { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + imports: { default: async }, + with: { + "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, + "videre:types/types": super::venue_adapter::videre::types::types, + }, + }); +} + +/// The host-bound client interface: the `Host` trait the registry implements +/// and the `add_to_linker` the videre extension's linker hook calls. +pub use client_host::videre::venue::client; +/// The shared intent ontology, re-exported at the plain spellings the +/// registry and the `client::Host` impl name. +pub use venue_adapter::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::videre::value_flow::types as value_flow; + +/// Bindgen smoke for the `videre:value-flow` types package, compiled under +/// test through a throwaway world that imports the interface. Its value is +/// the identifier-hygiene gate: the test names every generated type, +/// variant, and field by its plain Rust spelling, so a WIT id that collided +/// with a Rust keyword would surface as an `r#` escape and fail to compile +/// here rather than in a downstream binding. +#[cfg(test)] +mod value_flow_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:value-flow-smoke; + world smoke { + import videre:value-flow/types@0.1.0; + } + ", + path: ["../../wit/videre-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use videre::value_flow::types::{Asset, AssetAmount, Erc20}; + + let erc20 = Erc20 { + token: vec![0u8; 20], + }; + let _ = Asset::Native; + let asset = Asset::Erc20(erc20); + + let amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} + +/// Bindgen smoke for the `videre:types` and `videre:venue` packages, +/// mirroring the value-flow smoke above: a throwaway world imports the +/// client interface and, transitively, the types interface and its +/// value-flow dependency. The test names every generated type, case, and +/// field by its plain Rust spelling, and a dummy `client` host impl pins +/// the four function signatures, so a keyword collision or an accidental +/// signature change fails this build rather than a downstream binding. +#[cfg(test)] +mod client_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:client-smoke; + world smoke { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + }); + + use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, + }; + use videre::value_flow::types::{Asset, AssetAmount}; + + struct DummyClient; + + impl videre::venue::client::Host for DummyClient { + fn quote(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn submit(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn status( + &mut self, + _venue: String, + _receipt: Vec, + ) -> Result { + Err(VenueError::UnknownVenue) + } + + fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + } + + fn amount(bytes: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: bytes, + } + } + + #[test] + fn identifiers_bind_unescaped() { + use videre::venue::client::Host; + + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Eip712; + + let header = IntentHeader { + gives: amount(vec![1]), + wants: amount(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }; + assert!(header.wants.amount.is_empty()); + + let _ = IntentStatus::Pending; + let _ = IntentStatus::Open; + let _ = IntentStatus::Fulfilled; + let _ = IntentStatus::Cancelled; + let _ = IntentStatus::Expired; + + let tx = UnsignedTx { + chain: 1, + to: Vec::new(), + value: Vec::new(), + data: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let quotation = Quotation { + gives: amount(vec![1]), + wants: amount(Vec::new()), + fee: amount(Vec::new()), + valid_until_ms: 0, + }; + assert!(quotation.fee.amount.is_empty()); + + let _ = VenueError::UnknownVenue; + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::Unsupported; + let _ = VenueError::Denied(String::new()); + let _ = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::Timeout; + + let mut client = DummyClient; + assert!(client.quote(String::new(), Vec::new()).is_err()); + assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.status(String::new(), Vec::new()).is_err()); + assert!(client.cancel(String::new(), Vec::new()).is_err()); + } +} diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/videre-host/src/client.rs similarity index 84% rename from crates/nexum-runtime/src/host/impls/venue_client.rs rename to crates/videre-host/src/client.rs index 8b86e8a4..2973b01e 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/videre-host/src/client.rs @@ -4,15 +4,18 @@ //! resolution, per-adapter serialisation, guard seam (advisory-only for //! now), and quota. The caller identity the registry meters against is this //! store's module namespace. No registry service means no venues, so every -//! call resolves to `unknown-venue`. +//! call resolves to `unknown-venue`. The `Host` trait is local to this +//! crate's bindgen, so implementing it for the runtime's `HostState` is +//! orphan-legal. use std::sync::Arc; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::state::HostState; + use crate::bindings::client::Host; use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; -use crate::host::venue_registry::{VenueId, VenueRegistry}; +use crate::registry::{VenueId, VenueRegistry}; /// The registry published under the videre service namespace. fn registry(state: &HostState) -> Result, VenueError> { diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs new file mode 100644 index 00000000..fcd715bb --- /dev/null +++ b/crates/videre-host/src/lib.rs @@ -0,0 +1,145 @@ +//! The videre venue platform, packaged as one [`nexum_runtime`] +//! extension: the venue-adapter provider kind, the [`VenueRegistry`] +//! service, the advisory [`EgressGuard`] seam, and the keeper-facing +//! `videre:venue/client` interface. A composition root registers it all +//! with `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod bindings; +mod client; +mod registry; + +use std::sync::Arc; +use std::time::Duration; + +use nexum_runtime::bindings::nexum::host::types::Event; +use nexum_runtime::engine_config::EngineConfig; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, +}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::NamespaceCaps; +use tokio::sync::mpsc; +use wasmtime::component::{HasSelf, Linker}; + +pub use registry::{ + DuplicateVenue, EgressGuard, GuardContext, GuardVerdict, IntentStatusUpdate, VenueActor, + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, +}; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS_KIND: &str = "intent-status"; + +/// Buffer for the status poll channel; small because the event loop +/// drains in real time. +const STATUS_CHANNEL_BUF: usize = 64; + +/// The venue platform over the config-resolved quota and watch policy, +/// with the unit guard. The single registration entrypoint. +pub fn platform(config: &EngineConfig) -> Videre { + Videre::from_registry( + VenueRegistryBuilder::new(config.limits.quota()) + .with_watch_limit(config.limits.watch()) + .build(), + ) +} + +/// The videre platform as one runtime extension. Registers the +/// `videre:venue/client` interface and its capability namespace on every +/// worker linker, publishes the [`VenueRegistry`] service, installs the +/// venue-adapter provider kind, and opens the status-poll event source. +pub struct Videre { + registry: Arc, +} + +impl Videre { + /// Assemble over a pre-built registry, for a custom [`EgressGuard`] + /// or policy; [`platform`] covers the config-resolved default. + pub fn from_registry(registry: VenueRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } +} + +impl Extension for Videre { + fn namespace(&self) -> &'static str { + VenueRegistry::NAMESPACE + } + + /// Only the keeper-facing `client` interface is a capability; the + /// `videre:types` and `videre:value-flow` packages are type-only and + /// need no declaration. + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "videre:venue/", + ifaces: &["client"], + } + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + bindings::client::add_to_linker::, HasSelf>>(linker, |state| { + state + })?; + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::clone(&self.registry) as Arc) + } + + fn provider(&self) -> Option>> { + Some(Box::new(VenueAdapterKind)) + } + + fn subscriptions(&self) -> &'static [&'static str] { + &[INTENT_STATUS_KIND] + } + + /// The status poll source: on every cadence tick, poll each installed + /// adapter's status export through the shared registry and forward the + /// observed transitions. Opened only when a module subscribes and at + /// least one venue is installed. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + if !sources.subscribed.contains(INTENT_STATUS_KIND) { + return Ok(Vec::new()); + } + let registry = (*self.registry).clone(); + if registry.venue_count() == 0 { + return Ok(Vec::new()); + } + let cadence = sources.config.limits.status_poll_interval(); + let (tx, rx) = mpsc::channel::(STATUS_CHANNEL_BUF); + sources.spawn(status_poll_task(registry, cadence, tx)); + let stream = futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + Ok(vec![Box::pin(stream)]) + } +} + +/// Poll loop behind [`Extension::events`]. Sleeps the cadence first so +/// the engine's boot dispatch settles before the first poll; ends when +/// the event loop drops its receiver. +async fn status_poll_task( + registry: VenueRegistry, + cadence: Duration, + tx: mpsc::Sender, +) { + loop { + tokio::time::sleep(cadence).await; + for update in registry.poll_status_transitions().await { + let event = ExtensionEvent { + kind: INTENT_STATUS_KIND, + attrs: vec![("venue", update.venue.clone())], + event: Event::IntentStatus(update), + }; + if tx.send(event).await.is_err() { + // Receiver dropped -> engine shutting down. + return; + } + } + } +} diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/videre-host/src/registry.rs similarity index 95% rename from crates/nexum-runtime/src/host/venue_registry.rs rename to crates/videre-host/src/registry.rs index 301e371e..43f558f8 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/videre-host/src/registry.rs @@ -31,31 +31,27 @@ use std::time::{Duration, Instant}; use anyhow::{Context, anyhow}; use async_trait::async_trait; use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{SubmitQuota, WatchLimit}; +use nexum_runtime::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; +use nexum_runtime::host::state::HostState; use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; use tracing::{info, warn}; use wasmtime::Store; use wasmtime::component::HasSelf; +/// The registry-observed status transition delivered through the host +/// `event` variant, re-exported at the spelling the registry names. +pub use nexum_runtime::bindings::nexum::host::types::IntentStatusUpdate; + use crate::bindings::{ - IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, - VenueAdapter, VenueError, nexum, + IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, }; -use crate::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; -use crate::host::component::RuntimeTypes; -use crate::host::extension::{ - HostService, Installed, ProviderInstance, ProviderKind, downcast_service, -}; -use crate::host::state::HostState; - -/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. -pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; -/// Default sliding window the per-caller submission budget is counted over. -pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); -/// Default cap on receipts under status watch at once. -pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; -/// Default lifetime of one status watch before it is evicted unreported. -pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); /// Venue identifier: the id an adapter registers under and a submission /// names. Opaque beyond equality. @@ -87,61 +83,6 @@ impl fmt::Display for VenueId { } } -/// Per-caller submission quota. Both a forwarded submission and a charged -/// decode failure consume one unit; the window slides so a caller's budget -/// refills as old charges age out. -#[derive(Debug, Clone, Copy)] -pub struct SubmitQuota { - /// Maximum charges a single caller may accrue within `window`. - pub max_charges: u32, - /// Sliding window the charges are counted across. - pub window: Duration, -} - -impl SubmitQuota { - /// Pair a budget with the window it is counted over. - pub const fn new(max_charges: u32, window: Duration) -> Self { - Self { - max_charges, - window, - } - } -} - -impl Default for SubmitQuota { - fn default() -> Self { - Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) - } -} - -/// Bounds on the status-watch set. The cap bounds the per-cadence poll -/// fan-out; the expiry evicts a watch whose venue has gone silent for a -/// whole window. -#[derive(Debug, Clone, Copy)] -pub struct WatchLimit { - /// Maximum receipts under status watch at once. - pub max_entries: usize, - /// How long a watch survives without a successful poll before it is - /// evicted unreported. - pub expiry: Duration, -} - -impl WatchLimit { - /// Pair a cap with the per-entry expiry. - pub const fn new(max_entries: usize, expiry: Duration) -> Self { - Self { - max_entries, - expiry, - } - } -} - -impl Default for WatchLimit { - fn default() -> Self { - Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) - } -} - /// The guard interposition seam. The registry runs this on the /// adapter-derived header after `derive-header` and before `submit`. /// @@ -712,9 +653,8 @@ impl VenueRegistry { } /// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` -/// component and installs its actor in the venue registry. Registered by -/// the boot path while the registry lives in-core; the videre extension -/// takes it over. +/// component and installs its actor in the venue registry. Registered +/// through the videre extension's provider slot. pub struct VenueAdapterKind; impl VenueAdapterKind { @@ -769,8 +709,7 @@ impl ProviderKind for VenueAdapterKind { Err(e) => { warn!( adapter = %venue_id, - kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + fault = ?e, "adapter init failed - loaded but marked dead", ); return Ok(Installed::Dead); @@ -1464,7 +1403,7 @@ mod tests { #[test] fn zero_watch_cap_saturates_to_one() { let registry = VenueRegistryBuilder::new(SubmitQuota::default()) - .with_watch_limit(WatchLimit::new(0, DEFAULT_WATCH_EXPIRY)) + .with_watch_limit(WatchLimit::new(0, Duration::from_secs(60))) .build(); assert_eq!(registry.inner.watch_limit.max_entries, 1); } diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs new file mode 100644 index 00000000..05bc062e --- /dev/null +++ b/crates/videre-host/tests/platform.rs @@ -0,0 +1,714 @@ +//! E2E coverage for the videre platform over the generic runtime seam: +//! the venue-adapter provider boot, the client -> registry -> adapter +//! round trip, the status-poll event source, and the trap-to-recovery +//! sweeps. Exercises pre-built wasm artefacts and skips gracefully when +//! an artefact is absent. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, PoisonLimitsSection, +}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::CapabilityRegistry; +use nexum_runtime::supervisor::{Supervisor, build_linker, build_provider_linker}; +use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, mock_components}; +use videre_host::bindings::{ + IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, value_flow, +}; +use videre_host::{ + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, Videre, platform, +}; +use wasmtime::component::Linker; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS: &str = "intent-status"; + +// ── fixtures + assembly ─────────────────────────────────────────────── + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir, +/// or `None` with a skip message when it is not built. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None + } +} + +fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") +} + +/// The platform under test plus the extension slice the boot paths take. +/// The concrete handle stays available for the event-source calls. +fn videre_assembly(videre: &Arc) -> Vec>> { + vec![Arc::clone(videre) as Arc>] +} + +fn make_linker( + engine: &wasmtime::Engine, + extensions: &[Arc>], +) -> Linker> { + build_linker::(engine, extensions).expect("build_linker") +} + +/// The registry the booted supervisor publishes under the videre +/// namespace. +fn registry_of(supervisor: &Supervisor) -> Arc { + supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service") +} + +/// A test block that drives dispatch and the dispatch-time sweeps. +fn block(chain_id: u64) -> nexum::host::types::Block { + nexum::host::types::Block { + chain_id, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + } +} + +/// Wrap a polled transition as the extension event the platform emits. +fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { + ExtensionEvent { + kind: INTENT_STATUS, + attrs: vec![("venue", update.venue.clone())], + event: nexum::host::types::Event::IntentStatus(update), + } +} + +// ── world contract ──────────────────────────────────────────────────── + +/// The per-component venue-adapter world contract: an adapter built +/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped +/// transport its manifest declares (`chain`), by construction of the +/// emitted world. The venue side never depended on toolchain elision; +/// this pins that it does not regress to it. +#[test] +fn e2e_echo_venue_component_imports_equal_declared_capabilities() { + let Some(wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["chain"]), + "imports were: {imports:?}" + ); + + // No host key-material or persistence interface leaks in: an adapter + // structurally cannot reach messaging it never declared, local-store, + // identity, or logging. + assert!( + imports.iter().all(|name| !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + +/// The venue-adapter provider linker binds only the scoped transport +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[tokio::test] +async fn provider_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + build_provider_linker::(&engine, &VenueAdapterKind) + .expect("provider linker assembles"); +} + +// ── intent-status subscription E2E ──────────────────────────────────── + +/// A scripted venue adapter for the registry: accepts every submission +/// with a fixed receipt and serves statuses front-first from a script; +/// once drained, every further call reports `open`. +struct ScriptedAdapter { + statuses: VecDeque, +} + +impl ScriptedAdapter { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: statuses.into_iter().collect(), + } + } +} + +fn native(bytes: Vec) -> value_flow::AssetAmount { + value_flow::AssetAmount { + asset: value_flow::Asset::Native, + amount: bytes, + } +} + +impl VenueInvoker for ScriptedAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(IntentHeader { + gives: native(vec![1]), + wants: native(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: videre_host::bindings::AuthScheme::Eip712, + }) + }) + } + + fn quote<'a>(&'a mut self, _body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(Quotation { + gives: native(vec![1]), + wants: native(Vec::new()), + fee: native(Vec::new()), + valid_until_ms: 1_700_000_000_000, + }) + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(SubmitOutcome::Accepted(b"receipt".to_vec())) }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(self.statuses.pop_front().unwrap_or(IntentStatus::Open)) }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { Ok(()) }) + } +} + +/// A registry with one scripted adapter installed under `cow`. +fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { + let registry = VenueRegistryBuilder::new(Default::default()).build(); + registry + .install( + VenueId::from("cow"), + nexum_runtime::host::actor::Liveness::default(), + adapter, + ) + .expect("install scripted adapter"); + registry +} + +/// Write a manifest subscribing the example module to intent-status +/// events from the `cow` venue. +fn intent_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + +/// Boot the example module against the given videre platform. +async fn boot_example(videre: &Arc, wasm: &Path, manifest: &Path) -> Supervisor { + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let limits = ModuleLimits::default(); + Supervisor::boot_single( + &engine, + &linker, + wasm, + Some(manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single") +} + +/// The acceptance path: a module subscribed to `intent-status` receives +/// the transitions the registry observed by polling the adapter's status +/// export, and a transition from a venue outside its filter is not +/// delivered. +#[tokio::test] +async fn e2e_intent_status_subscription_receives_polled_transitions() { + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([ + IntentStatus::Pending, + IntentStatus::Fulfilled, + ])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // The registry watches the receipt of an accepted submission and polls + // the adapter's status export; each poll here observes a transition. + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // A venue outside the module's filter is not delivered. + let foreign = videre_host::IntentStatusUpdate { + venue: "other".to_owned(), + receipt: b"receipt".to_vec(), + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(foreign)) + .await, + 0 + ); +} + +/// The event-loop wiring, through the real seam: the platform's `events` +/// source opens against the booted service map, its poll task drives the +/// supervisor, and the module's handler observably ran (its log line is +/// retained). +#[tokio::test] +async fn e2e_intent_status_flows_through_the_event_loop() { + use nexum_tasks::{TaskManager, TaskSet}; + + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single"); + + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + // A fast cadence so the 300 ms window sees the first poll. + let mut config = EngineConfig::default(); + config.limits.status_poll.interval_ms = Some(10); + + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + let subscribed = supervisor.extension_subscription_kinds(); + let streams = { + let mut sources = EventSources::new( + &config, + supervisor.services(), + &subscribed, + &executor, + &mut tasks, + ); + Extension::::events(&*videre, &mut sources).expect("open event source") + }; + assert_eq!(streams.len(), 1, "one status-poll stream opened"); + + nexum_runtime::runtime::event_loop::run( + &mut supervisor, + Vec::new(), + Vec::new(), + streams, + tasks, + tokio::time::sleep(Duration::from_millis(300)), + ) + .await; + + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + let runs = logs.list_runs("example"); + assert_eq!(runs.len(), 1, "one run recorded for the example module"); + let page = logs.read(&runs[0].run, 0); + assert!( + page.records + .iter() + .any(|r| r.message.contains("intent status update from venue cow")), + "the module's on_intent_status handler ran; records were: {:?}", + page.records + .iter() + .map(|r| r.message.as_str()) + .collect::>(), + ); +} + +/// With no subscriber (or no installed venue) the platform opens no +/// event source. +#[tokio::test] +async fn event_source_stays_closed_without_subscribers_or_venues() { + use nexum_tasks::{TaskManager, TaskSet}; + + let config = EngineConfig::default(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let services = nexum_runtime::host::extension::HostServices::default(); + + // A venue is installed but nothing subscribes. + let with_venue = Arc::new(Videre::from_registry(scripted_registry( + ScriptedAdapter::new([]), + ))); + let empty = std::collections::BTreeSet::new(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &empty, &executor, &mut tasks); + let streams = Extension::::events(&*with_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no subscriber, no stream"); + + // A subscriber exists but no venue is installed. + let no_venue = Arc::new(platform(&config)); + let subscribed: std::collections::BTreeSet = + std::iter::once(INTENT_STATUS.to_owned()).collect(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &subscribed, &executor, &mut tasks); + let streams = Extension::::events(&*no_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no venue, no stream"); +} + +// ── echo round trip ─────────────────────────────────────────────────── + +/// The acceptance path, end to end over two real components: the +/// echo-client module submits through `videre:venue/client`, the host +/// registry forwards to the installed echo-venue adapter, and the module +/// receives the fulfilled `intent-status` the registry polls back. Proves +/// the intent core round-trips module -> host registry -> venue adapter +/// with no scripted stand-ins on either side. +#[tokio::test] +async fn e2e_echo_module_registry_adapter_round_trip() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. The response body is + // discarded by the adapter, so any Ok value serves. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); + let logs = components.logs.clone(); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/examples/echo-client/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!( + supervisor.adapter_alive_count(), + 1, + "echo-venue is routable" + ); + assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // A block drives the module's on_block, which submits to the echo venue + // through the shared registry; the registry watches the accepted receipt. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + + // Poll the registry the module submitted through and fan its transitions + // back to the module. echo-venue settles instantly, so the first poll + // reports a terminal status and the watch is pruned. + let registry = registry_of(&supervisor); + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + assert_eq!(update.venue, "echo-venue"); + let body = + nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + assert_eq!( + body.status, + nexum_status_body::IntentStatus::Fulfilled, + "echo settles instantly", + ); + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!( + delivered, 1, + "one terminal status delivered to the subscriber" + ); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // The module observably completed the round trip: it quoted, it + // submitted, and it received the settled status from the echo venue. + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for echo-client"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("quoted") && m.contains("echo-venue")), + "module quoted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("submitted") && m.contains("echo-venue")), + "module submitted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("intent status from venue echo-venue")), + "module received the settled status; records were: {messages:?}", + ); +} + +// ── venue-adapter trap recovery ─────────────────────────────────────── + +/// Boot one flaky-venue adapter over the mock chain, whose head starts at +/// the fixture's poison sentinel. Returns the chain handle so the test can +/// let the venue recover. +async fn boot_flaky_venue( + adapter_wasm: PathBuf, + limits: ModuleLimits, +) -> (Supervisor, MockChainProvider) { + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); + let components = + nexum_runtime::test_utils::mock_components_from(chain.clone(), MockStateStore::new()); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/fixtures/flaky-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + limits, + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + (supervisor, chain) +} + +/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped +/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the +/// provider restart sweep reinstantiates it after backoff, after which a +/// submit succeeds again. +#[tokio::test] +async fn e2e_trapped_adapter_is_swept_and_restarts() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let (mut supervisor, chain) = boot_flaky_venue(wasm, ModuleLimits::default()).await; + assert_eq!(supervisor.adapter_count(), 1); + assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // The poison head detonates submit: the guest panic traps the store + // and the shared liveness drops. + let err = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect_err("the poison head traps the adapter"); + assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "the trap drops liveness" + ); + + // Temporarily dead resolves distinctly from never installed. + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + + // The venue recovers; past the 1s backoff the dispatch-time sweep + // reinstalls the adapter on a fresh store. + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); + let outcome = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect("the recovered adapter accepts"); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); +} + +/// A crash-looping adapter is quarantined by the provider poison sweep: +/// at the threshold the restarts stop, and the venue stays dead past every +/// backoff until an operator intervenes. +#[tokio::test] +async fn e2e_crash_looping_adapter_is_poisoned() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let limits = ModuleLimits { + poison: PoisonLimitsSection { + max_failures: Some(2), + window_secs: Some(600), + }, + ..ModuleLimits::default() + }; + // The chain head stays at the poison sentinel for the whole test: every + // submit after a restart traps again. + let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // Trap 1, then a successful restart past the 1s backoff. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); + + // Trap 2 crosses the 2-failure threshold: the sweep quarantines the + // adapter instead of scheduling another restart. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); + + // Past every backoff the poisoned adapter stays dead and unavailable. + tokio::time::sleep(Duration::from_millis(1_500)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "no restart while poisoned" + ); + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); +} From d0ec1e903d5797fff6aa6c90e66a70113972edc9 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 02:49:22 +0000 Subject: [PATCH 25/53] feat: refuse mismatched body-schema versions at install --- Cargo.lock | 2 + crates/nexum-macros/src/lib.rs | 22 +- crates/nexum-runtime/src/host/extension.rs | 50 +++- crates/nexum-runtime/src/manifest/load.rs | 37 +++ crates/nexum-runtime/src/manifest/mod.rs | 1 + crates/nexum-runtime/src/manifest/types.rs | 10 + crates/nexum-runtime/src/supervisor.rs | 86 +++++- crates/nexum-runtime/src/supervisor/tests.rs | 35 +++ crates/nexum-venue-sdk/src/adapter.rs | 14 +- crates/videre-host/Cargo.toml | 2 + crates/videre-host/src/handshake.rs | 285 +++++++++++++++++++ crates/videre-host/src/lib.rs | 26 +- crates/videre-host/src/registry.rs | 13 + crates/videre-host/tests/platform.rs | 126 ++++++++ modules/examples/echo-client/module.toml | 6 + modules/examples/echo-venue/module.toml | 5 + modules/examples/echo-venue/src/lib.rs | 6 + wit/videre-venue/venue.wit | 4 + 18 files changed, 714 insertions(+), 16 deletions(-) create mode 100644 crates/videre-host/src/handshake.rs diff --git a/Cargo.lock b/Cargo.lock index 26dbfa62..99a57b72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6050,9 +6050,11 @@ dependencies = [ "nexum-runtime", "nexum-status-body", "nexum-tasks", + "serde", "tempfile", "thiserror 2.0.18", "tokio", + "toml 1.1.2+spec-1.1.0", "tracing", "wasmtime", ] diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 72e243a0..52f92d95 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -281,7 +281,8 @@ const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", /// Apply to an inherent `impl` block whose associated functions are the /// adapter face: `derive_header`, `quote`, `submit`, `status`, `cancel` /// (all required, from `videre:venue/adapter`), plus an optional `init` -/// (absent means a no-op). Each takes and returns the per-cdylib +/// (absent means a no-op) and an optional `body_versions` (absent +/// declares none). Each takes and returns the per-cdylib /// wit-bindgen payloads for its signature. The macro reads the crate's /// `module.toml`, synthesizes a per-component world exporting the /// adapter face and importing exactly the manifest's declared scoped @@ -382,6 +383,23 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { }; let inline_world = &venue_world.wit; + // `body-versions` is a required adapter export; when the adapter + // omits it, declare none. Install asserts the export equals the + // manifest `[venue] body_versions` set. + let body_versions_impl = if defines("body_versions") { + quote! { + fn body_versions() -> ::std::vec::Vec { + <#self_ty>::body_versions() + } + } + } else { + quote! { + fn body_versions() -> ::std::vec::Vec { + ::std::vec::Vec::new() + } + } + }; + // `init` is a required world export; when the adapter omits it the // config is bound but unused, so drop it to stay warning-clean. let init_impl = if defines("init") { @@ -424,6 +442,8 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { } impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { + #body_versions_impl + fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result< diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index dfa05f7f..9d097fb0 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,6 +1,7 @@ //! The extension seam: what one extension contributes to the host - a //! namespace, a capability namespace, a linker hook, an optional host -//! service, an optional provider kind, and optional event sources. +//! service, an optional provider kind, optional event sources, and +//! optional install predicates over the manifest sections it claims. //! Assembled at the composition root and threaded into every module //! linker. @@ -20,7 +21,7 @@ use crate::engine_config::EngineConfig; use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; -use crate::manifest::NamespaceCaps; +use crate::manifest::{ExtensionSections, NamespaceCaps}; /// One runtime extension. A module that imports an extension interface /// boots only if the linker entry AND the capability namespace are both @@ -50,6 +51,32 @@ pub trait Extension: Send + Sync + 'static { None } + /// Manifest section names this extension claims. A non-core section + /// no wired extension claims is refused at boot. + fn manifest_sections(&self) -> &'static [&'static str] { + &[] + } + + /// Admit one provider at install, over its opaque manifest sections. + /// Runs before compilation; an `Err` refuses the install fail-fast. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let _ = (provider, sections); + Ok(()) + } + + /// Admit one worker at install, over its own and the loaded + /// providers' opaque manifest sections. Runs before compilation; an + /// `Err` refuses the install fail-fast. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + let _ = (worker, sections, providers); + Ok(()) + } + /// Manifest subscription kinds this extension's event sources emit. /// A `[[subscription]]` entry of any other non-core kind is refused /// at boot. @@ -151,7 +178,8 @@ pub trait ProviderKind: Send + Sync + 'static { /// One provider instance ready to install: the compiled component, the /// linker the kind's [`ProviderKind::link`] populated, the supervised -/// store, the manifest `[config]`, and the per-call fuel budget. +/// store, the manifest `[config]` and extension sections, and the +/// per-call fuel budget. pub struct ProviderInstance<'a, T: RuntimeTypes> { /// Compiled provider component. pub component: &'a Component, @@ -161,6 +189,9 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub store: Store>, /// Manifest `[config]` handed to the guest `init`. pub config: Vec<(String, String)>, + /// The provider's extension-owned manifest sections, so a kind can + /// hold the instance to its manifest claims at install. + pub sections: &'a ExtensionSections, /// Fuel budget applied before each routed guest call. pub fuel_per_call: u64, /// Shared liveness the installed instance reports traps on and the @@ -168,6 +199,19 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub liveness: Liveness, } +/// One loaded provider as [`Extension::admit_worker`] sees it: its +/// namespace, registered kind, and opaque manifest sections. Manifest +/// data only, so the predicate is static and liveness-independent. +#[derive(Clone, Debug)] +pub struct ProviderManifest { + /// The provider's namespace: its manifest name. + pub name: String, + /// Registered kind spelling. + pub kind: &'static str, + /// The provider's extension-owned manifest sections. + pub sections: ExtensionSections, +} + /// Outcome of one provider install. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Installed { diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 132dd838..a7a70e98 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -232,6 +232,43 @@ scope = 7 assert!(err.to_string().contains("must be a string"), "{err}"); } + /// A non-core top-level section parses into the opaque extension + /// map: the runtime carries it verbatim and ascribes it no meaning. + #[test] + fn load_parses_extension_sections_opaquely() { + let toml = r#" +[module] +name = "keeper" + +[venue] +body_version = 2 + +[[subscription]] +kind = "block" +chain_id = 1 +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert_eq!(manifest.module.name, "keeper"); + assert_eq!(manifest.subscriptions.len(), 1); + assert_eq!(manifest.extensions.len(), 1); + let venue = manifest.extensions.get("venue").expect("venue section"); + assert_eq!( + venue.get("body_version").and_then(toml::Value::as_integer), + Some(2), + ); + } + + /// A manifest without extension sections carries an empty map. + #[test] + fn load_defaults_to_no_extension_sections() { + let toml = r#" +[module] +name = "plain" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(manifest.extensions.is_empty()); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index e93e179b..da7e0c06 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,6 +35,7 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; +pub use types::ExtensionSections; pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index c13ca680..f6fbc95c 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -39,8 +39,18 @@ pub struct Manifest { /// parsed and ignored (deferred to 0.3). #[serde(default, rename = "subscription")] pub subscriptions: Vec, + /// Extension-owned sections: every non-core top-level key, parsed + /// opaquely. The runtime ascribes them no meaning; it routes them + /// to the wired extensions' install predicates, and a section no + /// extension claims is refused at boot. + #[serde(flatten)] + pub extensions: ExtensionSections, } +/// Extension-owned manifest sections, keyed by top-level name. Opaque +/// to the runtime; each claiming extension parses its own. +pub type ExtensionSections = BTreeMap; + /// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 102dd196..e7c49ecf 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -53,6 +53,7 @@ use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; use crate::host::extension::ExtensionEvent; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, + ProviderManifest, }; use crate::host::http::HttpGate; #[cfg(test)] @@ -269,6 +270,9 @@ struct LoadedProvider { name: String, /// Registered kind spelling the restart sweep reinstalls through. kind: &'static str, + /// Extension-owned manifest sections, as the worker install + /// predicates see them. + sections: manifest::ExtensionSections, /// Cached for restart, like a module's. component: Component, /// Cached for restart: the manifest `[config]` handed to `init`. @@ -337,6 +341,27 @@ fn extension_subscription_vocabulary( .collect() } +/// Refuse a manifest section no wired extension claims, so a typo'd +/// section fails loudly instead of silently skipping its extension's +/// install predicate. +fn enforce_extension_sections( + owner: &str, + sections: &manifest::ExtensionSections, + extensions: &[Arc>], +) -> Result<()> { + for key in sections.keys() { + let claimed = extensions + .iter() + .any(|ext| ext.manifest_sections().contains(&key.as_str())); + if !claimed { + return Err(anyhow!( + "{owner} declares manifest section [{key}]; no wired extension claims it" + )); + } + } + Ok(()) +} + /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -385,11 +410,22 @@ impl Supervisor { &provider_registry, clocks.as_ref(), &kinds, + extensions, ) .await .with_context(|| format!("load provider {}", entry.path.display()))?; providers.push(loaded); } + // The loaded providers' manifests, as the worker install + // predicates see them. + let provider_manifests: Vec = providers + .iter() + .map(|p| ProviderManifest { + name: p.name.clone(), + kind: p.kind, + sections: p.sections.clone(), + }) + .collect(); let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); @@ -404,6 +440,8 @@ impl Supervisor { clocks.as_ref(), services.clone(), &extension_kinds, + extensions, + &provider_manifests, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -467,6 +505,8 @@ impl Supervisor { clocks.as_ref(), services.clone(), &extension_kinds, + extensions, + &[], ) .await?; Ok(Self { @@ -579,6 +619,8 @@ impl Supervisor { clocks: Option<&WasiClockOverride>, services: HostServices, extension_kinds: &BTreeSet<&'static str>, + extensions: &[Arc>], + provider_manifests: &[ProviderManifest], ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -594,6 +636,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "module".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the worker against the loaded providers' manifests. + let sections = &loaded_manifest.manifest.extensions; + enforce_extension_sections(&module_namespace, sections, extensions)?; + for ext in extensions { + ext.admit_worker(&module_namespace, sections, provider_manifests) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // Compile + instantiate. info!(component = %entry.path.display(), "compiling component"); @@ -608,11 +665,6 @@ impl Supervisor { registry, ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "module".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; // Layer the manifest's `[module.resources]` over the engine `[limits]` // defaults: an unset override field keeps the engine default. let ResolvedLimits { @@ -761,6 +813,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, kinds: &ProviderKinds, + extensions: &[Arc>], ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -776,6 +829,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the provider's own sections. + let sections = loaded_manifest.manifest.extensions.clone(); + enforce_extension_sections(&namespace, §ions, extensions)?; + for ext in extensions { + ext.admit_provider(&namespace, §ions) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // The manifest kind is the discriminator: an [[adapters]] entry // must name a registered provider kind, caught here before @@ -822,11 +890,6 @@ impl Supervisor { ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let namespace = if loaded_manifest.manifest.module.name.is_empty() { - "provider".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; info!( provider = %namespace, kind = kind.kind(), @@ -870,6 +933,7 @@ impl Supervisor { linker: &linker, store, config: config.clone(), + sections: §ions, fuel_per_call: limits_cfg.fuel(), liveness: liveness.clone(), }, @@ -883,6 +947,7 @@ impl Supervisor { Ok(LoadedProvider { name: namespace, kind: kind.kind(), + sections, component, init_config: config, http_allow: entry.http_allow.clone(), @@ -1626,6 +1691,7 @@ impl Supervisor { linker: &linker, store, config: provider.init_config.clone(), + sections: &provider.sections, fuel_per_call: provider.fuel_per_call, liveness: provider.liveness.clone(), }, diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 6396ffe0..2617869f 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -28,6 +28,41 @@ fn manifest_resource_overrides_take_effect_and_are_field_local() { assert_eq!(resolved.state_bytes, 2048); } +/// A manifest section a wired extension claims passes; an unclaimed one +/// (a typo, or a section for an unwired extension) is refused. +#[test] +fn extension_sections_must_be_claimed() { + struct Claiming; + impl Extension for Claiming { + fn namespace(&self) -> &'static str { + "acme" + } + fn capabilities(&self) -> crate::manifest::NamespaceCaps { + crate::manifest::NamespaceCaps { + prefix: "acme:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn manifest_sections(&self) -> &'static [&'static str] { + &["venue"] + } + } + let extensions: Vec>> = vec![Arc::new(Claiming)]; + + let mut sections = manifest::ExtensionSections::new(); + sections.insert("venue".into(), toml::Value::Boolean(true)); + enforce_extension_sections("keeper", §ions, &extensions).expect("claimed section"); + + sections.insert("venu".into(), toml::Value::Boolean(true)); + let err = enforce_extension_sections("keeper", §ions, &extensions) + .expect_err("unclaimed section"); + assert!(err.to_string().contains("[venu]"), "{err}"); + assert!(err.to_string().contains("keeper"), "{err}"); +} + #[tokio::test] async fn empty_supervisor_returns_no_subscriptions() { let engine = make_wasmtime_engine(); diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index 1a7195b9..25364863 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -2,7 +2,8 @@ //! it into the component's `venue-adapter` world surface. //! //! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the five intent functions from `videre:venue/adapter`. +//! world itself, the intent functions and the body-version declaration +//! from `videre:venue/adapter`. //! Functions are associated (no `self`): the component model instantiates //! one adapter per venue and calls exports statically, so adapter state //! lives in the adapter's own statics, exactly as in event modules. @@ -21,6 +22,13 @@ pub trait VenueAdapter { /// boots both component kinds through the same machinery. fn init(config: Config) -> Result<(), Fault>; + /// Body-schema versions this adapter decodes. Install asserts it + /// equals the manifest `[venue] body_versions` set. Defaults to + /// declaring none. + fn body_versions() -> Vec { + Vec::new() + } + /// Project an opaque intent body onto the stable header guard /// policy runs on. Must be a pure derivation: no transport, no side /// effects, so the host can inspect a header before deciding to @@ -70,6 +78,10 @@ macro_rules! export_venue_adapter { impl $crate::bindings::exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { + fn body_versions() -> ::std::vec::Vec { + <$adapter as $crate::VenueAdapter>::body_versions() + } + fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml index a2d9ae30..8db90362 100644 --- a/crates/videre-host/Cargo.toml +++ b/crates/videre-host/Cargo.toml @@ -21,7 +21,9 @@ anyhow.workspace = true thiserror.workspace = true async-trait.workspace = true futures.workspace = true +serde.workspace = true tokio.workspace = true +toml.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/crates/videre-host/src/handshake.rs b/crates/videre-host/src/handshake.rs new file mode 100644 index 00000000..a1aa23d7 --- /dev/null +++ b/crates/videre-host/src/handshake.rs @@ -0,0 +1,285 @@ +//! Install-time body-version handshake over the `[venue]` manifest +//! section: a keeper declares the one body-schema version it encodes, +//! an adapter the set it decodes, and a keeper boots only when every +//! installed venue adapter decodes its version, since any of them is a +//! legal runtime submit target. + +use std::collections::BTreeSet; + +use anyhow::{anyhow, bail}; +use nexum_runtime::host::extension::ProviderManifest; +use nexum_runtime::manifest::ExtensionSections; +use serde::Deserialize; +use tracing::error; + +use crate::registry::VenueAdapterKind; + +/// The manifest section the videre platform claims. +pub(crate) const SECTION: &str = "venue"; + +/// The claimed-section list handed to the runtime. +pub(crate) const SECTIONS: &[&str] = &[SECTION]; + +/// Keeper-side `[venue]`: the one body-schema version it encodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KeeperSection { + body_version: u32, +} + +/// Adapter-side `[venue]`: the body-schema versions it decodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdapterSection { + body_versions: BTreeSet, +} + +/// Parse one `[venue]` value as `S`, tagging failures with the owner. +fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyhow::Result { + value + .clone() + .try_into() + .map_err(|e| anyhow!("{owner} [venue]: {e}")) +} + +/// Admit one provider: a `[venue]` section, when present, must be the +/// adapter shape with a non-empty version set. +pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let section: AdapterSection = parse(provider, value)?; + if section.body_versions.is_empty() { + bail!("{provider} [venue]: body_versions must not be empty"); + } + Ok(()) +} + +/// An adapter's declared decode set: its `[venue] body_versions`, empty +/// when the section is absent. +pub(crate) fn declared_versions( + provider: &str, + sections: &ExtensionSections, +) -> anyhow::Result> { + let Some(value) = sections.get(SECTION) else { + return Ok(BTreeSet::new()); + }; + let section: AdapterSection = parse(provider, value)?; + Ok(section.body_versions) +} + +/// Assert an adapter's `body-versions()` export equals its manifest +/// claim, refusing the install on divergence so the two sources of the +/// decode set cannot drift. +pub(crate) fn verify_exported_versions( + provider: &str, + declared: &BTreeSet, + exported: Vec, +) -> anyhow::Result<()> { + let exported: BTreeSet = exported.into_iter().collect(); + if exported != *declared { + bail!( + "{provider} exports body versions {exported:?}; the manifest [venue] \ + body_versions declares {declared:?}" + ); + } + Ok(()) +} + +/// The membership install predicate: a worker declaring `[venue] +/// body_version` is admitted only when every installed venue adapter's +/// `[venue] body_versions` set contains that version. Every installed +/// venue is a legal runtime submit target, so one non-decoding adapter +/// refuses the keeper. +pub(crate) fn admit_worker( + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], +) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let KeeperSection { body_version } = parse(worker, value)?; + let adapters: Vec<&ProviderManifest> = providers + .iter() + .filter(|p| p.kind == VenueAdapterKind::KIND) + .collect(); + if adapters.is_empty() { + return Err(refuse( + worker, + body_version, + "no venue adapter declares [venue] body_versions", + )); + } + for provider in adapters { + let Some(value) = provider.sections.get(SECTION) else { + return Err(refuse( + worker, + body_version, + &format!("{} declares no [venue] body_versions", provider.name), + )); + }; + let section: AdapterSection = parse(&provider.name, value)?; + if !section.body_versions.contains(&body_version) { + return Err(refuse( + worker, + body_version, + &format!("{} decodes {:?}", provider.name, section.body_versions), + )); + } + } + Ok(()) +} + +/// Log and build the refusal for one keeper/adapter pairing. +fn refuse(worker: &str, body_version: u32, decoded: &str) -> anyhow::Error { + error!( + keeper = %worker, + body_version, + %decoded, + "body-version handshake refused the keeper/adapter pair", + ); + anyhow!("keeper {worker} encodes body version {body_version}; {decoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sections(toml: &str) -> ExtensionSections { + let table: toml::Table = toml.parse().expect("parse"); + table.into_iter().collect() + } + + fn adapter(name: &str, toml: &str) -> ProviderManifest { + ProviderManifest { + name: name.to_owned(), + kind: VenueAdapterKind::KIND, + sections: sections(toml), + } + } + + /// A keeper whose version every installed adapter decodes is admitted. + #[test] + fn matching_pair_is_admitted() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [2, 3]"), + ]; + admit_worker("keeper", &keeper, &adapters).expect("admitted"); + } + + /// A keeper whose version no adapter decodes is refused, and the + /// refusal names the version and the declared set. + #[test] + fn mismatched_pair_is_refused() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [adapter("cow", "[venue]\nbody_versions = [1]")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("cow decodes {1}"), "{msg}"); + } + + /// Membership is a conjunction over the installed adapters: one + /// non-decoding adapter refuses the keeper even when another decodes + /// its version, since either is a legal runtime submit target. + #[test] + fn one_non_decoding_adapter_refuses_the_keeper() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [1]"), + ]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("uni decodes {1}"), "{msg}"); + } + + /// A declaring keeper with no installed venue adapter is refused. + #[test] + fn undeclared_adapters_refuse_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let err = admit_worker("keeper", &keeper, &[]).expect_err("refused"); + assert!(err.to_string().contains("no venue adapter declares")); + } + + /// An installed adapter without a `[venue]` section refuses a + /// declaring keeper: its decode set is undeclared, not universal. + #[test] + fn a_section_less_adapter_refuses_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let adapters = [adapter("cow", "")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + assert!(err.to_string().contains("cow declares no [venue]")); + } + + /// A provider of another kind never satisfies the membership check. + #[test] + fn other_provider_kinds_are_ignored() { + let keeper = sections("[venue]\nbody_version = 1"); + let mut other = adapter("oracle", "[venue]\nbody_versions = [1]"); + other.kind = "price-oracle"; + admit_worker("keeper", &keeper, &[other]).expect_err("refused"); + } + + /// Workers and providers without a `[venue]` section are admitted. + #[test] + fn undeclared_sections_are_admitted() { + admit_worker("keeper", &ExtensionSections::new(), &[]).expect("worker admitted"); + admit_provider("venue", &ExtensionSections::new()).expect("provider admitted"); + } + + /// The wrong-side spelling fails loudly on both faces. + #[test] + fn wrong_side_spelling_is_refused() { + let keeper = sections("[venue]\nbody_versions = [1]"); + admit_worker("keeper", &keeper, &[]).expect_err("keeper with the adapter key"); + + let venue = sections("[venue]\nbody_version = 1"); + admit_provider("venue", &venue).expect_err("adapter with the keeper key"); + } + + /// An adapter declaring an empty decode set is refused at install. + #[test] + fn empty_adapter_set_is_refused() { + let venue = sections("[venue]\nbody_versions = []"); + let err = admit_provider("venue", &venue).expect_err("refused"); + assert!(err.to_string().contains("must not be empty")); + } + + /// A well-formed adapter declaration is admitted. + #[test] + fn adapter_declaration_is_admitted() { + let venue = sections("[venue]\nbody_versions = [1, 2]"); + admit_provider("venue", &venue).expect("admitted"); + } + + /// The exported set must equal the manifest claim exactly; either + /// direction of drift refuses the install. + #[test] + fn exported_versions_must_equal_the_manifest_claim() { + let declared = declared_versions("venue", §ions("[venue]\nbody_versions = [1, 2]")) + .expect("declared"); + verify_exported_versions("venue", &declared, vec![2, 1]).expect("equal sets"); + + let err = verify_exported_versions("venue", &declared, vec![1]).expect_err("narrower"); + assert!( + err.to_string().contains("exports body versions {1}"), + "{err}" + ); + verify_exported_versions("venue", &declared, vec![1, 2, 3]).expect_err("wider"); + } + + /// A section-less adapter must export an empty set: an undeclared + /// manifest with a declaring export is drift, not a default. + #[test] + fn a_section_less_adapter_must_export_no_versions() { + let declared = declared_versions("venue", &ExtensionSections::new()).expect("declared"); + assert!(declared.is_empty()); + verify_exported_versions("venue", &declared, Vec::new()).expect("both undeclared"); + verify_exported_versions("venue", &declared, vec![1]).expect_err("export-only drift"); + } +} diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index fcd715bb..d228e27a 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -8,6 +8,7 @@ pub mod bindings; mod client; +mod handshake; mod registry; use std::sync::Arc; @@ -18,9 +19,10 @@ use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::RuntimeTypes; use nexum_runtime::host::extension::{ EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, + ProviderManifest, }; use nexum_runtime::host::state::HostState; -use nexum_runtime::manifest::NamespaceCaps; +use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; use tokio::sync::mpsc; use wasmtime::component::{HasSelf, Linker}; @@ -94,6 +96,28 @@ impl Extension for Videre { Some(Box::new(VenueAdapterKind)) } + fn manifest_sections(&self) -> &'static [&'static str] { + handshake::SECTIONS + } + + /// Adapter-side handshake shape check: a `[venue]` section must + /// declare a non-empty `body_versions` set. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + handshake::admit_provider(provider, sections) + } + + /// The body-version membership predicate: a keeper declaring + /// `[venue] body_version` boots only when every installed venue + /// adapter's `[venue] body_versions` set contains it. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + handshake::admit_worker(worker, sections, providers) + } + fn subscriptions(&self) -> &'static [&'static str] { &[INTENT_STATUS_KIND] } diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index 43f558f8..84da2630 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -691,6 +691,7 @@ impl ProviderKind for VenueAdapterKind { linker, mut store, config, + sections, fuel_per_call, liveness, } = instance; @@ -700,6 +701,18 @@ impl ProviderKind for VenueAdapterKind { .context("instantiate adapter")?; // The venue id is the adapter's namespace: its manifest name. let venue_id = VenueId::from(&*store.data().run.module); + // The manifest `[venue] body_versions` is the install-time + // authority the keeper handshake reads; the export must agree, + // so a manifest claiming versions the code does not decode never + // installs. + let declared = crate::handshake::declared_versions(venue_id.as_str(), sections)?; + let exported = bindings + .videre_venue_adapter() + .call_body_versions(&mut store) + .await + .map_err(anyhow::Error::from) + .context("read adapter body-versions")?; + crate::handshake::verify_exported_versions(venue_id.as_str(), &declared, exported)?; match bindings .call_init(&mut store, &config) .await diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 05bc062e..462eae22 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -580,6 +580,132 @@ async fn e2e_echo_module_registry_adapter_round_trip() { ); } +/// The body-version handshake refuses a mismatched pair: an adapter +/// decoding only v1 against a keeper encoding v2 fails the boot at the +/// keeper's install, before instantiation, naming both sides' versions. +#[tokio::test] +async fn e2e_mismatched_body_versions_refuse_the_pair_at_boot() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1] +"#, + ) + .expect("write adapter manifest"); + let keeper_manifest = dir.path().join("echo-client.toml"); + std::fs::write( + &keeper_manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[venue] +body_version = 2 +"#, + ) + .expect("write keeper manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(keeper_manifest), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("mismatched pair must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("body version 2"), "{chain}"); + assert!(chain.contains("echo-venue decodes {1}"), "{chain}"); +} + +/// An adapter whose manifest claims versions its code does not decode +/// fails its own install: the `body-versions()` export must equal the +/// manifest `[venue] body_versions` set. +#[tokio::test] +async fn e2e_manifest_export_divergence_refuses_the_adapter_at_boot() { + let Some(adapter_wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1, 2] +"#, + ) + .expect("write adapter manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("a diverging adapter must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("exports body versions {1}"), "{chain}"); + assert!(chain.contains("declares {1, 2}"), "{chain}"); +} + // ── venue-adapter trap recovery ─────────────────────────────────────── /// Boot one flaky-venue adapter over the mock chain, whose head starts at diff --git a/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml index 4b1df513..9dee6673 100644 --- a/modules/examples/echo-client/module.toml +++ b/modules/examples/echo-client/module.toml @@ -30,3 +30,9 @@ venue = "echo-venue" [config] name = "echo-client" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml index f57607ca..63f9a7a2 100644 --- a/modules/examples/echo-venue/module.toml +++ b/modules/examples/echo-venue/module.toml @@ -22,3 +22,8 @@ allow = [] [config] name = "echo-venue" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 92b0e62c..9af90589 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -32,6 +32,12 @@ impl EchoVenue { Ok(()) } + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + fn derive_header(body: Vec) -> Result { // The echo venue gives back exactly the bytes handed to it, so the // header's `gives` amount is the body length: enough to exercise diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 1f01b9cc..33e73f10 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -17,6 +17,10 @@ interface client { interface adapter { use videre:types/types@0.1.0.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + /// Body-schema versions this adapter decodes. Must equal the + /// manifest `[venue] body_versions` set; install asserts it. + body-versions: func() -> list; + /// Pure: derive the guard-facing header from a body. No I/O. derive-header: func(body: list) -> result; quote: func(body: list) -> result; From df0bad837551ad4a0cec85db509535e8e65aab8c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 02:30:04 +0000 Subject: [PATCH 26/53] ci: align the zero-leak check to the charter symbol set Scan the runtime sources for the charter set only (nexum:intent, value-flow, VenueAdapter, synthesize_venue, nexum:adapter, PoolRouter), dropping the loose word-shape sweep that false-flagged the opaque extension-map fixtures; keep the crate-graph and WIT-leaf checks, and state the invariant in the nexum-runtime crate charter. The CI job stays advisory until the physical repo cut. --- .github/workflows/ci.yml | 8 ++++---- crates/nexum-runtime/src/lib.rs | 5 +++++ scripts/check-venue-agnostic.sh | 22 ++++++++++++---------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42cc34c0..debe6ad1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,10 +144,10 @@ jobs: AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu - # Advisory guard that nexum-runtime stays venue-agnostic: crate graph, symbol - # scan, and nexum:host WIT leaf-ness (scripts/check-venue-agnostic.sh). - # `continue-on-error` keeps this a signal, not a gate, until the physical - # host cut lands; the flip to a blocking gate is tracked for M2. + # Advisory zero-leak guard that nexum-runtime stays venue-agnostic: crate + # graph, charter symbol scan, and nexum:host WIT leaf-ness + # (scripts/check-venue-agnostic.sh). `continue-on-error` keeps this a + # signal, not a gate; it flips to a required check at the physical repo cut. venue-agnostic: name: venue-agnostic (advisory) runs-on: ubuntu-latest diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index 1d200b90..b4cccbdd 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -1,6 +1,11 @@ //! Nexum runtime: a wasmtime-based host for WASM Component Model //! modules, usable as an embeddable library. The bundled binary is a //! thin consumer of the same public surface. +//! +//! Zero-leak charter: this crate is settlement-domain-agnostic. It +//! carries no domain symbol or WIT reference, `nexum:host` stays a +//! leaf WIT package, and no crate edge reaches a domain crate. The +//! zero-leak script under `scripts/` enforces this in CI. #![cfg_attr(not(test), warn(unused_crate_dependencies))] diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index 26d60d3a..86da679f 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# Venue-agnosticism check for the host layer: no host-layer crate graph +# Zero-leak check for the host layer: no host-layer crate graph # (runtime, launcher, bare engine) reaches a videre/intent/venue/cow -# crate, the runtime sources carry no venue symbol, and nexum:host -# resolves as a leaf WIT package. Advisory in CI until the physical cut -# lands; run locally via `just check-venue-agnostic`. +# crate, the runtime sources carry no charter symbol +# (nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter), +# and nexum:host resolves as a leaf WIT package. Advisory in CI until +# the physical cut lands; run locally via `just check-venue-agnostic`. set -uo pipefail @@ -34,14 +35,15 @@ for crate in nexum-runtime nexum-launch nexum-cli; do fi done -# 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes -# skip std::borrow::Cow, ProviderError, and "intentional". -symbols='\b[Vv]idere|\b[Ii]ntent([_A-Z-]|s?\b)|\b[Vv]enue|\bcow|CoW|\bCow[A-Z]' -rg -n --no-heading -e "$symbols" crates/nexum-runtime +# 2. Symbol scan: the charter set (forbidden WIT namespaces, the old +# router and adapter names). Section 1 guards dependency edges; this +# scan stays curated so opaque extension payloads never false-flag. +charter='nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter' +rg -n --no-heading -e "$charter" crates/nexum-runtime/src case $? in - 0) fail "venue symbols leak into nexum-runtime" ;; + 0) fail "charter symbols leak into nexum-runtime" ;; 1) pass "symbol scan empty" ;; - *) fail "symbol scan errored (crates/nexum-runtime missing?)" ;; + *) fail "symbol scan errored (crates/nexum-runtime/src missing?)" ;; esac # 3. WIT DAG: nexum:host is a leaf. No cross-package use/import, and the From 16a67247240ba1382a2c6f1689072c36b6d55d98 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 03:57:55 +0000 Subject: [PATCH 27/53] ci: flip the zero-leak gate to blocking with the echo-venue boot oracle Drop continue-on-error from the venue-agnostic job, add privileged-field and host-WIT namespace scans to the zero-leak script, and pin the invariant with two integration tests: the echo venue boots and routes a worker's submission purely through the generic extension seam, and the nexum-runtime crate graph names no venue-shaped crate. A missing wasm fixture hard-fails the boot oracle under CI so the gate cannot skip itself. --- .github/workflows/ci.yml | 11 +- crates/videre-host/tests/zero_leak.rs | 155 ++++++++++++++++++++++++++ justfile | 5 +- scripts/check-venue-agnostic.sh | 36 ++++-- 4 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 crates/videre-host/tests/zero_leak.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index debe6ad1..6207c455 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,14 +144,13 @@ jobs: AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu - # Advisory zero-leak guard that nexum-runtime stays venue-agnostic: crate - # graph, charter symbol scan, and nexum:host WIT leaf-ness - # (scripts/check-venue-agnostic.sh). `continue-on-error` keeps this a - # signal, not a gate; it flips to a required check at the physical repo cut. + # Blocking zero-leak gate: host-layer crate graphs stay venue-free, the + # runtime Rust sources carry no charter symbol and no privileged router + # field, and nexum:host names no foreign WIT package and resolves as a + # leaf (scripts/check-venue-agnostic.sh). venue-agnostic: - name: venue-agnostic (advisory) + name: venue-agnostic runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: ./.github/actions/rust-setup diff --git a/crates/videre-host/tests/zero_leak.rs b/crates/videre-host/tests/zero_leak.rs new file mode 100644 index 00000000..43f32ea5 --- /dev/null +++ b/crates/videre-host/tests/zero_leak.rs @@ -0,0 +1,155 @@ +//! Zero-leak oracle: the host boots the echo venue and routes a worker's +//! submission purely through the generic extension seam, while the +//! `nexum-runtime` crate graph reaches no venue-shaped crate. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::supervisor::{Supervisor, build_linker}; +use nexum_runtime::test_utils::{ + MockChainProvider, MockStateStore, MockTypes, mock_components_from, +}; +use videre_host::{VenueRegistry, platform}; + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir. +/// A missing artefact is a hard failure under CI (the gate may not skip +/// itself) and a soft skip locally. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + return Some(p); + } + assert!( + std::env::var_os("CI").is_none(), + "{} must be prebuilt in CI", + p.display() + ); + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None +} + +/// The boot oracle: the venue adapter installs and a worker's submission +/// reaches it, with the platform supplied only as a generic extension. +#[tokio::test] +async fn e2e_echo_venue_boots_and_submits_through_the_generic_seam() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = mock_components_from(chain, MockStateStore::new()); + + let mut engine_config = wasmtime::Config::new(); + engine_config.wasm_component_model(true); + engine_config.consume_fuel(true); + let engine = wasmtime::Engine::new(&engine_config).expect("wasmtime engine"); + + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/examples/echo-client/module.toml")), + }], + ..Default::default() + }; + let extensions: Vec>> = vec![Arc::new(platform(&config))]; + let linker = build_linker::(&engine, &extensions).expect("build_linker"); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "echo-venue installed"); + assert_eq!(supervisor.alive_count(), 1, "echo-client alive"); + + // One block drives the worker's on_block submission; the registry the + // extension published on the service map observes the accepted receipt. + let block = nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + assert_eq!(supervisor.dispatch_block(block).await, 1); + let registry = supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service"); + let updates = registry.poll_status_transitions().await; + assert!( + updates.iter().any(|u| u.venue == "echo-venue"), + "the submission reached the venue; updates were: {updates:?}" + ); +} + +/// The graph oracle: `cargo tree` for the host crate (normal + build +/// edges) names no videre, intent, venue, or cow crate. +#[test] +fn host_crate_graph_reaches_no_venue_shaped_crate() { + let output = Command::new(env!("CARGO")) + .args([ + "tree", + "-p", + "nexum-runtime", + "-e", + "normal,build", + "--all-features", + "--prefix", + "none", + "--locked", + ]) + .current_dir(workspace_path("")) + .output() + .expect("cargo tree runs"); + assert!( + output.status.success(), + "cargo tree failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let tree = String::from_utf8_lossy(&output.stdout); + let reached: Vec<&str> = tree + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter(|name| { + let name = name.to_lowercase(); + ["videre", "intent", "venue", "cow"] + .iter() + .any(|word| name.contains(word)) + }) + .collect(); + assert!( + reached.is_empty(), + "venue-shaped crates reached: {reached:?}" + ); +} diff --git a/justfile b/justfile index de542480..270a8248 100644 --- a/justfile +++ b/justfile @@ -72,8 +72,9 @@ build-e2e: build-m2 build-m3 run-e2e: build-e2e build-engine cargo run -p shepherd -- --engine-config engine.e2e.toml -# Assert nexum-runtime is venue-agnostic: crate graph, symbol scan, and -# the nexum:host WIT leaf. Advisory in CI until the physical cut lands. +# Zero-leak gate: host-layer crate graphs, runtime charter-symbol and +# router-field scans, and the nexum:host WIT leaf and foreign-namespace +# scans. Blocking in CI. check-venue-agnostic: ./scripts/check-venue-agnostic.sh diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index 86da679f..407006c9 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash -# Zero-leak check for the host layer: no host-layer crate graph -# (runtime, launcher, bare engine) reaches a videre/intent/venue/cow -# crate, the runtime sources carry no charter symbol -# (nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter), -# and nexum:host resolves as a leaf WIT package. Advisory in CI until -# the physical cut lands; run locally via `just check-venue-agnostic`. +# Zero-leak check for the host layer, scoped precisely: no host-layer +# crate graph (runtime, launcher, bare engine) reaches a +# videre/intent/venue/cow crate; the runtime Rust sources carry no +# charter symbol +# (nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter) +# and no privileged router field; and nexum:host names no foreign WIT +# package and resolves as a leaf. The opaque-status envelope +# (intent-status-update, its venue id string) is ratified host surface, +# not a leak. Blocking in CI; run locally via `just check-venue-agnostic`. set -uo pipefail @@ -46,8 +49,25 @@ case $? in *) fail "symbol scan errored (crates/nexum-runtime/src missing?)" ;; esac -# 3. WIT DAG: nexum:host is a leaf. No cross-package use/import, and the -# package resolves standalone. +# 3. Privileged-field scan: the venue registry rides the extension +# service map; no router field may return to the runtime. +rg -n --no-heading -e 'venue_registry|pool_router' crates/nexum-runtime/src +case $? in + 0) fail "a privileged router field returned to nexum-runtime" ;; + 1) pass "no privileged router field" ;; + *) fail "field scan errored (crates/nexum-runtime/src missing?)" ;; +esac + +# 4. WIT surface: nexum:host is a leaf. No foreign package named +# anywhere in its sources, no cross-package use/import, and the +# package resolves standalone. The opaque-status envelope stays. +wit_charter='nexum:intent|nexum:adapter|value-flow|videre:|shepherd:cow' +rg -n --no-heading -e "$wit_charter" wit/nexum-host +case $? in + 0) fail "a foreign WIT namespace leaks into wit/nexum-host" ;; + 1) pass "no foreign WIT namespace named" ;; + *) fail "WIT namespace scan errored (wit/nexum-host missing?)" ;; +esac rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host case $? in 0) fail "nexum:host references another WIT package" ;; From 446ee89d1e7e2af106e8ed6fa706d523464202cc Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 05:05:27 +0000 Subject: [PATCH 28/53] sdk: make the IntentBody derive no_std The derive reaches Vec and ToString through the venue SDK's __private alloc re-export instead of ::std, so a #![no_std] consumer needs no extern crate alloc. cow-venue flips to no_std (tests aside) and a compile-only no-std-probe crate keeps the contract under the workspace gate. --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + crates/cow-venue/src/composable.rs | 2 ++ crates/cow-venue/src/lib.rs | 11 +++++++---- crates/nexum-macros/src/intent_body.rs | 14 +++++++++----- crates/nexum-venue-sdk/src/body.rs | 5 ++++- crates/no-std-probe/Cargo.toml | 18 ++++++++++++++++++ crates/no-std-probe/src/lib.rs | 14 ++++++++++++++ 8 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 crates/no-std-probe/Cargo.toml create mode 100644 crates/no-std-probe/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 99a57b72..c0d10b14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3722,6 +3722,13 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "no-std-probe" +version = "0.1.0" +dependencies = [ + "nexum-venue-sdk", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 0d85dead..6b11a451 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/nexum-venue-sdk", "crates/nexum-venue-test", "crates/nexum-world", + "crates/no-std-probe", "crates/shepherd", "crates/shepherd-backtest", "crates/shepherd-cow-host", diff --git a/crates/cow-venue/src/composable.rs b/crates/cow-venue/src/composable.rs index c3535865..5e7a94a4 100644 --- a/crates/cow-venue/src/composable.rs +++ b/crates/cow-venue/src/composable.rs @@ -8,6 +8,8 @@ //! `static_input` is opaque to the venue; only the named handler parses //! it, so this crate never inspects its bytes. +use alloc::vec::Vec; + use borsh::{BorshDeserialize, BorshSerialize}; use crate::order::Address; diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 1fa8a521..230f6779 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -9,10 +9,9 @@ //! venue SDK (for the [`IntentBody`](nexum_venue_sdk::IntentBody) derive) //! and borsh, so a venue adapter component or a strategy module can carry //! the body types and codec without dragging in the host-side CoW -//! machinery. The one non-obvious constraint: the derive's generated code -//! names `::std`, so the slice links std and is not a bare-metal -//! `#![no_std]` crate; it is guest-consumable on the runtime's -//! std-bearing wasm target rather than target-free. +//! machinery. The crate is `#![no_std]` (tests aside): the derive's +//! generated code reaches `alloc` through the venue SDK re-export, never +//! `::std`. //! //! With `--no-default-features` the slice drops out entirely and the //! crate compiles empty, so a consumer can depend on a future slice @@ -26,9 +25,13 @@ //! so an adapter or a module that wants only the body types stays //! dependency-light. +#![cfg_attr(not(test), no_std)] #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] +#[cfg(feature = "body")] +extern crate alloc; + #[cfg(feature = "body")] pub mod body; diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/nexum-macros/src/intent_body.rs index 79b074cd..cc8fe54b 100644 --- a/crates/nexum-macros/src/intent_body.rs +++ b/crates/nexum-macros/src/intent_body.rs @@ -12,7 +12,9 @@ //! //! Generated code names the venue SDK by its crate path //! (`::nexum_venue_sdk`), so the derive is only usable through that -//! crate's re-export. +//! crate's re-export. The expansion names only `::core` and the SDK's +//! `__private` re-exports (borsh, `alloc`), so a `#![no_std]` consumer +//! needs no `extern crate alloc`. use proc_macro2::TokenStream; use quote::quote; @@ -80,12 +82,12 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { encode_arms.push(quote! { Self::#ident(payload) => { - let mut out = ::std::vec::Vec::new(); + let mut out = ::nexum_venue_sdk::body::__private::alloc::vec::Vec::new(); out.push(#tag); ::nexum_venue_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( |err| ::nexum_venue_sdk::body::BodyError::Encode { version: #tag, - detail: ::std::string::ToString::to_string(&err), + detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string(&err), }, )?; ::core::result::Result::Ok(out) @@ -96,7 +98,9 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { ::nexum_venue_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) .map_err(|err| ::nexum_venue_sdk::body::BodyError::Malformed { version: #tag, - detail: ::std::string::ToString::to_string(&err), + detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string( + &err, + ), })?, )), }); @@ -108,7 +112,7 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { fn to_bytes( &self, ) -> ::core::result::Result< - ::std::vec::Vec, + ::nexum_venue_sdk::body::__private::alloc::vec::Vec, ::nexum_venue_sdk::body::BodyError, > { match self { diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/nexum-venue-sdk/src/body.rs index 1f202ce2..58a64e12 100644 --- a/crates/nexum-venue-sdk/src/body.rs +++ b/crates/nexum-venue-sdk/src/body.rs @@ -86,8 +86,11 @@ impl From for VenueError { } /// Re-exports for `#[derive(IntentBody)]` generated code only; not a -/// public surface. +/// public surface. `alloc` rides along so the expansion resolves in a +/// `#![no_std]` consumer without its own `extern crate alloc`. #[doc(hidden)] pub mod __private { + pub extern crate alloc; + pub use borsh; } diff --git a/crates/no-std-probe/Cargo.toml b/crates/no-std-probe/Cargo.toml new file mode 100644 index 00000000..88fb4444 --- /dev/null +++ b/crates/no-std-probe/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "no-std-probe" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Compile-only #![no_std] probe: the IntentBody derive must expand without the consumer's std prelude." + +[lib] +# Never shipped. Living in the workspace keeps the derive's no_std +# contract under the workspace check/clippy gate. + +[lints] +workspace = true + +[dependencies] +# Source of the `IntentBody` derive and trait the probe enum implements. +nexum-venue-sdk = { path = "../nexum-venue-sdk" } diff --git a/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs new file mode 100644 index 00000000..2b91da39 --- /dev/null +++ b/crates/no-std-probe/src/lib.rs @@ -0,0 +1,14 @@ +//! Compile-only `#![no_std]` probe: `#[derive(IntentBody)]` must expand +//! without the consumer's std prelude or an `extern crate alloc`. + +#![no_std] +#![warn(missing_docs)] + +use nexum_venue_sdk::IntentBody; + +/// The probe schema: one published version over a bare byte payload. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +pub enum ProbeBody { + /// First published version. + V1(u8), +} From eb84355e29672c61ce0bea08a1426cf1053ba92e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 05:02:24 +0000 Subject: [PATCH 29/53] sdk: emit the bind-macro fault and level conversions as From impls Replace the convert_fault / sdk_fault_into_wit / convert_level shim functions with From impls emitted by the macro (orphan-legal in the per-cdylib expansion), so module glue converts via ? and Into. No behaviour change. --- crates/nexum-sdk/src/wit_bindgen_macro.rs | 128 ++++++++++--------- crates/shepherd-sdk/src/wit_bindgen_macro.rs | 4 +- docs/05-sdk-design.md | 4 +- docs/tutorial-first-module.md | 10 +- modules/ethflow-watcher/src/lib.rs | 5 +- modules/examples/balance-tracker/src/lib.rs | 6 +- modules/examples/http-probe/src/lib.rs | 7 +- modules/examples/price-alert/src/lib.rs | 7 +- modules/examples/stop-loss/src/lib.rs | 6 +- modules/twap-monitor/src/lib.rs | 6 +- 10 files changed, 94 insertions(+), 89 deletions(-) diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 25a91376..6ac615e4 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -3,10 +3,9 @@ //! //! Before this macro existed, each module hand-rolled ~80 lines of //! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus -//! `convert_chain_err` / `convert_fault` / `sdk_fault_into_wit` / -//! `convert_level`. The code differed across modules in zero places -//! that were not bugs. +//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus the fault, +//! chain-error, and level conversions. The code differed across modules +//! in zero places that were not bugs. //! //! The adapter is capability-selected: the `caps: [...]` form emits //! only the pieces backed by the module's declared capabilities @@ -32,10 +31,10 @@ //! // or, capability-selected: //! // nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); //! -//! // `WitBindgenHost`, `convert_fault`, and `sdk_fault_into_wit` are -//! // now in scope, plus per selected capability: `convert_chain_err` -//! // (chain), the `LocalStoreHost` impl (local_store), and -//! // `convert_level`, `HostLogSink`, and `install_tracing` +//! // `WitBindgenHost` and the `Fault` `From` impls (both directions) +//! // are now in scope, plus per selected capability: `convert_chain_err` +//! // (chain), the `LocalStoreHost` impl (local_store), and the +//! // `Level` `From` impl, `HostLogSink`, and `install_tracing` //! // (logging), with the wit-bindgen and SDK types tied together //! // through identifier resolution. Call `install_tracing()` once at //! // the top of `Guest::init` to route `tracing::info!(...)` to the @@ -45,12 +44,15 @@ //! ``` /// Generate `WitBindgenHost` + the `*Host` trait impls + the error / -/// level converters for the selected capabilities. See module docs. +/// level `From` impls for the selected capabilities. See module docs. +/// +/// The fault and level conversions are `From` impls: orphan-legal here +/// because the wit-bindgen types are local to the expanding cdylib. /// /// Macro hygiene note: `macro_rules!` is not hygienic for type names /// or function items, so the names `WitBindgenHost`, `convert_chain_err`, -/// `convert_fault`, `sdk_fault_into_wit`, `convert_level`, `HostLogSink`, -/// and `install_tracing` are intentionally visible in the caller's scope. +/// `HostLogSink`, and `install_tracing` are intentionally visible in the +/// caller's scope. #[macro_export] macro_rules! bind_host_via_wit_bindgen { // Blanket-world form: every core interface is in scope, emit the @@ -71,46 +73,51 @@ macro_rules! bind_host_via_wit_bindgen { /// Lift the wit-bindgen `types.fault` (per-cdylib) into the /// SDK's `Fault`. Exhaustive on the seven-case vocabulary; the /// `rate-limited` backoff record maps field for field. - fn convert_fault(f: nexum::host::types::Fault) -> $crate::host::Fault { - match f { - nexum::host::types::Fault::Unsupported(s) => $crate::host::Fault::Unsupported(s), - nexum::host::types::Fault::Unavailable(s) => $crate::host::Fault::Unavailable(s), - nexum::host::types::Fault::Denied(s) => $crate::host::Fault::Denied(s), - nexum::host::types::Fault::RateLimited(rl) => { - $crate::host::Fault::RateLimited($crate::host::RateLimit { - retry_after_ms: rl.retry_after_ms, - }) + impl ::core::convert::From for $crate::host::Fault { + fn from(f: nexum::host::types::Fault) -> Self { + match f { + nexum::host::types::Fault::Unsupported(s) => Self::Unsupported(s), + nexum::host::types::Fault::Unavailable(s) => Self::Unavailable(s), + nexum::host::types::Fault::Denied(s) => Self::Denied(s), + nexum::host::types::Fault::RateLimited(rl) => { + Self::RateLimited($crate::host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + nexum::host::types::Fault::Timeout => Self::Timeout, + nexum::host::types::Fault::InvalidInput(s) => Self::InvalidInput(s), + nexum::host::types::Fault::Internal(s) => Self::Internal(s), } - nexum::host::types::Fault::Timeout => $crate::host::Fault::Timeout, - nexum::host::types::Fault::InvalidInput(s) => $crate::host::Fault::InvalidInput(s), - nexum::host::types::Fault::Internal(s) => $crate::host::Fault::Internal(s), } } - /// Reverse direction: lower the SDK [`Fault`]( - /// $crate::host::Fault) back into the per-cdylib wit-bindgen - /// `Fault` so `Guest::init` / `Guest::on_event` can return what - /// the export signature expects. + /// Reverse direction: lower the SDK `Fault` back into the + /// per-cdylib wit-bindgen `Fault` so `Guest::init` / + /// `Guest::on_event` can return what the export signature + /// expects (`?` applies it). /// /// Carries a wildcard arm because `$crate::host::Fault` is /// `#[non_exhaustive]`: a future SDK-side case must compile in /// module crates without source changes. Falls back to /// `internal` carrying the `Display` detail. - fn sdk_fault_into_wit(f: $crate::host::Fault) -> nexum::host::types::Fault { - use nexum::host::types::{Fault as WitFault, RateLimit as WitRateLimit}; - match f { - $crate::host::Fault::Unsupported(s) => WitFault::Unsupported(s), - $crate::host::Fault::Unavailable(s) => WitFault::Unavailable(s), - $crate::host::Fault::Denied(s) => WitFault::Denied(s), - $crate::host::Fault::RateLimited(rl) => WitFault::RateLimited(WitRateLimit { - retry_after_ms: rl.retry_after_ms, - }), - $crate::host::Fault::Timeout => WitFault::Timeout, - $crate::host::Fault::InvalidInput(s) => WitFault::InvalidInput(s), - $crate::host::Fault::Internal(s) => WitFault::Internal(s), - // `$crate::host::Fault` is `#[non_exhaustive]`; a future - // SDK case lands here as `internal`. - other => WitFault::Internal(::std::string::ToString::to_string(&other)), + impl ::core::convert::From<$crate::host::Fault> for nexum::host::types::Fault { + fn from(f: $crate::host::Fault) -> Self { + match f { + $crate::host::Fault::Unsupported(s) => Self::Unsupported(s), + $crate::host::Fault::Unavailable(s) => Self::Unavailable(s), + $crate::host::Fault::Denied(s) => Self::Denied(s), + $crate::host::Fault::RateLimited(rl) => { + Self::RateLimited(nexum::host::types::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + $crate::host::Fault::Timeout => Self::Timeout, + $crate::host::Fault::InvalidInput(s) => Self::InvalidInput(s), + $crate::host::Fault::Internal(s) => Self::Internal(s), + // `$crate::host::Fault` is `#[non_exhaustive]`; a + // future SDK case lands here as `internal`. + other => Self::Internal(::std::string::ToString::to_string(&other)), + } } } @@ -163,7 +170,7 @@ macro_rules! __bind_host_cap_via_wit_bindgen { fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { match e { nexum::host::chain::ChainError::Fault(f) => { - $crate::host::ChainError::Fault(convert_fault(f)) + $crate::host::ChainError::Fault(::core::convert::Into::into(f)) } nexum::host::chain::ChainError::Rpc(r) => { $crate::host::ChainError::Rpc($crate::host::RpcError { @@ -184,31 +191,31 @@ macro_rules! __bind_host_cap_via_wit_bindgen { ::core::option::Option<::std::vec::Vec>, $crate::host::Fault, > { - nexum::host::local_store::get(key).map_err(convert_fault) + nexum::host::local_store::get(key).map_err($crate::host::Fault::from) } fn set( &self, key: &str, value: &[u8], ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::set(key, value).map_err(convert_fault) + nexum::host::local_store::set(key, value).map_err($crate::host::Fault::from) } fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::delete(key).map_err(convert_fault) + nexum::host::local_store::delete(key).map_err($crate::host::Fault::from) } fn list_keys( &self, prefix: &str, ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> { - nexum::host::local_store::list_keys(prefix).map_err(convert_fault) + nexum::host::local_store::list_keys(prefix).map_err($crate::host::Fault::from) } } }; (logging) => { impl $crate::host::LoggingHost for WitBindgenHost { fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(convert_level(level), message); + nexum::host::logging::log(nexum::host::logging::Level::from(level), message); } } @@ -216,18 +223,19 @@ macro_rules! __bind_host_cap_via_wit_bindgen { /// `logging::Level` wire enum. `Level` is a set of associated /// consts, not a matchable enum, so compare rather than match; /// the five tiers are total, so the final arm is `Trace`. - fn convert_level(level: $crate::Level) -> nexum::host::logging::Level { - use $crate::Level; - if level == Level::ERROR { - nexum::host::logging::Level::Error - } else if level == Level::WARN { - nexum::host::logging::Level::Warn - } else if level == Level::INFO { - nexum::host::logging::Level::Info - } else if level == Level::DEBUG { - nexum::host::logging::Level::Debug - } else { - nexum::host::logging::Level::Trace + impl ::core::convert::From<$crate::Level> for nexum::host::logging::Level { + fn from(level: $crate::Level) -> Self { + if level == $crate::Level::ERROR { + Self::Error + } else if level == $crate::Level::WARN { + Self::Warn + } else if level == $crate::Level::INFO { + Self::Info + } else if level == $crate::Level::DEBUG { + Self::Debug + } else { + Self::Trace + } } } diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs index 0a4c9b14..fa7060d2 100644 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ b/crates/shepherd-sdk/src/wit_bindgen_macro.rs @@ -3,7 +3,7 @@ //! //! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the //! core adapter (`WitBindgenHost`, the `ChainHost` / `LocalStoreHost` -//! / `LoggingHost` impls, the error and level converters, and the +//! / `LoggingHost` impls, the fault and level `From` impls, and the //! tracing wiring). This macro invokes it and adds the //! [`CowApiHost`](crate::cow::CowApiHost) impl over the //! `shepherd:cow/cow-api` import shims. @@ -37,7 +37,7 @@ macro_rules! bind_cow_host_via_wit_bindgen { fn convert_cow_err(e: shepherd::cow::cow_api::CowApiError) -> $crate::cow::CowApiError { match e { shepherd::cow::cow_api::CowApiError::Fault(f) => { - $crate::cow::CowApiError::Fault(convert_fault(f)) + $crate::cow::CowApiError::Fault(::core::convert::Into::into(f)) } shepherd::cow::cow_api::CowApiError::Http(h) => { $crate::cow::CowApiError::Http($crate::cow::HttpFailure { diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 60594ad3..c5e4d812 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -176,14 +176,14 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; // ... Ok(()) } fn on_block(block: types::Block) -> Result<(), Fault> { strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) - .map_err(sdk_fault_into_wit) + .map_err(Into::into) } } ``` diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index 9c4c5604..6dc7d2ae 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -304,8 +304,8 @@ use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `convert_fault`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source +// `WitBindgenHost`, the fault and level `From` impls, `HostLogSink`, +// and `install_tracing` are generated below. Single source // of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -316,7 +316,7 @@ struct StopLoss; impl Guest for StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -333,7 +333,7 @@ impl Guest for StopLoss { return Ok(()); }; if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; } Ok(()) } @@ -344,7 +344,7 @@ export!(StopLoss); The macro generates `WitBindgenHost`, the `ChainHost` / `LocalStoreHost` / `LoggingHost` / `CowApiHost` impls, the -`Fault` conversions (`convert_fault`, `sdk_fault_into_wit`), and +`Fault` `From` impls (both directions), and `install_tracing`, which installs the guest `tracing` facade so the `tracing::info!`, `warn!`, and `error!` macros reach the host log call with no `Host` value to thread through. Call it once at diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 21fd3d1c..8cca9f36 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -47,7 +47,7 @@ wit_bindgen::generate!({ pub mod strategy; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` +// `WitBindgenHost` and the fault and level `From` impls // are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. // Gated on `wasm32` so the strategy can be reused in native targets // (e.g. the backtest replay harness in `crates/shepherd-backtest`). @@ -72,8 +72,7 @@ impl Guest for EthFlowWatcher { if let types::Event::ChainLogs(batch) = event { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs) - .map_err(sdk_fault_into_wit)?; + strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs)?; } // Block / Tick / Message are not used by this module. Ok(()) diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 263a7bcd..7b89ece6 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -35,7 +35,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the // wit-bindgen call and the `Guest`/`export!` glue. struct BalanceTracker; @@ -44,7 +44,7 @@ struct BalanceTracker; impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "balance-tracker init: {} addresses, threshold={} wei", cfg.addresses.len(), @@ -58,6 +58,6 @@ impl BalanceTracker { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) } } diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index c48377b1..672f0001 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -43,7 +43,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `sdk_fault_into_wit` and `install_tracing` (used by the handlers) are +// The fault `From` impls and `install_tracing` (used by the handlers) are // generated by the attribute alongside the wit-bindgen call and the // `Guest`/`export!` glue. struct HttpProbe; @@ -52,7 +52,7 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "http-probe init: probe_url={} denied_url={} every_n_blocks={}", cfg.probe_url, @@ -67,7 +67,6 @@ impl HttpProbe { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) - .map_err(sdk_fault_into_wit) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index 94285268..f8e3a825 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -50,7 +50,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the // wit-bindgen call and the `Guest`/`export!` glue. struct PriceAlert; @@ -59,7 +59,7 @@ struct PriceAlert; impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", cfg.oracle_address, @@ -75,7 +75,6 @@ impl PriceAlert { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) - .map_err(sdk_fault_into_wit) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index ff846bdf..5e203753 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -37,7 +37,7 @@ use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` are generated +// `WitBindgenHost` and the fault and level `From` impls are generated // below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -48,7 +48,7 @@ struct StopLoss; impl Guest for StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -65,7 +65,7 @@ impl Guest for StopLoss { return Ok(()); }; if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; } Ok(()) } diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 0483cb52..fda3ac82 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -36,7 +36,7 @@ mod strategy; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` +// `WitBindgenHost` and the fault and level `From` impls // are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -54,7 +54,7 @@ impl Guest for TwapMonitor { types::Event::ChainLogs(batch) => { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs).map_err(sdk_fault_into_wit)?; + strategy::on_chain_logs(&WitBindgenHost, &logs)?; } types::Event::Block(block) => { let info = strategy::BlockInfo { @@ -62,7 +62,7 @@ impl Guest for TwapMonitor { number: block.number, timestamp: block.timestamp, }; - strategy::on_block(&WitBindgenHost, info).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, info)?; } // Tick / Message are not used by this module. _ => {} From 72923c6bf71113b97544f94086343cdc68fb7350 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 05:12:04 +0000 Subject: [PATCH 30/53] sdk: put an alloy provider seam over the chain host Add HostTransport, an alloy Transport dispatching JSON-RPC packets through ChainHost::request, and host.provider(chain) minting a RootProvider over it, driven by a single-poll block_on. Methods outside the typed ChainMethod read surface (mirrored guest-side) fail as -32601 before reaching the host; node errors carry code, message and decoded revert bytes; host faults surface as typed transport errors. Chain and ChainId newtypes replace bare u64 ids at the SDK edge. The hoisted alloy-provider entry drops its native transport features so the wasm guest build stays transport-free; the engine and load-gen re-add theirs at the call site. --- Cargo.lock | 5 + Cargo.toml | 8 +- crates/nexum-runtime/Cargo.toml | 2 +- crates/nexum-sdk/Cargo.toml | 13 +- crates/nexum-sdk/src/chain/id.rs | 125 ++++++++++++ crates/nexum-sdk/src/chain/method.rs | 121 ++++++++++++ crates/nexum-sdk/src/chain/mod.rs | 19 +- crates/nexum-sdk/src/chain/provider.rs | 119 +++++++++++ crates/nexum-sdk/src/chain/transport.rs | 252 ++++++++++++++++++++++++ crates/nexum-sdk/src/lib.rs | 11 +- tools/load-gen/Cargo.toml | 2 +- 11 files changed, 667 insertions(+), 10 deletions(-) create mode 100644 crates/nexum-sdk/src/chain/id.rs create mode 100644 crates/nexum-sdk/src/chain/method.rs create mode 100644 crates/nexum-sdk/src/chain/provider.rs create mode 100644 crates/nexum-sdk/src/chain/transport.rs diff --git a/Cargo.lock b/Cargo.lock index c0d10b14..b9135dee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3645,9 +3645,13 @@ dependencies = [ name = "nexum-sdk" version = "0.1.0" dependencies = [ + "alloy-json-rpc", "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-sol-types", + "alloy-transport", "http", "nexum-macros", "nexum-sdk-test", @@ -3656,6 +3660,7 @@ dependencies = [ "serde_json", "strum", "thiserror 2.0.18", + "tower", "tracing", "tracing-core", "wstd", diff --git a/Cargo.toml b/Cargo.toml index 6b11a451..d7bf9904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,7 +111,9 @@ clap = { version = "4", features = ["derive"] } # moves every consumer at once. alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -alloy-provider = { version = "2.1", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +# Featureless here so the guest SDK's wasm build stays transport-free; +# the engine and tooling add ws/ipc/pubsub/reqwest at their call sites. +alloy-provider = { version = "2.1", default-features = false } alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } alloy-transport-ws = { version = "2.1", default-features = false } # Typed EIP-155 chain ids for config keys, provider/orderbook pools, and @@ -173,7 +175,9 @@ toml = "1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } -# alloy JSON-RPC client + transport (engine chain backend). +# alloy JSON-RPC client + transport (engine chain backend and the +# guest SDK's host-backed transport). +alloy-json-rpc = { version = "2.1", default-features = false } alloy-rpc-client = { version = "2.1", default-features = false } alloy-transport = { version = "2.1", default-features = false } diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 63c4be27..cb29b7b9 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -67,7 +67,7 @@ bytes.workspace = true # from a `WsConnect`/`Http` transport so the host's `request` / # `request-batch` impls can hand a raw `(method, params)` pair to # alloy's JSON-RPC layer without reimplementing the codec. -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws", "ipc", "pubsub", "reqwest"] } alloy-rpc-client.workspace = true alloy-rpc-types-eth.workspace = true alloy-transport.workspace = true diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 655e28ed..429aa4b5 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -31,11 +31,22 @@ alloy-primitives.workspace = true # assembled from the WIT record at the binding edge (see `events`). alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true +# The provider seam: `HostTransport` speaks alloy's JSON-RPC packet +# vocabulary over `ChainHost::request`, and `provider()` fronts it with +# a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches +# the wasm guest. +alloy-json-rpc.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true +alloy-transport.workspace = true +tower.workspace = true # Standard HTTP request/response/method vocabulary; the SDK adds only # the wasi:http-specific `fetch` seam on top. wstd re-exports the same # `http` types, so a request passes through to the client unconverted. http.workspace = true -serde_json.workspace = true +# `raw_value` backs the transport's pass-through of host JSON into +# alloy's `Box` payload slots. +serde_json = { workspace = true, features = ["std", "raw_value"] } strum.workspace = true thiserror.workspace = true # `tracing-core` backs the guest facade's subscriber plumbing; the diff --git a/crates/nexum-sdk/src/chain/id.rs b/crates/nexum-sdk/src/chain/id.rs new file mode 100644 index 00000000..598857c3 --- /dev/null +++ b/crates/nexum-sdk/src/chain/id.rs @@ -0,0 +1,125 @@ +//! Zero-cost chain identity newtypes. + +use core::fmt; + +/// EIP-155 chain id, typed so a bare `u64` never crosses an SDK API. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ChainId(u64); + +impl ChainId { + /// Wrap a raw EIP-155 id. + pub const fn new(id: u64) -> Self { + Self(id) + } + + /// The raw id, for the WIT edge. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl From for ChainId { + fn from(id: u64) -> Self { + Self::new(id) + } +} + +impl From for u64 { + fn from(id: ChainId) -> Self { + id.get() + } +} + +impl fmt::Display for ChainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// A chain a strategy targets, keyed by its [`ChainId`]. The type the +/// provider seam takes; events deliver a raw id, so `ev.chain_id.into()` +/// bridges at the handler edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Chain(ChainId); + +impl Chain { + /// Ethereum mainnet. + pub const MAINNET: Self = Self::from_id(1); + /// Gnosis Chain. + pub const GNOSIS: Self = Self::from_id(100); + /// Base. + pub const BASE: Self = Self::from_id(8_453); + /// Arbitrum One. + pub const ARBITRUM: Self = Self::from_id(42_161); + /// Sepolia testnet. + pub const SEPOLIA: Self = Self::from_id(11_155_111); + + /// Chain with the given raw EIP-155 id. + pub const fn from_id(id: u64) -> Self { + Self(ChainId::new(id)) + } + + /// The chain's id. + pub const fn id(self) -> ChainId { + self.0 + } +} + +impl From for Chain { + fn from(id: u64) -> Self { + Self::from_id(id) + } +} + +impl From for Chain { + fn from(id: ChainId) -> Self { + Self(id) + } +} + +impl From for ChainId { + fn from(chain: Chain) -> Self { + chain.id() + } +} + +impl From for u64 { + fn from(chain: Chain) -> Self { + chain.id().get() + } +} + +impl fmt::Display for Chain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::{Chain, ChainId}; + + #[test] + fn ids_round_trip() { + assert_eq!(u64::from(ChainId::new(100)), 100); + assert_eq!(ChainId::from(7u64).get(), 7); + assert_eq!(u64::from(Chain::from_id(42)), 42); + assert_eq!(Chain::from(ChainId::new(1)), Chain::MAINNET); + assert_eq!(ChainId::from(Chain::SEPOLIA).get(), 11_155_111); + } + + #[test] + fn named_chains_carry_canonical_ids() { + assert_eq!(u64::from(Chain::MAINNET), 1); + assert_eq!(u64::from(Chain::GNOSIS), 100); + assert_eq!(u64::from(Chain::BASE), 8_453); + assert_eq!(u64::from(Chain::ARBITRUM), 42_161); + assert_eq!(u64::from(Chain::SEPOLIA), 11_155_111); + } + + #[test] + fn display_is_the_raw_id() { + assert_eq!(Chain::GNOSIS.to_string(), "100"); + assert_eq!(ChainId::new(1).to_string(), "1"); + } +} diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs new file mode 100644 index 00000000..3f45013b --- /dev/null +++ b/crates/nexum-sdk/src/chain/method.rs @@ -0,0 +1,121 @@ +//! The typed JSON-RPC method surface, guest side. + +use strum::{EnumString, IntoStaticStr}; + +/// The permitted JSON-RPC read surface as a closed type, mirroring the +/// runtime's `ChainMethod` case for case. Signing and mutating methods +/// have no variant, so they cannot be represented and never cross the +/// WIT edge; [`HostTransport`](super::HostTransport) rejects anything +/// outside this set before calling the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] +pub enum ChainMethod { + /// `eth_blockNumber`. + #[strum(serialize = "eth_blockNumber")] + EthBlockNumber, + /// `eth_call`. + #[strum(serialize = "eth_call")] + EthCall, + /// `eth_chainId`. + #[strum(serialize = "eth_chainId")] + EthChainId, + /// `eth_estimateGas`. + #[strum(serialize = "eth_estimateGas")] + EthEstimateGas, + /// `eth_feeHistory`. + #[strum(serialize = "eth_feeHistory")] + EthFeeHistory, + /// `eth_gasPrice`. + #[strum(serialize = "eth_gasPrice")] + EthGasPrice, + /// `eth_maxPriorityFeePerGas`. + #[strum(serialize = "eth_maxPriorityFeePerGas")] + EthMaxPriorityFeePerGas, + /// `eth_getBalance`. + #[strum(serialize = "eth_getBalance")] + EthGetBalance, + /// `eth_getBlockByHash`. + #[strum(serialize = "eth_getBlockByHash")] + EthGetBlockByHash, + /// `eth_getBlockByNumber`. + #[strum(serialize = "eth_getBlockByNumber")] + EthGetBlockByNumber, + /// `eth_getBlockReceipts`. + #[strum(serialize = "eth_getBlockReceipts")] + EthGetBlockReceipts, + /// `eth_getCode`. + #[strum(serialize = "eth_getCode")] + EthGetCode, + /// `eth_getLogs`. + #[strum(serialize = "eth_getLogs")] + EthGetLogs, + /// `eth_getProof`. + #[strum(serialize = "eth_getProof")] + EthGetProof, + /// `eth_getStorageAt`. + #[strum(serialize = "eth_getStorageAt")] + EthGetStorageAt, + /// `eth_getTransactionByHash`. + #[strum(serialize = "eth_getTransactionByHash")] + EthGetTransactionByHash, + /// `eth_getTransactionCount`. + #[strum(serialize = "eth_getTransactionCount")] + EthGetTransactionCount, + /// `eth_getTransactionReceipt`. + #[strum(serialize = "eth_getTransactionReceipt")] + EthGetTransactionReceipt, + /// `net_version`. + #[strum(serialize = "net_version")] + NetVersion, +} + +impl ChainMethod { + /// The wire method name. `&'static` because the set is closed. + pub fn as_str(self) -> &'static str { + self.into() + } +} + +#[cfg(test)] +mod tests { + use super::ChainMethod; + + #[test] + fn read_surface_methods_parse() { + for m in [ + "eth_call", + "eth_blockNumber", + "eth_getBalance", + "eth_getLogs", + "eth_getTransactionReceipt", + "net_version", + ] { + assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); + } + } + + #[test] + fn signing_and_mutating_methods_have_no_variant() { + for m in [ + "eth_sign", + "eth_signTransaction", + "eth_sendTransaction", + "eth_sendRawTransaction", + "eth_accounts", + "personal_sign", + "admin_peers", + "debug_traceCall", + "", + ] { + assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); + } + } + + #[test] + fn as_str_round_trips_the_wire_name() { + assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); + assert_eq!( + ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), + Ok(ChainMethod::EthGetBalance), + ); + } +} diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index dd60ba0d..e10c8808 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,10 +1,21 @@ -//! `chain::request` JSON plumbing. +//! Chain access for guest strategies. //! -//! Build the `[{to, data}, "latest"]` params array for `eth_call` and -//! parse the `"0x..."` hex result string. Pure-logic helpers so a -//! module can plumb its own `chain::request` shim around them. +//! Typed identity ([`Chain`], [`ChainId`]), the closed JSON-RPC read +//! surface ([`ChainMethod`]), and the alloy provider seam: a +//! [`HostTransport`] over `ChainHost::request` fronted by +//! [`ProviderHost::provider`], driven with [`block_on`]. Plus the +//! `eth_call` JSON plumbing helpers for modules that keep their own +//! `chain::request` shim. pub mod chainlink; pub mod eth_call; +pub mod id; +pub mod method; +pub mod provider; +pub mod transport; pub use eth_call::{eth_call_params, parse_eth_call_result}; +pub use id::{Chain, ChainId}; +pub use method::ChainMethod; +pub use provider::{ProviderHost, block_on}; +pub use transport::HostTransport; diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs new file mode 100644 index 00000000..a46d4f8c --- /dev/null +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -0,0 +1,119 @@ +//! `host.provider(chain)`: an alloy `Provider` over the chain host. + +use std::future::{Future, IntoFuture}; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; + +use super::{Chain, HostTransport}; +use crate::host::ChainHost; + +/// Mints an alloy [`Provider`](alloy_provider::Provider) over +/// [`ChainHost::request`], so a strategy calls typed provider methods +/// instead of hand-building JSON-RPC. Blanket-implemented for every +/// cloneable [`ChainHost`]; drive the returned futures with +/// [`block_on`]. +/// +/// ``` +/// use alloy_provider::Provider; +/// use nexum_sdk::chain::{Chain, ProviderHost, block_on}; +/// use nexum_sdk::host::{ChainError, ChainHost}; +/// +/// #[derive(Clone)] +/// struct StubHost; +/// impl ChainHost for StubHost { +/// fn request(&self, _: u64, _: &str, _: &str) -> Result { +/// Ok("\"0x2a\"".into()) +/// } +/// } +/// +/// let provider = StubHost.provider(Chain::MAINNET); +/// let block = block_on(provider.get_block_number()).unwrap(); +/// assert_eq!(block, 42); +/// ``` +pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { + /// Provider for `chain`, routed through the host's RPC stack. + fn provider(&self, chain: Chain) -> RootProvider { + RootProvider::new(RpcClient::new( + HostTransport::new(self.clone(), chain), + false, + )) + } +} + +impl ProviderHost for H {} + +/// Drive a provider future to completion. Host-backed transports +/// resolve synchronously, so this is a poll loop, not a scheduler; a +/// future that awaits anything other than a host call will spin. +pub fn block_on(future: F) -> F::Output { + let mut future = pin!(future.into_future()); + let mut cx = Context::from_waker(Waker::noop()); + loop { + if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { + return output; + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Bytes, address}; + use alloy_provider::Provider; + use alloy_rpc_types_eth::TransactionRequest; + + use super::{ProviderHost, block_on}; + use crate::chain::Chain; + use crate::host::{ChainError, ChainHost}; + + #[derive(Clone)] + struct StubHost; + + impl ChainHost for StubHost { + fn request( + &self, + chain_id: u64, + method: &str, + _params: &str, + ) -> Result { + assert_eq!(chain_id, 100); + match method { + "eth_blockNumber" => Ok("\"0x2a\"".into()), + "eth_call" => Ok("\"0x1234\"".into()), + other => panic!("unexpected method {other}"), + } + } + } + + #[test] + fn provider_reads_typed_values_through_the_host() { + let provider = StubHost.provider(Chain::GNOSIS); + let block = block_on(provider.get_block_number()).expect("block number"); + assert_eq!(block, 42); + } + + #[test] + fn provider_call_decodes_bytes() { + let provider = StubHost.provider(Chain::GNOSIS); + let tx = TransactionRequest::default() + .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); + let out = block_on(provider.call(tx)).expect("eth_call"); + assert_eq!(out, Bytes::from(vec![0x12, 0x34])); + } + + #[test] + fn signing_methods_error_before_the_host() { + let provider = StubHost.provider(Chain::GNOSIS); + let err = block_on(provider.raw_request::<_, String>("eth_sendRawTransaction".into(), ())) + .expect_err("write method is rejected"); + let payload = err.as_error_resp().expect("json-rpc error response"); + assert_eq!(payload.code, -32601); + } + + #[test] + fn block_on_drives_plain_futures() { + assert_eq!(block_on(async { 7 }), 7); + } +} diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs new file mode 100644 index 00000000..10c64974 --- /dev/null +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -0,0 +1,252 @@ +//! [`HostTransport`]: the alloy transport over [`ChainHost::request`]. + +use std::future::ready; +use std::task::{Context, Poll}; + +use alloy_json_rpc::{ + ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, SerializedRequest, +}; +use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; +use serde_json::value::RawValue; +use tower::Service; + +use super::{Chain, ChainMethod}; +use crate::host::{ChainError, ChainHost}; + +/// An alloy `Transport` routing JSON-RPC through the host's chain +/// interface. Dispatch is synchronous: the host blocks the guest until +/// the response is available, so every returned future is ready on its +/// first poll and [`block_on`](super::block_on) drives it for free. +/// +/// Methods outside the typed [`ChainMethod`] surface never reach the +/// host; they fail as a JSON-RPC `-32601` error response. A structured +/// node error comes back as the error payload (code, message, revert +/// bytes as `0x` hex); a host [`Fault`](crate::host::Fault) surfaces as +/// a custom transport error carrying the typed fault. +#[derive(Clone, Copy, Debug)] +pub struct HostTransport { + host: H, + chain: Chain, +} + +impl HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + /// Transport dispatching on `chain` through `host`. + pub const fn new(host: H, chain: Chain) -> Self { + Self { host, chain } + } + + fn dispatch(&self, packet: RequestPacket) -> Result { + match packet { + RequestPacket::Single(req) => Ok(ResponsePacket::Single(self.dispatch_single(&req)?)), + RequestPacket::Batch(reqs) => reqs + .iter() + .map(|req| self.dispatch_single(req)) + .collect::, _>>() + .map(ResponsePacket::Batch), + } + } + + fn dispatch_single(&self, req: &SerializedRequest) -> Result { + let Ok(method) = ChainMethod::try_from(req.method()) else { + return Ok(failure( + req, + ErrorPayload { + code: -32601, + message: format!( + "method outside the permitted read surface: {}", + req.method() + ) + .into(), + data: None, + }, + )); + }; + let params = req.params().map_or("[]", RawValue::get); + match self + .host + .request(self.chain.into(), method.as_str(), params) + { + Ok(result) => { + let payload = RawValue::from_string(result) + .map_err(|e| TransportError::deser_err(e, "host chain response"))?; + Ok(Response { + id: req.id().clone(), + payload: ResponsePayload::Success(payload), + }) + } + Err(ChainError::Rpc(rpc)) => Ok(failure( + req, + ErrorPayload { + code: rpc.code.into(), + message: rpc.message.into(), + data: rpc.data.and_then(|bytes| { + serde_json::value::to_raw_value(&alloy_primitives::hex::encode_prefixed( + bytes, + )) + .ok() + }), + }, + )), + Err(ChainError::Fault(fault)) => Err(TransportErrorKind::custom(fault)), + } + } +} + +fn failure(req: &SerializedRequest, payload: ErrorPayload) -> Response { + Response { + id: req.id().clone(), + payload: ResponsePayload::Failure(payload), + } +} + +impl Service for HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, packet: RequestPacket) -> Self::Future { + let result = self.dispatch(packet); + Box::pin(ready(result)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use alloy_json_rpc::{Id, Request, RequestPacket, ResponsePacket, ResponsePayload}; + use alloy_transport::TransportError; + use tower::Service; + + use super::HostTransport; + use crate::chain::{Chain, block_on}; + use crate::host::{ChainError, ChainHost, Fault, RpcError}; + + type StubFn = dyn Fn(u64, &str, &str) -> Result + Send + Sync; + + #[derive(Clone)] + struct Stub(Arc); + + impl Stub { + fn new( + f: impl Fn(u64, &str, &str) -> Result + Send + Sync + 'static, + ) -> Self { + Self(Arc::new(f)) + } + } + + impl ChainHost for Stub { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + (self.0)(chain_id, method, params) + } + } + + fn single(method: &'static str) -> RequestPacket { + let req = Request::new(method, Id::Number(1), ()) + .serialize() + .expect("request serializes"); + RequestPacket::Single(req) + } + + fn call(transport: &mut HostTransport, packet: RequestPacket) -> super::Response { + let ResponsePacket::Single(resp) = + block_on(Service::call(transport, packet)).expect("transport dispatches") + else { + panic!("single request yields a single response"); + }; + resp + } + + #[test] + fn success_passes_host_json_through() { + let stub = Stub::new(|chain_id, method, params| { + assert_eq!(chain_id, 100); + assert_eq!(method, "eth_blockNumber"); + assert_eq!(params, "[]"); + Ok("\"0x2a\"".into()) + }); + let mut transport = HostTransport::new(stub, Chain::GNOSIS); + let resp = call(&mut transport, single("eth_blockNumber")); + let ResponsePayload::Success(payload) = resp.payload else { + panic!("expected success, got {resp:?}"); + }; + assert_eq!(payload.get(), "\"0x2a\""); + } + + #[test] + fn unlisted_method_never_reaches_the_host() { + let stub = Stub::new(|_, method, _| panic!("host must not see {method}")); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let resp = call(&mut transport, single("eth_sendRawTransaction")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32601); + assert!(err.message.contains("eth_sendRawTransaction")); + } + + #[test] + fn rpc_error_surfaces_code_message_and_revert_hex() { + let stub = Stub::new(|_, _, _| { + Err(ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), + })) + }); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let resp = call(&mut transport, single("eth_call")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32000); + assert_eq!(err.message, "execution reverted"); + assert_eq!(err.data.expect("revert data").get(), "\"0x08c379a0\"",); + } + + #[test] + fn fault_becomes_a_typed_transport_error() { + let stub = Stub::new(|_, _, _| Err(ChainError::Fault(Fault::Timeout))); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let err = block_on(Service::call(&mut transport, single("eth_call"))) + .expect_err("fault propagates"); + let TransportError::Transport(kind) = err else { + panic!("expected transport kind, got {err:?}"); + }; + assert!(kind.to_string().contains("timeout")); + } + + #[test] + fn batches_dispatch_per_request() { + let stub = Stub::new(|_, method, _| match method { + "eth_blockNumber" => Ok("\"0x1\"".into()), + _ => Ok("\"0x64\"".into()), + }); + let mut transport = HostTransport::new(stub, Chain::MAINNET); + let reqs = vec![ + Request::new("eth_blockNumber", Id::Number(1), ()) + .serialize() + .expect("request serializes"), + Request::new("eth_chainId", Id::Number(2), ()) + .serialize() + .expect("request serializes"), + ]; + let ResponsePacket::Batch(resps) = + block_on(Service::call(&mut transport, RequestPacket::Batch(reqs))) + .expect("batch dispatches") + else { + panic!("batch request yields a batch response"); + }; + assert_eq!(resps.len(), 2); + } +} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 04e8fffd..291a56f7 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -39,7 +39,10 @@ //! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the //! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! -//! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], +//! - [`chain`] - typed chain access: [`Chain`] / [`ChainId`] newtypes, +//! the closed [`ChainMethod`] read surface, and the alloy provider +//! seam ([`HostTransport`], [`provider`], [`block_on`]); plus +//! `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader //! ([`read_latest_answer`]). //! @@ -93,6 +96,12 @@ //! [`ConditionalSource`]: keeper::ConditionalSource //! [`Retrier`]: keeper::Retrier //! [`RetryAction`]: keeper::RetryAction +//! [`Chain`]: chain::Chain +//! [`ChainId`]: chain::ChainId +//! [`ChainMethod`]: chain::ChainMethod +//! [`HostTransport`]: chain::HostTransport +//! [`provider`]: chain::ProviderHost::provider +//! [`block_on`]: chain::block_on //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer diff --git a/tools/load-gen/Cargo.toml b/tools/load-gen/Cargo.toml index 9652bdc8..4c62cd8a 100644 --- a/tools/load-gen/Cargo.toml +++ b/tools/load-gen/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true alloy-primitives.workspace = true -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws"] } alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true alloy-transport-ws.workspace = true From b6208a7608a01715fa188809a1a7a9387d4337de Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 06:16:09 +0000 Subject: [PATCH 31/53] sdk: rename to videre-sdk and add the keeper sweep assembler and typed venue client Rename nexum-venue-sdk to videre-sdk across the workspace. Add the generic Keeper::sweep assembler (WatchSet to Gates to poll to Retrier to Journal) over a shared Sweep outcome resolving the dangling ConditionalSource::Outcome; the world-neutral primitives stay in nexum-sdk. Thread a VenueId newtype through the client seam, add the owned VenueFault mirror (retry-after-ms preserved) behind ClientError, mark the client-facing enums non-exhaustive, and seal IntentBody to its derive. --- Cargo.lock | 35 +- Cargo.toml | 2 +- crates/cow-venue/Cargo.toml | 4 +- crates/cow-venue/src/body.rs | 6 +- crates/cow-venue/src/client.rs | 22 +- crates/cow-venue/src/lib.rs | 2 +- crates/nexum-macros/src/intent_body.rs | 31 +- crates/nexum-macros/src/lib.rs | 16 +- crates/nexum-venue-test/Cargo.toml | 2 +- crates/nexum-venue-test/src/codec.rs | 4 +- crates/nexum-venue-test/src/header.rs | 8 +- crates/nexum-venue-test/src/reference.rs | 4 +- crates/nexum-venue-test/src/transport.rs | 2 +- crates/nexum-venue-test/tests/conformance.rs | 12 +- crates/no-std-probe/Cargo.toml | 2 +- crates/no-std-probe/src/lib.rs | 2 +- crates/videre-host/tests/platform.rs | 2 +- .../Cargo.toml | 8 +- .../src/adapter.rs | 8 +- .../src/bindings.rs | 2 +- .../src/body.rs | 11 +- .../src/client.rs | 98 ++-- .../src/faults.rs | 59 ++- crates/videre-sdk/src/keeper.rs | 451 ++++++++++++++++++ .../src/lib.rs | 34 +- .../src/transport.rs | 0 .../tests/adapter.rs | 43 +- docs/05-sdk-design.md | 4 +- justfile | 2 +- modules/examples/echo-venue/Cargo.toml | 2 +- modules/examples/echo-venue/module.toml | 2 +- modules/examples/echo-venue/src/lib.rs | 4 +- modules/fixtures/flaky-venue/Cargo.toml | 2 +- modules/fixtures/flaky-venue/src/lib.rs | 2 +- 34 files changed, 724 insertions(+), 164 deletions(-) rename crates/{nexum-venue-sdk => videre-sdk}/Cargo.toml (73%) rename crates/{nexum-venue-sdk => videre-sdk}/src/adapter.rs (95%) rename crates/{nexum-venue-sdk => videre-sdk}/src/bindings.rs (94%) rename crates/{nexum-venue-sdk => videre-sdk}/src/body.rs (91%) rename crates/{nexum-venue-sdk => videre-sdk}/src/client.rs (61%) rename crates/{nexum-venue-sdk => videre-sdk}/src/faults.rs (73%) create mode 100644 crates/videre-sdk/src/keeper.rs rename crates/{nexum-venue-sdk => videre-sdk}/src/lib.rs (76%) rename crates/{nexum-venue-sdk => videre-sdk}/src/transport.rs (100%) rename crates/{nexum-venue-sdk => videre-sdk}/tests/adapter.rs (87%) diff --git a/Cargo.lock b/Cargo.lock index b9135dee..43c1425c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,10 +1559,10 @@ version = "0.1.0" dependencies = [ "borsh", "nexum-sdk", - "nexum-venue-sdk", "serde", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "videre-sdk", ] [[package]] @@ -2162,8 +2162,8 @@ dependencies = [ name = "echo-venue" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", "nexum-venue-test", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -2381,7 +2381,7 @@ dependencies = [ name = "flaky-venue" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -3691,18 +3691,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nexum-venue-sdk" -version = "0.1.0" -dependencies = [ - "borsh", - "nexum-macros", - "nexum-sdk", - "strum", - "thiserror 2.0.18", - "wit-bindgen 0.59.0", -] - [[package]] name = "nexum-venue-test" version = "0.1.0" @@ -3712,11 +3700,11 @@ dependencies = [ "http", "nexum-sdk", "nexum-sdk-test", - "nexum-venue-sdk", "serde", "serde_json", "tempfile", "thiserror 2.0.18", + "videre-sdk", ] [[package]] @@ -3731,7 +3719,7 @@ dependencies = [ name = "no-std-probe" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", + "videre-sdk", ] [[package]] @@ -6071,6 +6059,19 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "videre-sdk" +version = "0.1.0" +dependencies = [ + "borsh", + "nexum-macros", + "nexum-sdk", + "nexum-sdk-test", + "strum", + "thiserror 2.0.18", + "wit-bindgen 0.59.0", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index d7bf9904..13067951 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "crates/nexum-sdk-test", "crates/nexum-status-body", "crates/nexum-tasks", - "crates/nexum-venue-sdk", "crates/nexum-venue-test", "crates/nexum-world", "crates/no-std-probe", @@ -19,6 +18,7 @@ members = [ "crates/shepherd-sdk", "crates/shepherd-sdk-test", "crates/videre-host", + "crates/videre-sdk", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 975ea7a9..c7ab7fb4 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -21,7 +21,7 @@ workspace = true borsh = { workspace = true, optional = true } # Source of the `IntentBody` derive and trait the version enum implements, # and the typed intent client the `client` slice binds to the CoW venue. -nexum-venue-sdk = { path = "../nexum-venue-sdk", optional = true } +videre-sdk = { path = "../videre-sdk", optional = true } # `client` slice only: the keeper `RetryAction` the generated # classification table maps each errorType to. The TOML parse happens in # `build.rs`, so serde/toml/thiserror are build- and dev-only and never @@ -47,5 +47,5 @@ thiserror = { workspace = true } # depend on a future `adapter` slice without pulling the codec or the # keeper transitively. default = ["body"] -body = ["dep:borsh", "dep:nexum-venue-sdk"] +body = ["dep:borsh", "dep:videre-sdk"] client = ["body", "dep:nexum-sdk"] diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 3cad2c1e..8caed852 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -5,13 +5,13 @@ //! per-venue version enum the venue publishes, and `#[derive(IntentBody)]` //! gives it the borsh codec: a one-byte version tag plus the borsh //! payload, with an unknown tag failing as a typed -//! [`BodyError`](nexum_venue_sdk::BodyError) rather than a stringly borsh +//! [`BodyError`](videre_sdk::BodyError) rather than a stringly borsh //! error. The one non-obvious invariant: the tag order is the schema, so //! new versions append at the end and no variant is ever reordered or //! removed. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::IntentBody; +use videre_sdk::IntentBody; use crate::composable::ComposableBody; use crate::order::OrderBody; @@ -36,7 +36,7 @@ pub enum CowIntentBody { #[cfg(test)] mod tests { use super::*; - use nexum_venue_sdk::BodyError; + use videre_sdk::BodyError; use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 7ac5c610..b8d4b302 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -8,8 +8,8 @@ //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use nexum_venue_sdk::client::{ClientError, IntentClient, VenueClient}; -use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; +use videre_sdk::client::{ClientError, IntentClient, VenueClient, VenueId}; +use videre_sdk::{IntentStatus, SubmitOutcome}; use crate::body::CowIntentBody; @@ -34,7 +34,7 @@ impl CowClient

{ } /// The venue id every call routes to (always [`VENUE`]). - pub fn venue(&self) -> &str { + pub fn venue(&self) -> &VenueId { self.inner.venue() } @@ -57,9 +57,9 @@ impl CowClient

{ #[cfg(test)] mod tests { use super::*; - use nexum_venue_sdk::VenueError; use std::cell::RefCell; use std::rc::Rc; + use videre_sdk::VenueFault; /// One recorded submit: the venue it routed to and the wire bytes. type SubmitLog = Rc)>>>; @@ -75,24 +75,24 @@ mod tests { impl VenueClient for SpyClient { fn quote( &self, - _venue: &str, + _venue: &VenueId, _body: Vec, - ) -> Result { + ) -> Result { unreachable!("quote not exercised") } - fn submit(&self, venue: &str, body: Vec) -> Result { + fn submit(&self, venue: &VenueId, body: Vec) -> Result { self.submitted .borrow_mut() .push((venue.to_string(), body.clone())); Ok(SubmitOutcome::Accepted(body)) } - fn status(&self, _venue: &str, _receipt: &[u8]) -> Result { + fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { unreachable!("status not exercised") } - fn cancel(&self, _venue: &str, _receipt: &[u8]) -> Result<(), VenueError> { + fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { unreachable!("cancel not exercised") } } @@ -118,14 +118,14 @@ mod tests { #[test] fn submit_routes_to_the_cow_venue_with_encoded_body() { - use nexum_venue_sdk::IntentBody; + use videre_sdk::IntentBody; let spy = SpyClient::default(); let body = sample_body(); let expected = body.to_bytes().expect("body encodes"); let client = CowClient::new(spy.clone()); - assert_eq!(client.venue(), VENUE); + assert_eq!(client.venue().as_str(), VENUE); client.submit(&body).expect("submit succeeds"); let calls = spy.submitted.borrow(); diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 230f6779..147d4920 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -6,7 +6,7 @@ //! typed client and the adapter component are later slices. //! //! The body slice is dependency-light on purpose. It links only the -//! venue SDK (for the [`IntentBody`](nexum_venue_sdk::IntentBody) derive) +//! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) //! and borsh, so a venue adapter component or a strategy module can carry //! the body types and codec without dragging in the host-side CoW //! machinery. The crate is `#![no_std]` (tests aside): the derive's diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/nexum-macros/src/intent_body.rs index cc8fe54b..8ebbc3d6 100644 --- a/crates/nexum-macros/src/intent_body.rs +++ b/crates/nexum-macros/src/intent_body.rs @@ -11,7 +11,7 @@ //! its type's `BorshDeserialize`. //! //! Generated code names the venue SDK by its crate path -//! (`::nexum_venue_sdk`), so the derive is only usable through that +//! (`::videre_sdk`), so the derive is only usable through that //! crate's re-export. The expansion names only `::core` and the SDK's //! `__private` re-exports (borsh, `alloc`), so a `#![no_std]` consumer //! needs no `extern crate alloc`. @@ -82,12 +82,12 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { encode_arms.push(quote! { Self::#ident(payload) => { - let mut out = ::nexum_venue_sdk::body::__private::alloc::vec::Vec::new(); + let mut out = ::videre_sdk::body::__private::alloc::vec::Vec::new(); out.push(#tag); - ::nexum_venue_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( - |err| ::nexum_venue_sdk::body::BodyError::Encode { + ::videre_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( + |err| ::videre_sdk::body::BodyError::Encode { version: #tag, - detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string(&err), + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string(&err), }, )?; ::core::result::Result::Ok(out) @@ -95,10 +95,10 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { }); decode_arms.push(quote! { #tag => ::core::result::Result::Ok(Self::#ident( - ::nexum_venue_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) - .map_err(|err| ::nexum_venue_sdk::body::BodyError::Malformed { + ::videre_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) + .map_err(|err| ::videre_sdk::body::BodyError::Malformed { version: #tag, - detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string( + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string( &err, ), })?, @@ -108,12 +108,15 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { Ok(quote! { #[automatically_derived] - impl ::nexum_venue_sdk::body::IntentBody for #name { + impl ::videre_sdk::body::__private::Derived for #name {} + + #[automatically_derived] + impl ::videre_sdk::body::IntentBody for #name { fn to_bytes( &self, ) -> ::core::result::Result< - ::nexum_venue_sdk::body::__private::alloc::vec::Vec, - ::nexum_venue_sdk::body::BodyError, + ::videre_sdk::body::__private::alloc::vec::Vec, + ::videre_sdk::body::BodyError, > { match self { #(#encode_arms)* @@ -122,14 +125,14 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { fn from_bytes( bytes: &[u8], - ) -> ::core::result::Result { + ) -> ::core::result::Result { let (version, payload) = bytes .split_first() - .ok_or(::nexum_venue_sdk::body::BodyError::Empty)?; + .ok_or(::videre_sdk::body::BodyError::Empty)?; match *version { #(#decode_arms)* version => ::core::result::Result::Err( - ::nexum_venue_sdk::body::BodyError::UnknownVersion { version }, + ::videre_sdk::body::BodyError::UnknownVersion { version }, ), } } diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 52f92d95..ec26d462 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -16,7 +16,7 @@ //! over a per-venue version enum. //! //! Consumers reach these through the SDK re-exports (`nexum_sdk::module`, -//! `nexum_venue_sdk::venue`, `nexum_venue_sdk::IntentBody`) rather than +//! `videre_sdk::venue`, `videre_sdk::IntentBody`) rather than //! depending on this crate directly. mod intent_body; @@ -36,7 +36,7 @@ use syn::{DeriveInput, ImplItem, ItemImpl, Type}; /// fails typedly as `BodyError::UnknownVersion`. /// /// Generated code resolves the SDK by crate path, so use the -/// `nexum_venue_sdk::IntentBody` re-export with `nexum-venue-sdk` as a +/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a /// direct dependency. #[proc_macro_derive(IntentBody)] pub fn derive_intent_body(input: TokenStream) -> TokenStream { @@ -307,7 +307,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { return syn::Error::new( proc_macro2::Span::call_site(), - "#[nexum_venue_sdk::venue] takes no arguments", + "#[videre_sdk::venue] takes no arguments", ) .to_compile_error() .into(); @@ -319,7 +319,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if !is_plain_type(self_ty) { return syn::Error::new_spanned( self_ty, - "#[nexum_venue_sdk::venue] must be applied to an inherent impl of a named type", + "#[videre_sdk::venue] must be applied to an inherent impl of a named type", ) .to_compile_error() .into(); @@ -327,7 +327,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if let Some((_, trait_path, _)) = &input.trait_ { return syn::Error::new_spanned( trait_path, - "#[nexum_venue_sdk::venue] must be applied to an inherent impl, not a trait impl", + "#[videre_sdk::venue] must be applied to an inherent impl, not a trait impl", ) .to_compile_error() .into(); @@ -335,7 +335,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if !input.generics.params.is_empty() { return syn::Error::new_spanned( &input.generics, - "#[nexum_venue_sdk::venue] must be applied to a non-generic impl", + "#[videre_sdk::venue] must be applied to a non-generic impl", ) .to_compile_error() .into(); @@ -355,7 +355,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { return syn::Error::new_spanned( self_ty, format!( - "#[nexum_venue_sdk::venue] requires the adapter face; this impl is missing {:?}. \ + "#[videre_sdk::venue] requires the adapter face; this impl is missing {:?}. \ Define all of `derive_header`, `quote`, `submit`, `status`, `cancel` (plus an \ optional `init`)", missing @@ -553,7 +553,7 @@ fn derive_module_world() -> Result<(Vec, world::ModuleWorld), String> { /// declarations. Returns the manifest path (for the rebuild anchor) /// alongside the world. fn derive_venue_world() -> Result<(String, world::ModuleWorld), String> { - let (manifest_path, declared) = read_manifest_capabilities("#[nexum_venue_sdk::venue]")?; + let (manifest_path, declared) = read_manifest_capabilities("#[videre_sdk::venue]")?; let venue_world = world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; Ok((manifest_path, venue_world)) diff --git a/crates/nexum-venue-test/Cargo.toml b/crates/nexum-venue-test/Cargo.toml index 67eb2e3c..c4fd08dc 100644 --- a/crates/nexum-venue-test/Cargo.toml +++ b/crates/nexum-venue-test/Cargo.toml @@ -31,7 +31,7 @@ nexum-sdk = { path = "../nexum-sdk" } nexum-sdk-test = { path = "../nexum-sdk-test" } # The contract under test: `IntentBody`, `BodyError`, the intent # header types, and the `MessagingHost` seam. -nexum-venue-sdk = { path = "../nexum-venue-sdk" } +videre-sdk = { path = "../videre-sdk" } serde = { workspace = true } serde_json.workspace = true thiserror.workspace = true diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index 52d46226..538db948 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -13,8 +13,8 @@ use std::path::Path; -use nexum_venue_sdk::{BodyError, IntentBody}; use serde::{Deserialize, Serialize}; +use videre_sdk::{BodyError, IntentBody}; use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; @@ -244,7 +244,7 @@ impl CodecVector { #[cfg(test)] mod tests { use borsh::{BorshDeserialize, BorshSerialize}; - use nexum_venue_sdk::IntentBody; + use videre_sdk::IntentBody; use super::*; diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index 6c32af65..5791859e 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -16,9 +16,9 @@ use std::fmt; use std::path::Path; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; -use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement}; use serde::{Deserialize, Serialize}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{AuthScheme, IntentHeader, Settlement}; use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; @@ -277,8 +277,8 @@ impl HeaderGoldens { #[cfg(test)] mod tests { - use nexum_venue_sdk::VenueError; - use nexum_venue_sdk::value_flow::Erc20; + use videre_sdk::VenueError; + use videre_sdk::value_flow::Erc20; use super::*; diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/nexum-venue-test/src/reference.rs index be6eef02..6ad7e546 100644 --- a/crates/nexum-venue-test/src/reference.rs +++ b/crates/nexum-venue-test/src/reference.rs @@ -11,8 +11,8 @@ //! must reproduce them byte for byte. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Erc20}; -use nexum_venue_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; +use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use videre_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; /// The published codec vector file, verbatim. pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/nexum-venue-test/src/transport.rs index 7ec823f3..e90e4b24 100644 --- a/crates/nexum-venue-test/src/transport.rs +++ b/crates/nexum-venue-test/src/transport.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use nexum_sdk::host::{ChainError, ChainHost, Fault}; use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; pub use nexum_sdk_test::{ChainCall, MockChain}; -pub use nexum_venue_sdk::transport::{Message, MessagingHost}; +pub use videre_sdk::transport::{Message, MessagingHost}; /// Composed in-memory transport. Each field exposes the per-seam mock /// so tests can program responses and assert on calls. diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs index 8f0a6b3f..0faa7196 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -1,17 +1,17 @@ //! Acceptance surface for the conformance kit: an adapter written -//! against `nexum-venue-sdk` is held to the published vector and +//! against `videre-sdk` is held to the published vector and //! golden files, and a deliberately divergent adapter is caught by //! them. -use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; -use nexum_venue_sdk::{ - AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, - VenueError, -}; use nexum_venue_test::reference::{ CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, }; use nexum_venue_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, + VenueError, +}; /// An adapter under test: the reference venue implemented through the /// SDK trait, transport injected through the seams so the kit's mocks diff --git a/crates/no-std-probe/Cargo.toml b/crates/no-std-probe/Cargo.toml index 88fb4444..0a5c6279 100644 --- a/crates/no-std-probe/Cargo.toml +++ b/crates/no-std-probe/Cargo.toml @@ -15,4 +15,4 @@ workspace = true [dependencies] # Source of the `IntentBody` derive and trait the probe enum implements. -nexum-venue-sdk = { path = "../nexum-venue-sdk" } +videre-sdk = { path = "../videre-sdk" } diff --git a/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs index 2b91da39..fd455704 100644 --- a/crates/no-std-probe/src/lib.rs +++ b/crates/no-std-probe/src/lib.rs @@ -4,7 +4,7 @@ #![no_std] #![warn(missing_docs)] -use nexum_venue_sdk::IntentBody; +use videre_sdk::IntentBody; /// The probe schema: one published version over a bare byte payload. #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 462eae22..67ea2120 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -111,7 +111,7 @@ fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { // ── world contract ──────────────────────────────────────────────────── /// The per-component venue-adapter world contract: an adapter built -/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped +/// through `#[videre_sdk::venue]` imports exactly the scoped /// transport its manifest declares (`chain`), by construction of the /// emitted world. The venue side never depended on toolchain elision; /// this pins that it does not regress to it. diff --git a/crates/nexum-venue-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml similarity index 73% rename from crates/nexum-venue-sdk/Cargo.toml rename to crates/videre-sdk/Cargo.toml index f3fd250a..674804aa 100644 --- a/crates/nexum-venue-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "nexum-venue-sdk" +name = "videre-sdk" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Guest-side SDK for venue adapters: the VenueAdapter trait over the venue-adapter world bindgen, the borsh-versioned IntentBody codec, the typed intent client core, and typed wrappers over the scoped transport imports." +description = "Guest-side videre SDK: the VenueAdapter trait over the venue-adapter world bindgen, the borsh-versioned IntentBody codec, the typed intent client core, the generic keeper sweep assembler, and typed wrappers over the scoped transport imports." [lib] # Plain library - adapters link this and emit their own cdylib for the @@ -31,3 +31,7 @@ nexum-sdk = { path = "../nexum-sdk" } strum.workspace = true thiserror.workspace = true wit-bindgen.workspace = true + +[dev-dependencies] +# In-memory `LocalStoreHost` behind the keeper sweep tests. +nexum-sdk-test = { path = "../nexum-sdk-test" } diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/videre-sdk/src/adapter.rs similarity index 95% rename from crates/nexum-venue-sdk/src/adapter.rs rename to crates/videre-sdk/src/adapter.rs index 25364863..a2d58d9a 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/videre-sdk/src/adapter.rs @@ -65,9 +65,9 @@ pub trait VenueAdapter { macro_rules! export_venue_adapter { ($adapter:ty) => { #[doc(hidden)] - struct __NexumVenueAdapterExport; + struct __VidereVenueAdapterExport; - impl $crate::bindings::Guest for __NexumVenueAdapterExport { + impl $crate::bindings::Guest for __VidereVenueAdapterExport { fn init( config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, ) -> ::core::result::Result<(), $crate::Fault> { @@ -76,7 +76,7 @@ macro_rules! export_venue_adapter { } impl $crate::bindings::exports::videre::venue::adapter::Guest - for __NexumVenueAdapterExport + for __VidereVenueAdapterExport { fn body_versions() -> ::std::vec::Vec { <$adapter as $crate::VenueAdapter>::body_versions() @@ -114,7 +114,7 @@ macro_rules! export_venue_adapter { } $crate::bindings::__export_venue_adapter_world!( - __NexumVenueAdapterExport with_types_in $crate::bindings + __VidereVenueAdapterExport with_types_in $crate::bindings ); }; } diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/videre-sdk/src/bindings.rs similarity index 94% rename from crates/nexum-venue-sdk/src/bindings.rs rename to crates/videre-sdk/src/bindings.rs index f40e1585..e735f93c 100644 --- a/crates/nexum-venue-sdk/src/bindings.rs +++ b/crates/videre-sdk/src/bindings.rs @@ -21,6 +21,6 @@ wit_bindgen::generate!({ generate_all, pub_export_macro: true, export_macro_name: "__export_venue_adapter_world", - default_bindings_module: "nexum_venue_sdk::bindings", + default_bindings_module: "videre_sdk::bindings", additional_derives: [PartialEq], }); diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/videre-sdk/src/body.rs similarity index 91% rename from crates/nexum-venue-sdk/src/body.rs rename to crates/videre-sdk/src/body.rs index 58a64e12..eb3a64dd 100644 --- a/crates/nexum-venue-sdk/src/body.rs +++ b/crates/videre-sdk/src/body.rs @@ -19,9 +19,10 @@ use strum::IntoStaticStr; use crate::VenueError; /// The codec between a venue's typed body enum and the opaque bytes the -/// pool and adapter boundaries carry. Implement via -/// `#[derive(IntentBody)]` on the outer version enum. -pub trait IntentBody: Sized { +/// pool and adapter boundaries carry. Sealed to +/// `#[derive(IntentBody)]` on the outer version enum: the derive owns +/// the tag rules, so no hand impl can break them. +pub trait IntentBody: Sized + __private::Derived { /// Encode as the one-byte version tag plus the borsh payload. fn to_bytes(&self) -> Result, BodyError>; @@ -93,4 +94,8 @@ pub mod __private { pub extern crate alloc; pub use borsh; + + /// The [`IntentBody`](super::IntentBody) seal: implemented only by + /// `#[derive(IntentBody)]` expansions. + pub trait Derived {} } diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/videre-sdk/src/client.rs similarity index 61% rename from crates/nexum-venue-sdk/src/client.rs rename to crates/videre-sdk/src/client.rs index aa92da34..8652f849 100644 --- a/crates/nexum-venue-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -2,34 +2,73 @@ //! [`VenueClient`] seam. //! //! The client boundary carries opaque bodies; this module is where a -//! typed body meets it. [`IntentClient`] binds one venue and encodes -//! through [`IntentBody`] before submission, so keeper code never -//! handles wire bytes. The seam is byte-level on purpose: the +//! typed body meets it. [`IntentClient`] binds one [`VenueId`] and +//! encodes through [`IntentBody`] before submission, so keeper code +//! never handles wire bytes. The seam is byte-level on purpose: the //! strategy-module SDK implements [`VenueClient`] over its own //! `videre:venue/client` import shims, tests implement it in memory //! (an in-process adapter works directly), and the typed layer above is //! shared by both. +use std::fmt; + use strum::IntoStaticStr; -use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueError}; +use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueFault}; + +/// Venue identifier: the id an adapter registers under and every client +/// call routes to. Opaque beyond equality. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct VenueId(String); + +impl VenueId { + /// The id at its wire spelling. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for VenueId { + fn from(id: String) -> Self { + Self(id) + } +} + +impl From<&str> for VenueId { + fn from(id: &str) -> Self { + Self(id.to_owned()) + } +} + +impl AsRef for VenueId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for VenueId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} /// Byte-level access to the keeper-facing `videre:venue/client` -/// interface, venue named per call as on the wire. +/// interface, the venue named per call. pub trait VenueClient { /// Price an opaque intent body at the named venue. - fn quote(&self, venue: &str, body: Vec) -> Result; + fn quote(&self, venue: &VenueId, body: Vec) -> Result; /// Submit an opaque intent body to the named venue. - fn submit(&self, venue: &str, body: Vec) -> Result; + fn submit(&self, venue: &VenueId, body: Vec) -> Result; /// Report where a previously submitted intent is in its life. - fn status(&self, venue: &str, receipt: &[u8]) -> Result; + fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result; /// Ask the venue to withdraw an intent. Success means the venue /// accepted the cancellation, not that an in-flight settlement can /// no longer win the race. - fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError>; + fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault>; } /// A typed intent client bound to one venue: encodes an [`IntentBody`] @@ -37,20 +76,20 @@ pub trait VenueClient { #[derive(Clone, Debug)] pub struct IntentClient

{ venues: P, - venue: String, + venue: VenueId, } impl IntentClient

{ /// Bind a client handle to the venue id the registry resolves. - pub fn new(venues: P, venue: impl Into) -> Self { + pub fn new(venues: P, venue: impl Into) -> Self { Self { venues, venue: venue.into(), } } - /// The venue id every call on this client routes to. - pub fn venue(&self) -> &str { + /// The venue every call on this client routes to. + pub fn venue(&self) -> &VenueId { &self.venue } @@ -59,10 +98,7 @@ impl IntentClient

{ /// the body the venue priced. pub fn quote(&self, body: &B) -> Result, ClientError> { let bytes = body.to_bytes()?; - let quotation = self - .venues - .quote(&self.venue, bytes.clone()) - .map_err(ClientError::Venue)?; + let quotation = self.venues.quote(&self.venue, bytes.clone())?; Ok(Quoted { client: self, bytes, @@ -73,23 +109,17 @@ impl IntentClient

{ /// Encode a typed body and submit it to the bound venue. pub fn submit(&self, body: &B) -> Result { let bytes = body.to_bytes()?; - self.venues - .submit(&self.venue, bytes) - .map_err(ClientError::Venue) + Ok(self.venues.submit(&self.venue, bytes)?) } /// Report where a previously submitted intent is in its life. pub fn status(&self, receipt: &[u8]) -> Result { - self.venues - .status(&self.venue, receipt) - .map_err(ClientError::Venue) + Ok(self.venues.status(&self.venue, receipt)?) } /// Ask the bound venue to withdraw an intent. pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - self.venues - .cancel(&self.venue, receipt) - .map_err(ClientError::Venue) + Ok(self.venues.cancel(&self.venue, receipt)?) } } @@ -112,10 +142,7 @@ impl Quoted<'_, P> { /// Submit the quoted body to the venue that priced it. pub fn submit(self) -> Result { - self.client - .venues - .submit(&self.client.venue, self.bytes) - .map_err(ClientError::Venue) + Ok(self.client.venues.submit(&self.client.venue, self.bytes)?) } } @@ -124,15 +151,14 @@ impl Quoted<'_, P> { /// /// `IntoStaticStr` yields a snake_case label per case for log and /// metric fields. -#[derive(Clone, Debug, PartialEq, thiserror::Error, IntoStaticStr)] +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum ClientError { /// The typed body failed to encode; nothing crossed the wire. #[error(transparent)] Body(#[from] BodyError), - /// The registry or the venue behind it failed the call. The payload - /// is the wire `venue-error`, which carries no `Display`; format via - /// `Debug`. - #[error("venue error: {0:?}")] - Venue(VenueError), + /// The registry or the venue behind it refused the call. + #[error(transparent)] + Venue(#[from] VenueFault), } diff --git a/crates/nexum-venue-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs similarity index 73% rename from crates/nexum-venue-sdk/src/faults.rs rename to crates/videre-sdk/src/faults.rs index f8e00ce1..9e751747 100644 --- a/crates/nexum-venue-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -1,7 +1,8 @@ //! Conversions between the three failure vocabularies an adapter //! touches: the wire [`Fault`] its exports return, the SDK-neutral //! [`host::Fault`] the transport seams speak, and the [`VenueError`] the -//! intent face reports. +//! intent face reports; plus [`VenueFault`], the owned client-side +//! mirror of the wire error. //! //! Every conversion here is lossy only downward (a structured case folds //! to a payload-bearing string case, never the reverse), so `?` in an @@ -9,10 +10,66 @@ //! vocabulary can carry. use nexum_sdk::host; +use strum::IntoStaticStr; use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; use crate::{Fault, RateLimit, VenueError}; +/// Owned mirror of the wire `venue-error` with `Display`: what typed +/// client code reports when the registry or a venue refuses. The +/// structured retry hint (`rate-limited`'s `retry-after-ms`) survives +/// the lift. +/// +/// `IntoStaticStr` yields a snake_case label per case for log and +/// metric fields. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum VenueFault { + /// No adapter is registered under the named venue id. + #[error("unknown venue")] + UnknownVenue, + /// The venue rejected the body as malformed. + #[error("invalid body: {0}")] + InvalidBody(String), + /// The venue does not support the operation. + #[error("unsupported")] + Unsupported, + /// The venue or a policy refused the call. + #[error("denied: {0}")] + Denied(String), + /// The venue throttled the call. + #[error("rate limited{}", retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] + RateLimited { + /// Venue-suggested wait before retrying, in milliseconds. + retry_after_ms: Option, + }, + /// The venue is temporarily unreachable or failing. + #[error("unavailable: {0}")] + Unavailable(String), + /// The call timed out. + #[error("timeout")] + Timeout, +} + +/// Lift the wire error into the owned mirror. Exhaustive: the wire enum +/// is this crate's own bindgen, so a new WIT case fails here first. +impl From for VenueFault { + fn from(err: VenueError) -> Self { + match err { + VenueError::UnknownVenue => Self::UnknownVenue, + VenueError::InvalidBody(s) => Self::InvalidBody(s), + VenueError::Unsupported => Self::Unsupported, + VenueError::Denied(s) => Self::Denied(s), + VenueError::RateLimited(rl) => Self::RateLimited { + retry_after_ms: rl.retry_after_ms, + }, + VenueError::Unavailable(s) => Self::Unavailable(s), + VenueError::Timeout => Self::Timeout, + } + } +} + /// Lift the wire fault into the SDK-neutral vocabulary the transport /// seams and `nexum-sdk` helpers speak. Exhaustive: the wire enum is /// this crate's own bindgen, so a new WIT case fails here first. diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs new file mode 100644 index 00000000..23d2ae5d --- /dev/null +++ b/crates/videre-sdk/src/keeper.rs @@ -0,0 +1,451 @@ +//! The generic keeper sweep: one pass assembling the world-neutral +//! stores - [`WatchSet`] to [`Gates`] to [`ConditionalSource::poll`] to +//! [`Retrier`] to [`Journal`] - and routing submissions through the +//! [`VenueClient`] seam. +//! +//! [`Sweep`] is the shared poll outcome: the concrete +//! [`ConditionalSource::Outcome`] a keeper's sources produce so +//! [`Keeper::sweep`] can act on every one of them. The world-neutral +//! primitives stay in `nexum_sdk::keeper`; this module only assembles +//! them. + +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{ + ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; +use nexum_sdk::prelude::{hex, keccak256}; + +use crate::client::{VenueClient, VenueId}; +use crate::{SubmitOutcome, UnsignedTx, VenueFault}; + +/// What one poll asks the sweep to do with its watch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Sweep { + /// Submit these encoded intent-body bytes to the bound venue. + Submit(Vec), + /// Nothing to do yet; the next tick re-polls. + WaitBlock, + /// Gate the watch for `seconds` on the epoch clock. + Backoff { + /// Seconds to wait before the next poll. + seconds: u64, + }, + /// The commitment is spent or unservable; drop the watch. + Drop, +} + +/// A keeper: one conditional source bound to one venue, swept over the +/// keeper stores. +pub struct Keeper { + source: S, + venues: P, + venue: VenueId, +} + +impl Keeper { + /// Bind a source to the venue id its submissions route to. + pub fn new(source: S, venues: P, venue: impl Into) -> Self { + Self { + source, + venues, + venue: venue.into(), + } + } + + /// The venue every submission routes to. + pub fn venue(&self) -> &VenueId { + &self.venue + } +} + +impl Keeper { + /// Sweep the watch set once at `tick`: poll every ready watch, + /// submit [`Sweep::Submit`] bodies through the venue client, and + /// run every other outcome and every venue refusal through the + /// [`Retrier`]. A venue-and-body key is checked against the + /// `submitted:` [`Journal`] before every submit and recorded on + /// acceptance, so an accepted body never reaches the venue twice; + /// a `requires-signing` answer journals nothing and is surfaced + /// afresh each sweep. Store faults abort the sweep; venue refusals + /// never do - they fold into per-watch retry actions. + pub fn sweep(&self, host: &H, tick: &Tick) -> Result + where + H: LocalStoreHost, + S: ConditionalSource, + { + let watches = WatchSet::new(host); + let gates = Gates::new(host); + let retrier = Retrier::new(host); + let journal = Journal::submitted(host); + let mut report = SweepReport::default(); + + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + report.skipped += 1; + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + report.gated += 1; + continue; + } + let Some(params) = watches.get(watch)? else { + report.skipped += 1; + continue; + }; + report.polled += 1; + + let action = match self.source.poll(host, watch, ¶ms, tick) { + Sweep::Submit(body) => { + let key = submission_key(&self.venue, &body); + if journal.contains(&key)? { + report.duplicates += 1; + continue; + } + match self.venues.submit(&self.venue, body) { + Ok(SubmitOutcome::Accepted(_)) => { + journal.record(&key)?; + report.submitted += 1; + continue; + } + Ok(SubmitOutcome::RequiresSigning(tx)) => { + report.unsigned.push(tx); + continue; + } + Err(fault) => retry_action(fault), + } + } + Sweep::WaitBlock => RetryAction::TryNextBlock, + Sweep::Backoff { seconds } => RetryAction::Backoff { seconds }, + Sweep::Drop => RetryAction::Drop, + }; + match action { + RetryAction::Drop => report.dropped += 1, + _ => report.retried += 1, + } + retrier.apply(watch, action, tick.epoch_s)?; + } + Ok(report) + } +} + +/// One sweep's tally, by watch disposition. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct SweepReport { + /// Watches polled. + pub polled: usize, + /// Watches skipped by an unexpired gate. + pub gated: usize, + /// Watches skipped unread: a malformed key, or a row that vanished + /// mid-sweep. + pub skipped: usize, + /// Bodies the venue accepted, submission key newly journalled. + pub submitted: usize, + /// Bodies whose key an earlier sweep had journalled, skipped + /// without a venue call. + pub duplicates: usize, + /// Watches left in place for a later tick. + pub retried: usize, + /// Watches dropped. + pub dropped: usize, + /// Transactions the venue answered `requires-signing`; a sweep + /// cannot sign, so the caller owns them. + pub unsigned: Vec, +} + +/// Deterministic pre-submit journal key: the venue id and the +/// keccak-256 of the body. The hash is a fixed-length suffix, so the +/// key is unambiguous whatever the venue id contains. +fn submission_key(venue: &VenueId, body: &[u8]) -> String { + format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) +} + +/// Fold a venue refusal into the retry action the ledger runs: the +/// throttle hint becomes an epoch gate, transient failures retry next +/// block, and refusals no retry can cure drop the watch. +fn retry_action(fault: VenueFault) -> RetryAction { + match fault { + VenueFault::RateLimited { + retry_after_ms: Some(ms), + } => RetryAction::Backoff { + seconds: ms.div_ceil(1000), + }, + VenueFault::RateLimited { + retry_after_ms: None, + } + | VenueFault::Timeout + | VenueFault::Unavailable(_) => RetryAction::TryNextBlock, + VenueFault::UnknownVenue + | VenueFault::InvalidBody(_) + | VenueFault::Unsupported + | VenueFault::Denied(_) => RetryAction::Drop, + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; + use nexum_sdk::prelude::{Address, B256, hex, keccak256}; + use nexum_sdk_test::MockLocalStore; + + use super::{Keeper, Sweep, SweepReport}; + use crate::client::{VenueClient, VenueId}; + use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; + + /// Answers every poll with one programmed outcome. + struct StubSource(Sweep); + + impl nexum_sdk::keeper::ConditionalSource for StubSource { + type Outcome = Sweep; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { + self.0.clone() + } + } + + /// Pops one programmed outcome per poll, from the back. + struct SeqSource(RefCell>); + + impl nexum_sdk::keeper::ConditionalSource for SeqSource { + type Outcome = Sweep; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { + self.0.borrow_mut().pop().unwrap_or(Sweep::WaitBlock) + } + } + + /// Answers every submit with one programmed outcome, logging bodies. + struct StubVenue { + outcome: Result, + submitted: RefCell>>, + } + + impl StubVenue { + fn new(outcome: Result) -> Self { + Self { + outcome, + submitted: RefCell::new(Vec::new()), + } + } + } + + impl VenueClient for &StubVenue { + fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + self.submitted.borrow_mut().push(body); + self.outcome.clone() + } + + fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + const TICK: Tick = Tick { + chain_id: 1, + block: 100, + epoch_s: 1_000, + }; + + fn put_watch(host: &MockLocalStore) -> String { + WatchSet::new(host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("mock store accepts the watch") + } + + fn keeper(outcome: Sweep, venue: &StubVenue) -> Keeper { + Keeper::new(StubSource(outcome), venue, "stub") + } + + #[test] + fn accepted_body_is_journalled_and_never_resubmitted() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xA5, 0x5A]))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.polled, 1); + assert_eq!(report.submitted, 1); + assert_eq!(venue.submitted.borrow().as_slice(), [b"body".to_vec()]); + + let journal = Journal::submitted(&host); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!(journal.contains(&key).expect("journal reads")); + assert_eq!(WatchSet::new(&host).list().expect("list reads").len(), 1); + + // A later sweep re-polls the watch but never re-posts the body. + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.submitted, 0); + assert_eq!(report.duplicates, 1); + assert_eq!(venue.submitted.borrow().len(), 1); + } + + #[test] + fn a_changed_body_submits_afresh() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + // Polls pop from the back: `one` first, then `two`. + let source = SeqSource(RefCell::new(vec![ + Sweep::Submit(b"two".to_vec()), + Sweep::Submit(b"one".to_vec()), + ])); + let keeper = Keeper::new(source, &venue, "stub"); + + assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").submitted, 1); + assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").submitted, 1); + assert_eq!( + venue.submitted.borrow().as_slice(), + [b"one".to_vec(), b"two".to_vec()] + ); + } + + #[test] + fn requires_signing_hands_the_transaction_to_the_caller() { + let host = MockLocalStore::default(); + put_watch(&host); + let tx = UnsignedTx { + chain: 1, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0xFE], + }; + let venue = StubVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.unsigned, vec![tx.clone()]); + assert_eq!(report.submitted, 0); + + // Nothing accepted, nothing journalled: the next sweep + // surfaces the same transaction again. + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.unsigned, vec![tx]); + } + + #[test] + fn gated_watch_is_not_polled() { + let host = MockLocalStore::default(); + let key = put_watch(&host); + let watch = WatchRef::parse(&key).expect("well-formed key"); + Gates::new(&host) + .set_next_block(watch, TICK.block + 1) + .expect("gate writes"); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = keeper(Sweep::Submit(b"body".to_vec()), &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report.gated, 1); + assert_eq!(report.polled, 0); + assert!(venue.submitted.borrow().is_empty()); + } + + #[test] + fn drop_outcome_removes_the_watch() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = keeper(Sweep::Drop, &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report.dropped, 1); + assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); + } + + #[test] + fn backoff_outcome_gates_the_watch_on_the_epoch_clock() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + let keeper = keeper(Sweep::Backoff { seconds: 30 }, &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.retried, 1); + + // Still inside the backoff window: gated, not polled. + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.gated, 1); + + // At the threshold the gate opens again. + let later = Tick { + epoch_s: TICK.epoch_s + 30, + ..TICK + }; + let report = keeper.sweep(&host, &later).expect("sweep runs"); + assert_eq!(report.polled, 1); + } + + #[test] + fn rate_limited_refusal_backs_off_by_the_venue_hint() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.retried, 1); + + // 2500 ms rounds up to a 3 s epoch gate. + let at_2s = Tick { + epoch_s: TICK.epoch_s + 2, + ..TICK + }; + assert_eq!(keeper.sweep(&host, &at_2s).expect("sweep runs").gated, 1); + let at_3s = Tick { + epoch_s: TICK.epoch_s + 3, + ..TICK + }; + assert_eq!(keeper.sweep(&host, &at_3s).expect("sweep runs").polled, 1); + } + + #[test] + fn non_retryable_refusal_drops_the_watch() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::Denied("blocked".into()))); + + let report = keeper(Sweep::Submit(b"body".to_vec()), &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report.dropped, 1); + assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); + } + + #[test] + fn transient_refusal_leaves_the_watch_for_the_next_tick() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::Unavailable("down".into()))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + assert_eq!(report.retried, 1); + assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").polled, 1); + } + + #[test] + fn empty_watch_set_reports_nothing() { + let host = MockLocalStore::default(); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = keeper(Sweep::WaitBlock, &venue) + .sweep(&host, &TICK) + .expect("sweep runs"); + assert_eq!(report, SweepReport::default()); + } +} diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs similarity index 76% rename from crates/nexum-venue-sdk/src/lib.rs rename to crates/videre-sdk/src/lib.rs index 5159ef05..c268e7ca 100644 --- a/crates/nexum-venue-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -1,9 +1,10 @@ -//! # nexum-venue-sdk +//! # videre-sdk //! -//! Guest-side SDK for venue adapters: the second component kind, one -//! venue's protocol speaker exporting the `venue-adapter` world. Where -//! `nexum-sdk` serves the strategy-module persona, this crate serves the -//! venue author. +//! Guest-side SDK for the videre personas: the venue author (one +//! venue's protocol speaker exporting the `venue-adapter` world) and +//! the keeper author driving venues through the client seam. Where +//! `nexum-sdk` serves the strategy-module persona, this crate serves +//! both venue sides of it. //! //! ## What lives here //! @@ -17,10 +18,17 @@ //! one-byte version tag plus the borsh payload; an unknown tag fails //! typedly rather than as a stringly decode error. //! -//! - [`client`] - the typed intent client core: [`IntentClient`] binds a -//! venue and encodes through [`IntentBody`] before the byte-level -//! [`VenueClient`] seam. Lives here (not in the strategy SDK) so the -//! codec and the client that speaks it version together. +//! - [`client`] - the typed intent client core: [`VenueId`] and +//! [`IntentClient`], which binds a venue and encodes through +//! [`IntentBody`] before the byte-level [`VenueClient`] seam. Lives +//! here (not in the strategy SDK) so the codec and the client that +//! speaks it version together. +//! +//! - [`keeper`] - the generic sweep assembler: [`Keeper::sweep`] runs +//! the world-neutral `nexum_sdk::keeper` stores over a +//! [`ConditionalSource`](nexum_sdk::keeper::ConditionalSource) +//! producing the shared [`Sweep`] outcome, submitting through the +//! [`VenueClient`] seam. //! //! - [`transport`] - typed wrappers over the world's scoped imports: //! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] @@ -29,7 +37,8 @@ //! wasi:http surface re-exported as [`transport::http`]. //! //! - [`faults`] - the conversions that make `?` work across the wire -//! fault, the SDK-neutral fault, and [`VenueError`]. +//! fault, the SDK-neutral fault, and [`VenueError`]; plus +//! [`VenueFault`], the owned client-side mirror. //! //! ## Why the bindgen lives in this crate //! @@ -53,11 +62,14 @@ pub mod adapter; pub mod body; pub mod client; pub mod faults; +pub mod keeper; pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, Quoted, VenueClient}; +pub use client::{ClientError, IntentClient, Quoted, VenueClient, VenueId}; +pub use faults::VenueFault; +pub use keeper::{Keeper, Sweep, SweepReport}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`nexum_macros::IntentBody`]. pub use nexum_macros::IntentBody; diff --git a/crates/nexum-venue-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs similarity index 100% rename from crates/nexum-venue-sdk/src/transport.rs rename to crates/videre-sdk/src/transport.rs diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs similarity index 87% rename from crates/nexum-venue-sdk/tests/adapter.rs rename to crates/videre-sdk/tests/adapter.rs index 21ab472a..8af3621f 100644 --- a/crates/nexum-venue-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -6,10 +6,11 @@ //! [`VenueClient`] seam. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; -use nexum_venue_sdk::{ +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, + VenueFault, VenueId, }; /// First published body version: a fixed-price quote. @@ -116,39 +117,39 @@ impl VenueAdapter for DemoAdapter { // The acceptance gate proper: the hand-written adapter exports as the // venue-adapter world. -nexum_venue_sdk::export_venue_adapter!(DemoAdapter); +videre_sdk::export_venue_adapter!(DemoAdapter); /// In-process client: routes the demo venue id straight into the adapter, /// standing in for the host registry the keeper-side seam will bind. struct InProcessClient; impl VenueClient for InProcessClient { - fn quote(&self, venue: &str, body: Vec) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn quote(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::quote(body) + DemoAdapter::quote(body).map_err(Into::into) } - fn submit(&self, venue: &str, body: Vec) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn submit(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::submit(body) + DemoAdapter::submit(body).map_err(Into::into) } - fn status(&self, venue: &str, receipt: &[u8]) -> Result { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::status(receipt.to_vec()) + DemoAdapter::status(receipt.to_vec()).map_err(Into::into) } - fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError> { - if venue != "demo" { - return Err(VenueError::UnknownVenue); + fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); } - DemoAdapter::cancel(receipt.to_vec()) + DemoAdapter::cancel(receipt.to_vec()).map_err(Into::into) } } @@ -254,7 +255,7 @@ fn typed_client_round_trips_through_the_client_seam() { assert!(matches!( client.status(&[0, 1]).unwrap_err(), - ClientError::Venue(VenueError::Denied(_)) + ClientError::Venue(VenueFault::Denied(_)) )); } @@ -284,6 +285,6 @@ fn unbound_venue_is_unknown_at_the_client() { let client = IntentClient::new(InProcessClient, "nowhere"); assert!(matches!( client.submit(&v2_body()).unwrap_err(), - ClientError::Venue(VenueError::UnknownVenue) + ClientError::Venue(VenueFault::UnknownVenue) )); } diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index c5e4d812..52ed08d0 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -31,7 +31,7 @@ things from the SDK: venue (CoW Protocol, a DEX, a lending market, ...) to modules through a common intent surface, so a module author does not need to know the venue's wire format. This persona is planned but not - yet shipped: the crate (`nexum-venue-sdk`), the per-venue crates + yet shipped: the crate (`videre-sdk`), the per-venue crates (e.g. a `cow-venue` crate carrying CoW's intent-body codec), the `#[nexum::venue]` macro, and the `nexum-venue-test` conformance kit are all tracked by the SDK-surfaces epic and have no code in the @@ -257,7 +257,7 @@ competing vision. The planned shape: -- **`nexum-venue-sdk`** - a new crate carrying the guest-side +- **`videre-sdk`** - a new crate carrying the guest-side `VenueAdapter` trait over the (also planned) adapter-world bindgen, a `borsh`-backed `IntentBody` derive that enforces a per-venue version enum (an adapter rejects an intent body tagged with an diff --git a/justfile b/justfile index 270a8248..696d781f 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ build-module: cargo build --target wasm32-wasip2 --release -p example # Build the reference venue adapter (echo-venue) for wasm32-wasip2. Its -# per-component world pins the #[nexum_venue_sdk::venue] acceptance test. +# per-component world pins the #[videre_sdk::venue] acceptance test. build-venue: cargo build --target wasm32-wasip2 --release -p echo-venue diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml index f900f97b..cd7f6172 100644 --- a/modules/examples/echo-venue/Cargo.toml +++ b/modules/examples/echo-venue/Cargo.toml @@ -12,7 +12,7 @@ workspace = true crate-type = ["cdylib"] [dependencies] -nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] diff --git a/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml index 63f9a7a2..bd17af64 100644 --- a/modules/examples/echo-venue/module.toml +++ b/modules/examples/echo-venue/module.toml @@ -1,4 +1,4 @@ -# echo-venue adapter manifest - the reference #[nexum_venue_sdk::venue] +# echo-venue adapter manifest - the reference #[videre_sdk::venue] # component. Declares a single scoped-transport capability (chain), so the # per-component world the macro derives imports nexum:host/chain and # nothing else. diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 9af90589..7e62a376 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -3,7 +3,7 @@ //! The minimal reference venue adapter: it accepts any body, echoes it back //! as the receipt, and settles instantly (every receipt it issued reports //! `fulfilled`). It carries no real venue protocol, so it doubles as the -//! smallest end-to-end demonstration of `#[nexum_venue_sdk::venue]` - the +//! smallest end-to-end demonstration of `#[videre_sdk::venue]` - the //! attribute supplies the per-cdylib wit-bindgen call for a world derived //! from `module.toml`, the `Guest` export glue, and `export!`, leaving only //! the adapter face - and as the `nexum-venue-test` conformance target (see @@ -26,7 +26,7 @@ use videre::value_flow::types::{Asset, AssetAmount}; struct EchoVenue; -#[nexum_venue_sdk::venue] +#[videre_sdk::venue] impl EchoVenue { fn init(_config: Config) -> Result<(), Fault> { Ok(()) diff --git a/modules/fixtures/flaky-venue/Cargo.toml b/modules/fixtures/flaky-venue/Cargo.toml index 5237feb0..bcbd4c95 100644 --- a/modules/fixtures/flaky-venue/Cargo.toml +++ b/modules/fixtures/flaky-venue/Cargo.toml @@ -13,5 +13,5 @@ workspace = true crate-type = ["cdylib"] [dependencies] -nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs index af1c9d9b..0970a63c 100644 --- a/modules/fixtures/flaky-venue/src/lib.rs +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -25,7 +25,7 @@ const POISON_HEAD: &str = "0xdead"; struct FlakyVenue; -#[nexum_venue_sdk::venue] +#[videre_sdk::venue] impl FlakyVenue { fn init(_config: Config) -> Result<(), Fault> { Ok(()) From 35b173ede36ad411ab7fa382b1f2c3e87ddeeeb1 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 06:24:21 +0000 Subject: [PATCH 32/53] sdk: add guest seams and mocks for identity, messaging and remote-store --- crates/nexum-sdk-test/src/lib.rs | 536 +++++++++++++++++- crates/nexum-sdk/src/host.rs | 203 ++++++- crates/nexum-sdk/src/lib.rs | 16 +- crates/nexum-sdk/src/prelude.rs | 2 +- crates/nexum-sdk/src/wit_bindgen_macro.rs | 149 ++++- crates/nexum-venue-test/src/transport.rs | 206 +------ crates/nexum-world/src/lib.rs | 32 +- crates/shepherd-sdk-test/src/lib.rs | 59 +- crates/shepherd-sdk/src/wit_bindgen_macro.rs | 9 +- crates/videre-sdk/src/transport.rs | 38 +- modules/examples/balance-tracker/src/lib.rs | 2 +- .../examples/balance-tracker/src/strategy.rs | 28 +- 12 files changed, 977 insertions(+), 303 deletions(-) diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 022c61bd..2605ced4 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -68,7 +68,11 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; +use nexum_sdk::host::{ + ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, + MessagingHost, RemoteStoreHost, +}; +use nexum_sdk::prelude::{Address, B256, Signature, keccak256}; use tracing::field::{Field, Visit}; use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; @@ -80,8 +84,14 @@ use tracing::{Event, Metadata, Subscriber}; pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, + /// `nexum:host/identity` mock. + pub identity: MockIdentity, /// `nexum:host/local-store` mock. pub store: MockLocalStore, + /// `nexum:host/remote-store` mock. + pub remote_store: MockRemoteStore, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, /// `nexum:host/logging` mock. pub logging: MockLogging, } @@ -114,6 +124,49 @@ impl LocalStoreHost for MockHost { } } +impl IdentityHost for MockHost { + fn accounts(&self) -> Result, Fault> { + self.identity.accounts() + } + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.identity.sign(account, message) + } + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.identity.sign_typed_data(account, typed_data) + } +} + +impl RemoteStoreHost for MockHost { + fn upload(&self, data: &[u8]) -> Result { + self.remote_store.upload(data) + } + fn download(&self, reference: B256) -> Result, Fault> { + self.remote_store.download(reference) + } + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.remote_store.read_feed(owner, topic) + } + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.remote_store.write_feed(topic, data) + } +} + +impl MessagingHost for MockHost { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } +} + impl LoggingHost for MockHost { fn log(&self, level: Level, message: &str) { self.logging.log(level, message); @@ -190,6 +243,315 @@ impl ChainHost for MockChain { } } +// ---------------------------------------------------------------- identity + +/// One recorded [`MockIdentity`] signing invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignCall { + /// Account the guest asked to sign with. + pub account: Address, + /// What was signed. + pub payload: SignPayload, +} + +/// The payload of a [`SignCall`], per signing entry point. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignPayload { + /// A `sign` call: raw message bytes (`personal_sign` semantics). + Message(Vec), + /// A `sign_typed_data` call: the JSON-encoded EIP-712 payload. + TypedData(String), +} + +/// In-memory [`IdentityHost`]. Holds a programmable account roster and +/// one programmed signing outcome; records every signing call. With no +/// outcome programmed, signing fails as [`Fault::Unsupported`], the +/// stub-backend posture; an account outside the roster fails as +/// [`Fault::Denied`] before the programmed outcome applies. +#[derive(Default)] +pub struct MockIdentity { + accounts: RefCell>, + response: RefCell>>, + calls: RefCell>, +} + +impl MockIdentity { + /// Add an account to the roster [`accounts`](IdentityHost::accounts) + /// reports and signing admits. + pub fn add_account(&self, account: Address) { + self.accounts.borrow_mut().push(account); + } + + /// Program the outcome every subsequent signing call returns. + pub fn respond(&self, result: Result) { + *self.response.borrow_mut() = Some(result); + } + + /// All signing calls received, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last signing call received, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Total signing call count. + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } + + fn dispatch(&self, account: Address, payload: SignPayload) -> Result { + self.calls.borrow_mut().push(SignCall { account, payload }); + if !self.accounts.borrow().contains(&account) { + return Err(Fault::Denied(format!( + "MockIdentity: account {account} is not held" + ))); + } + self.response.borrow().clone().unwrap_or_else(|| { + Err(Fault::Unsupported( + "MockIdentity: no signing outcome programmed".to_string(), + )) + }) + } +} + +impl IdentityHost for MockIdentity { + fn accounts(&self) -> Result, Fault> { + Ok(self.accounts.borrow().clone()) + } + + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.dispatch(account, SignPayload::Message(message.to_vec())) + } + + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.dispatch(account, SignPayload::TypedData(typed_data.to_owned())) + } +} + +// ---------------------------------------------------------------- messaging + +/// One recorded [`MessagingHost::publish`] invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishRecord { + /// Content topic published to. + pub content_topic: String, + /// Payload bytes, verbatim. + pub payload: Vec, +} + +/// In-memory [`MessagingHost`]. Seeded messages answer queries, +/// publishes are recorded for assertion, and an optional topic scope +/// mirrors the host's `messaging_topics` grant. Seeded history and +/// published records are deliberately separate stores: a query answers +/// from what the test seeded, never from what the guest published. +#[derive(Default)] +pub struct MockMessaging { + history: RefCell>, + published: RefCell>, + scope: RefCell>>, + faults: RefCell>, +} + +impl MockMessaging { + /// Seed one message into the queryable history. + pub fn seed(&self, message: Message) { + self.history.borrow_mut().push(message); + } + + /// Seed a payload on `content_topic` at `timestamp` (ms since the + /// Unix epoch, UTC), with no sender. + pub fn seed_payload( + &self, + content_topic: impl Into, + payload: impl Into>, + timestamp: u64, + ) { + self.seed(Message { + content_topic: content_topic.into(), + payload: payload.into(), + timestamp, + sender: None, + }); + } + + /// Confine the mock to `topics`, mirroring the component's + /// `messaging_topics` grant: any other topic fails as + /// [`Fault::Denied`]. Untouched, every topic is allowed. + pub fn scope_topics(&self, topics: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); + } + + /// Inject a fault for any operation on a topic starting with + /// `prefix`. Multiple patterns can be registered; the first + /// matching one fires. + pub fn fail_on(&self, prefix: impl Into, fault: Fault) { + self.faults.borrow_mut().push((prefix.into(), fault)); + } + + /// All publishes received, in arrival order. + pub fn published(&self) -> Vec { + self.published.borrow().clone() + } + + /// Last publish received, if any. + pub fn last_published(&self) -> Option { + self.published.borrow().last().cloned() + } + + /// Total publish count. + pub fn publish_count(&self) -> usize { + self.published.borrow().len() + } + + fn admit(&self, content_topic: &str) -> Result<(), Fault> { + for (prefix, fault) in self.faults.borrow().iter() { + if content_topic.starts_with(prefix.as_str()) { + return Err(fault.clone()); + } + } + if let Some(scope) = self.scope.borrow().as_ref() + && !scope.iter().any(|topic| topic == content_topic) + { + return Err(Fault::Denied(format!( + "MockMessaging: {content_topic} is outside the scoped topics" + ))); + } + Ok(()) + } +} + +impl MessagingHost for MockMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.admit(content_topic)?; + self.published.borrow_mut().push(PublishRecord { + content_topic: content_topic.to_owned(), + payload: payload.to_vec(), + }); + Ok(()) + } + + /// Answer from the seeded history: exact-topic matches whose + /// timestamp lies within the inclusive `start_time..=end_time` + /// window, in seed order. Seed order is delivery order, so a + /// `limit` keeps the newest matches: the tail. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.admit(content_topic)?; + let mut matches: Vec = self + .history + .borrow() + .iter() + .filter(|message| { + message.content_topic == content_topic + && start_time.is_none_or(|start| message.timestamp >= start) + && end_time.is_none_or(|end| message.timestamp <= end) + }) + .cloned() + .collect(); + if let Some(limit) = limit { + let keep = usize::try_from(limit).unwrap_or(usize::MAX); + if matches.len() > keep { + matches.drain(..matches.len() - keep); + } + } + Ok(matches) + } +} + +// ---------------------------------------------------------------- remote-store + +/// In-memory [`RemoteStoreHost`]: content-addressed blobs plus mutable +/// `(owner, topic)` feeds. The mock addresses blobs by `keccak256` of +/// their content, so uploads are deterministic for assertion; the real +/// store's addressing scheme is the host's concern. Feed writes land +/// under the mock's own owner ([`set_owner`](Self::set_owner), +/// zero-address by default), mirroring the host signing feed updates +/// with its configured identity. +#[derive(Default)] +pub struct MockRemoteStore { + blobs: RefCell>>, + feeds: RefCell>>, + owner: Cell

, + fault: RefCell>, +} + +impl MockRemoteStore { + /// Set the owner feed writes land under. + pub fn set_owner(&self, owner: Address) { + self.owner.set(owner); + } + + /// Seed a blob without going through the trait; returns its + /// reference. + pub fn seed_blob(&self, data: impl Into>) -> B256 { + let data = data.into(); + let reference = keccak256(&data); + self.blobs.borrow_mut().insert(reference, data); + reference + } + + /// Seed another owner's feed for [`read_feed`](RemoteStoreHost::read_feed) + /// tests. + pub fn seed_feed(&self, owner: Address, topic: B256, data: impl Into>) { + self.feeds.borrow_mut().insert((owner, topic), data.into()); + } + + /// Inject a fault every subsequent operation returns. + pub fn fail_with(&self, fault: Fault) { + *self.fault.borrow_mut() = Some(fault); + } + + /// Number of stored blobs. + pub fn blob_count(&self) -> usize { + self.blobs.borrow().len() + } + + fn check_injected_fault(&self) -> Result<(), Fault> { + match self.fault.borrow().as_ref() { + Some(fault) => Err(fault.clone()), + None => Ok(()), + } + } +} + +impl RemoteStoreHost for MockRemoteStore { + fn upload(&self, data: &[u8]) -> Result { + self.check_injected_fault()?; + Ok(self.seed_blob(data)) + } + + fn download(&self, reference: B256) -> Result, Fault> { + self.check_injected_fault()?; + self.blobs + .borrow() + .get(&reference) + .cloned() + .ok_or_else(|| Fault::Unavailable(format!("MockRemoteStore: no blob at {reference}"))) + } + + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.check_injected_fault()?; + Ok(self.feeds.borrow().get(&(owner, topic)).cloned()) + } + + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.check_injected_fault()?; + let reference = self.seed_blob(data); + self.feeds + .borrow_mut() + .insert((self.owner.get(), topic), data.to_vec()); + Ok(reference) + } +} + // ---------------------------------------------------------------- local-store /// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: @@ -679,6 +1041,8 @@ pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { #[cfg(test)] mod tests { + use nexum_sdk::prelude::U256; + use super::*; #[test] @@ -838,22 +1202,190 @@ mod tests { assert_eq!(store.len(), 2); } + #[test] + fn identity_roster_and_programmed_outcome() { + let identity = MockIdentity::default(); + let account = Address::from([0xAA; 20]); + assert!(identity.accounts().unwrap().is_empty()); + identity.add_account(account); + assert_eq!(identity.accounts().unwrap(), vec![account]); + + // No outcome programmed: signing is unsupported, the stub posture. + let err = identity.sign(account, b"hello").unwrap_err(); + assert!(matches!(err, Fault::Unsupported(ref m) if m.contains("MockIdentity"))); + + let signature = Signature::new(U256::from(1), U256::from(2), false); + identity.respond(Ok(signature)); + assert_eq!(identity.sign(account, b"hello").unwrap(), signature); + assert_eq!(identity.sign_typed_data(account, "{}").unwrap(), signature); + + assert_eq!(identity.call_count(), 3); + assert_eq!( + identity.last_call().unwrap(), + SignCall { + account, + payload: SignPayload::TypedData("{}".to_owned()), + }, + ); + } + + #[test] + fn identity_denies_off_roster_accounts() { + let identity = MockIdentity::default(); + identity.respond(Ok(Signature::new(U256::from(1), U256::from(2), true))); + let err = identity.sign(Address::from([0xBB; 20]), b"x").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused call is still recorded. + assert_eq!(identity.call_count(), 1); + } + + #[test] + fn messaging_records_publishes_and_answers_from_seeds() { + let messaging = MockMessaging::default(); + messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); + messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); + messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); + + messaging.publish("/acme/1/orders/proto", b"out").unwrap(); + assert_eq!(messaging.publish_count(), 1); + assert_eq!( + messaging.last_published().unwrap(), + PublishRecord { + content_topic: "/acme/1/orders/proto".to_owned(), + payload: b"out".to_vec(), + }, + ); + + // Publishes never leak into query results. + let all = messaging + .query("/acme/1/orders/proto", None, None, None) + .unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].payload, b"one"); + assert_eq!(all[1].payload, b"two"); + } + + #[test] + fn messaging_query_applies_bounds_and_limit() { + let messaging = MockMessaging::default(); + for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { + messaging.seed_payload("/t", payload.to_vec(), ts); + } + + let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); + assert_eq!(window.len(), 2); + assert_eq!(window[0].payload, b"b"); + + // A limit keeps the newest matches: the tail of the window. + let limited = messaging.query("/t", None, None, Some(2)).unwrap(); + assert_eq!(limited.len(), 2); + assert_eq!(limited[0].payload, b"c"); + assert_eq!(limited[1].payload, b"d"); + } + + #[test] + fn messaging_scope_denies_off_grant_topics() { + let messaging = MockMessaging::default(); + messaging.scope_topics(["/acme/1/orders/proto"]); + + messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); + let err = messaging.publish("/other", b"no").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + let err = messaging.query("/other", None, None, None).unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused publish was never recorded. + assert_eq!(messaging.publish_count(), 1); + } + + #[test] + fn messaging_fault_injection_fires_by_prefix() { + let messaging = MockMessaging::default(); + messaging.fail_on("/flaky", Fault::Timeout); + assert!(matches!( + messaging.publish("/flaky/topic", b"x").unwrap_err(), + Fault::Timeout, + )); + messaging.publish("/steady", b"x").unwrap(); + } + + #[test] + fn remote_store_round_trips_content_addressed_blobs() { + let store = MockRemoteStore::default(); + let reference = store.upload(b"chunk").unwrap(); + assert_eq!(reference, keccak256(b"chunk")); + assert_eq!(store.download(reference).unwrap(), b"chunk"); + assert_eq!(store.blob_count(), 1); + + let missing = store.download(B256::from([0xCC; 32])).unwrap_err(); + assert!(matches!(missing, Fault::Unavailable(ref m) if m.contains("MockRemoteStore"))); + } + + #[test] + fn remote_store_feeds_are_owner_scoped() { + let store = MockRemoteStore::default(); + let owner = Address::from([0xAA; 20]); + let topic = B256::from([0x11; 32]); + + // Writes land under the mock's own owner and stay downloadable. + store.set_owner(owner); + let reference = store.write_feed(topic, b"v1").unwrap(); + assert_eq!(store.download(reference).unwrap(), b"v1"); + assert_eq!( + store.read_feed(owner, topic).unwrap().as_deref(), + Some(&b"v1"[..]) + ); + + // Another owner's feed is a distinct slot. + let other = Address::from([0xBB; 20]); + assert_eq!(store.read_feed(other, topic).unwrap(), None); + store.seed_feed(other, topic, b"theirs"); + assert_eq!( + store.read_feed(other, topic).unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + } + + #[test] + fn remote_store_fault_injection_covers_every_operation() { + let store = MockRemoteStore::default(); + store.fail_with(Fault::Timeout); + assert!(matches!(store.upload(b"x").unwrap_err(), Fault::Timeout)); + assert!(matches!( + store.download(B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.read_feed(Address::ZERO, B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.write_feed(B256::ZERO, b"x").unwrap_err(), + Fault::Timeout, + )); + } + #[test] fn mock_host_dispatches_through_supertrait() { let host = MockHost::new(); host.chain .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + host.messaging.seed_payload("/t", b"m".to_vec(), 1); - // Through the `Host` supertrait. + // Through the `Host` supertrait: all six seams on one value. let _: &dyn nexum_sdk::host::Host = &host; host.set("key", b"val").unwrap(); assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); + assert!(host.accounts().unwrap().is_empty()); + assert_eq!(host.query("/t", None, None, None).unwrap().len(), 1); + let reference = host.upload(b"blob").unwrap(); + assert_eq!(host.download(reference).unwrap(), b"blob"); host.log(Level::INFO, "happy path"); assert_eq!(host.chain.call_count(), 1); assert_eq!(host.logging.lines().len(), 1); assert_eq!(host.store.len(), 1); + assert_eq!(host.remote_store.blob_count(), 1); } #[test] diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 6e0e71d5..949c5eec 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -1,13 +1,14 @@ //! Host traits - the seam between strategy logic and the wit-bindgen //! shims a module generates per-cdylib. //! -//! Each trait mirrors one nexum host interface ([`ChainHost`] for -//! `nexum:host/chain`, [`LocalStoreHost`] for `nexum:host/local-store`, -//! [`LoggingHost`] for `nexum:host/logging`). A module that wants +//! Each trait mirrors one nexum host interface: [`ChainHost`], +//! [`IdentityHost`], [`LocalStoreHost`], [`RemoteStoreHost`], +//! [`MessagingHost`], and [`LoggingHost`]. A module that wants //! host-free unit tests writes its strategy logic against the -//! [`Host`] supertrait and lets `nexum-sdk-test` slot in the -//! in-memory mocks. Domain SDKs bound extra host interfaces on top -//! with their own traits over the same [`Fault`]. +//! [`Host`] supertrait (all six) or the exact traits it exercises, +//! and lets `nexum-sdk-test` slot in the in-memory mocks. Domain SDKs +//! bound extra host interfaces on top with their own traits over the +//! same [`Fault`]. //! //! ## Why a separate `Fault` //! @@ -18,7 +19,7 @@ //! the mocks compile without a wasm toolchain. See `nexum-sdk-test`'s //! crate docs for the adapter pattern. -use alloy_primitives::Bytes; +use alloy_primitives::{Address, B256, Bytes, Signature}; use strum::IntoStaticStr; use tracing_core::Level; @@ -202,14 +203,108 @@ pub trait LoggingHost { fn log(&self, level: Level, message: &str); } -/// Supertrait that bundles the core host interfaces a typical -/// strategy module exercises. Modules that want full host-free -/// integration tests take `&impl Host` (or a generic ``) in -/// their strategy function; `nexum-sdk-test::MockHost` is the -/// in-memory implementation. Strategies that reach a domain extension -/// bound its host trait as well (the CoW SDK's `CowHost`, say). +/// `nexum:host/identity` - host-held accounts and signing. +pub trait IdentityHost { + /// Accounts the host is willing to sign for. Empty means no + /// signing capability. + fn accounts(&self) -> Result, Fault>; + /// Sign `message` with `personal_sign` semantics (the host + /// prepends the `"\x19Ethereum Signed Message:\n"` prefix). + fn sign(&self, account: Address, message: &[u8]) -> Result; + /// Sign a JSON-encoded EIP-712 payload. + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result; +} + +/// One delivered message, mirrored from `nexum:host/types.message` so +/// the [`MessagingHost`] seam stays mockable without naming bindgen +/// types. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Message { + /// Content topic the message arrived on. + pub content_topic: String, + /// Opaque payload bytes. + pub payload: Vec, + /// Delivery timestamp, ms since the Unix epoch, UTC. + pub timestamp: u64, + /// Optional sender identity (protocol-dependent). + pub sender: Option>, +} + +/// `nexum:host/messaging` - publish to and query content topics. The +/// host confines both to the component's `messaging_topics` grant; an +/// off-scope topic fails as [`Fault::Denied`]. +pub trait MessagingHost { + /// Publish a payload to a content topic + /// (`////`). + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; + /// Query historical messages on a topic, window bounded by the + /// optional `start_time` / `end_time` (ms since the Unix epoch, + /// UTC) and `limit`. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault>; +} + +/// `nexum:host/remote-store` - content-addressed blobs and mutable +/// feeds on the decentralized store. +pub trait RemoteStoreHost { + /// Upload raw data; returns its 32-byte content reference. + fn upload(&self, data: &[u8]) -> Result; + /// Download the data behind a content reference. + fn download(&self, reference: B256) -> Result, Fault>; + /// Latest value of the `(owner, topic)` mutable feed, when set. + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault>; + /// Update the host-owned feed at `topic` (the host signs with its + /// configured identity); returns the new chunk's reference. + fn write_feed(&self, topic: B256, data: &[u8]) -> Result; +} + +/// Lift a host-returned account into an [`Address`]. The WIT edge +/// carries it as bytes; any length but 20 is a host-side bug, folded +/// to [`Fault::Internal`]. +pub fn account_from_wire(raw: &[u8]) -> Result { + Address::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "identity returned a {}-byte account, expected 20", + raw.len() + )) + }) +} + +/// Lift a host-returned 65-byte `r || s || v` signature into a +/// [`Signature`]. A malformed buffer is a host-side bug, folded to +/// [`Fault::Internal`]. +pub fn signature_from_wire(raw: &[u8]) -> Result { + Signature::from_raw(raw) + .map_err(|e| Fault::Internal(format!("identity returned a malformed signature: {e}"))) +} + +/// Lift a host-returned content reference into a [`B256`]. Any length +/// but 32 is a host-side bug, folded to [`Fault::Internal`]. +pub fn reference_from_wire(raw: &[u8]) -> Result { + B256::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "remote-store returned a {}-byte reference, expected 32", + raw.len() + )) + }) +} + +/// Supertrait that bundles all six core host interfaces. Modules that +/// want full host-free integration tests take `&impl Host` (or a +/// generic ``) in their strategy function; +/// `nexum-sdk-test::MockHost` is the in-memory implementation. +/// Strategies that exercise fewer interfaces bound exactly those +/// (`H: ChainHost + LoggingHost`, say) so their production adapter +/// only needs the capabilities the module declares; a domain +/// extension's host trait is bounded the same way (the CoW SDK's +/// `CowHost`). /// -/// A blanket impl is provided for any type that implements all three +/// A blanket impl is provided for any type that implements all six /// component traits, so callers do not have to add a redundant /// `impl Host for MyHost {}`. /// @@ -222,8 +317,10 @@ pub trait LoggingHost { /// ``` /// use nexum_sdk::Level; /// use nexum_sdk::host::{ -/// ChainError, ChainHost, Fault, Host, LocalStoreHost, LoggingHost, +/// ChainError, ChainHost, Fault, Host, IdentityHost, LocalStoreHost, LoggingHost, +/// Message, MessagingHost, RemoteStoreHost, /// }; +/// # use nexum_sdk::prelude::{Address, B256, Signature}; /// /// /// Pure strategy logic - no wit-bindgen calls in here. /// fn record_block(host: &H, chain_id: u64, key: &str) -> Result<(), Fault> { @@ -241,23 +338,93 @@ pub trait LoggingHost { /// # Ok("\"0x0\"".into()) /// # } /// # } +/// # impl IdentityHost for StubHost { +/// # fn accounts(&self) -> Result, Fault> { Ok(vec![]) } +/// # fn sign(&self, _: Address, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn sign_typed_data(&self, _: Address, _: &str) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # } /// # impl LocalStoreHost for StubHost { /// # fn get(&self, _: &str) -> Result>, Fault> { Ok(None) } /// # fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } /// # fn delete(&self, _: &str) -> Result<(), Fault> { Ok(()) } /// # fn list_keys(&self, _: &str) -> Result, Fault> { Ok(vec![]) } /// # } +/// # impl RemoteStoreHost for StubHost { +/// # fn upload(&self, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn download(&self, _: B256) -> Result, Fault> { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn read_feed(&self, _: Address, _: B256) -> Result>, Fault> { Ok(None) } +/// # fn write_feed(&self, _: B256, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # } +/// # impl MessagingHost for StubHost { +/// # fn publish(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } +/// # fn query( +/// # &self, +/// # _: &str, +/// # _: Option, +/// # _: Option, +/// # _: Option, +/// # ) -> Result, Fault> { +/// # Ok(vec![]) +/// # } +/// # } /// # impl LoggingHost for StubHost { /// # fn log(&self, _: Level, _: &str) {} /// # } /// record_block(&StubHost, 1, "block:42").unwrap(); /// ``` -pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} -impl Host for T {} +pub trait Host: + ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} +impl Host for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} #[cfg(test)] mod tests { - use super::{ChainError, Fault, HostFault, RateLimit, RpcError}; + use alloy_primitives::{Address, B256, U256}; + + use super::{ + ChainError, Fault, HostFault, RateLimit, RpcError, account_from_wire, reference_from_wire, + signature_from_wire, + }; + + #[test] + fn wire_lifts_accept_exact_lengths() { + let account = account_from_wire(&[0x11; 20]).unwrap(); + assert_eq!(account, Address::from([0x11; 20])); + + let reference = reference_from_wire(&[0x22; 32]).unwrap(); + assert_eq!(reference, B256::from([0x22; 32])); + + let raw = alloy_primitives::Signature::new(U256::from(1), U256::from(2), true).as_bytes(); + let signature = signature_from_wire(&raw).unwrap(); + assert_eq!(signature.r(), U256::from(1)); + assert_eq!(signature.s(), U256::from(2)); + assert!(signature.v()); + } + + #[test] + fn wire_lifts_fold_malformed_buffers_to_internal() { + for fault in [ + account_from_wire(&[0u8; 19]).unwrap_err(), + signature_from_wire(&[0u8; 64]).unwrap_err(), + reference_from_wire(&[0u8; 31]).unwrap_err(), + ] { + assert!(matches!(fault, Fault::Internal(_)), "got {fault:?}"); + } + } #[test] fn fault_labels_are_stable_snake_case() { diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 291a56f7..8042efa3 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -17,12 +17,13 @@ //! primitives ([`Address`], [`B256`], [`Bytes`], [`U256`], //! [`keccak256`]). //! -//! - [`host`] - host trait seam ([`Host`] / [`ChainHost`] / -//! [`LocalStoreHost`] / [`LoggingHost`]) plus the host-neutral -//! [`Fault`] vocabulary. Modules that want host-free tests structure -//! their strategy logic against these traits and slot in the -//! `nexum-sdk-test` mocks. See the host module docs for the -//! wit-bindgen adapter pattern. +//! - [`host`] - host trait seam: [`Host`] bundling all six core +//! interfaces ([`ChainHost`] / [`IdentityHost`] / [`LocalStoreHost`] +//! / [`RemoteStoreHost`] / [`MessagingHost`] / [`LoggingHost`]) plus +//! the host-neutral [`Fault`] vocabulary. Modules that want +//! host-free tests structure their strategy logic against these +//! traits and slot in the `nexum-sdk-test` mocks. See the host +//! module docs for the wit-bindgen adapter pattern. //! //! - [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) - //! generates the per-module `WitBindgenHost` adapter over the @@ -87,7 +88,10 @@ //! [`keccak256`]: alloy_primitives::keccak256 //! [`Host`]: host::Host //! [`ChainHost`]: host::ChainHost +//! [`IdentityHost`]: host::IdentityHost //! [`LocalStoreHost`]: host::LocalStoreHost +//! [`RemoteStoreHost`]: host::RemoteStoreHost +//! [`MessagingHost`]: host::MessagingHost //! [`LoggingHost`]: host::LoggingHost //! [`Fault`]: host::Fault //! [`WatchSet`]: keeper::WatchSet diff --git a/crates/nexum-sdk/src/prelude.rs b/crates/nexum-sdk/src/prelude.rs index ef4bcc1f..b72a6b04 100644 --- a/crates/nexum-sdk/src/prelude.rs +++ b/crates/nexum-sdk/src/prelude.rs @@ -7,4 +7,4 @@ //! crate (one `wit_bindgen::generate!` call per cdylib). Domain SDKs //! ship their own prelude for their protocol surface. -pub use alloy_primitives::{Address, B256, Bytes, U256, address, b256, hex, keccak256}; +pub use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, hex, keccak256}; diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 6ac615e4..81f1c141 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -3,16 +3,16 @@ //! //! Before this macro existed, each module hand-rolled ~80 lines of //! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus the fault, -//! chain-error, and level conversions. The code differed across modules -//! in zero places that were not bugs. +//! impls plus the fault, chain-error, and level conversions. The code +//! differed across modules in zero places that were not bugs. //! //! The adapter is capability-selected: the `caps: [...]` form emits //! only the pieces backed by the module's declared capabilities //! (`#[nexum_sdk::module]` invokes it this way, matching the //! per-module world it generates), while the zero-argument form emits -//! the full `chain, local_store, logging` set for modules that -//! compile against a blanket world with every core import present. +//! the full six-interface set (`chain, identity, local_store, +//! remote_store, messaging, logging`) for modules that compile +//! against a blanket world with every core import present. //! Either way the call site must already have the wit-bindgen output //! for its world in scope (`wit_bindgen::generate!({ ..., //! generate_all })`): each selected piece resolves its @@ -33,14 +33,16 @@ //! //! // `WitBindgenHost` and the `Fault` `From` impls (both directions) //! // are now in scope, plus per selected capability: `convert_chain_err` -//! // (chain), the `LocalStoreHost` impl (local_store), and the -//! // `Level` `From` impl, `HostLogSink`, and `install_tracing` -//! // (logging), with the wit-bindgen and SDK types tied together -//! // through identifier resolution. Call `install_tracing()` once at -//! // the top of `Guest::init` to route `tracing::info!(...)` to the -//! // host. A `From for nexum_sdk::events::Log` is also -//! // emitted so `on_event` maps a chain-logs batch straight to -//! // `Vec`. +//! // (chain), the `IdentityHost`, `LocalStoreHost`, `RemoteStoreHost`, +//! // and `MessagingHost` impls (identity / local_store / remote_store / +//! // messaging), and the `Level` `From` impl, `HostLogSink`, and +//! // `install_tracing` (logging), with the wit-bindgen and SDK types +//! // tied together through identifier resolution. Call +//! // `install_tracing()` once at the top of `Guest::init` to route +//! // `tracing::info!(...)` to the host. `From` impls for +//! // `nexum_sdk::events::Log` and `nexum_sdk::host::Message` are also +//! // emitted so `on_event` maps chain-log batches and messages +//! // straight to the SDK types. //! ``` /// Generate `WitBindgenHost` + the `*Host` trait impls + the error / @@ -58,7 +60,9 @@ macro_rules! bind_host_via_wit_bindgen { // Blanket-world form: every core interface is in scope, emit the // full adapter. () => { - $crate::bind_host_via_wit_bindgen!(caps: [chain, local_store, logging]); + $crate::bind_host_via_wit_bindgen!( + caps: [chain, identity, local_store, remote_store, messaging, logging] + ); }; // Capability-selected form: the base pieces (which need only the // always-present `nexum:host/types`) plus one block per listed @@ -143,6 +147,20 @@ macro_rules! bind_host_via_wit_bindgen { } } + /// Rebuild the SDK message from the per-cdylib wit-bindgen + /// `message` record, so `on_event` maps a delivery straight to + /// `nexum_sdk::host::Message`. + impl ::core::convert::From for $crate::host::Message { + fn from(message: nexum::host::types::Message) -> Self { + Self { + content_topic: message.content_topic, + payload: message.payload, + timestamp: message.timestamp, + sender: message.sender, + } + } + } + $($crate::__bind_host_cap_via_wit_bindgen!($cap);)* }; } @@ -182,6 +200,40 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } }; + (identity) => { + impl $crate::host::IdentityHost for WitBindgenHost { + fn accounts( + &self, + ) -> ::core::result::Result< + ::std::vec::Vec<$crate::prelude::Address>, + $crate::host::Fault, + > { + nexum::host::identity::accounts() + .map_err($crate::host::Fault::from)? + .iter() + .map(|account| $crate::host::account_from_wire(account)) + .collect() + } + fn sign( + &self, + account: $crate::prelude::Address, + message: &[u8], + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign(account.as_slice(), message) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + fn sign_typed_data( + &self, + account: $crate::prelude::Address, + typed_data: &str, + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign_typed_data(account.as_slice(), typed_data) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + } + }; (local_store) => { impl $crate::host::LocalStoreHost for WitBindgenHost { fn get( @@ -212,6 +264,75 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } }; + (remote_store) => { + impl $crate::host::RemoteStoreHost for WitBindgenHost { + fn upload( + &self, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = + nexum::host::remote_store::upload(data).map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + fn download( + &self, + reference: $crate::prelude::B256, + ) -> ::core::result::Result<::std::vec::Vec, $crate::host::Fault> { + nexum::host::remote_store::download(reference.as_slice()) + .map_err($crate::host::Fault::from) + } + fn read_feed( + &self, + owner: $crate::prelude::Address, + topic: $crate::prelude::B256, + ) -> ::core::result::Result< + ::core::option::Option<::std::vec::Vec>, + $crate::host::Fault, + > { + nexum::host::remote_store::read_feed(owner.as_slice(), topic.as_slice()) + .map_err($crate::host::Fault::from) + } + fn write_feed( + &self, + topic: $crate::prelude::B256, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = nexum::host::remote_store::write_feed(topic.as_slice(), data) + .map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + } + }; + (messaging) => { + impl $crate::host::MessagingHost for WitBindgenHost { + fn publish( + &self, + content_topic: &str, + payload: &[u8], + ) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::messaging::publish(content_topic, payload) + .map_err($crate::host::Fault::from) + } + fn query( + &self, + content_topic: &str, + start_time: ::core::option::Option, + end_time: ::core::option::Option, + limit: ::core::option::Option, + ) -> ::core::result::Result<::std::vec::Vec<$crate::host::Message>, $crate::host::Fault> + { + let messages = + nexum::host::messaging::query(content_topic, start_time, end_time, limit) + .map_err($crate::host::Fault::from)?; + ::core::result::Result::Ok( + messages + .into_iter() + .map(::core::convert::Into::into) + .collect(), + ) + } + } + }; (logging) => { impl $crate::host::LoggingHost for WitBindgenHost { fn log(&self, level: $crate::Level, message: &str) { diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/nexum-venue-test/src/transport.rs index e90e4b24..37f9e7c1 100644 --- a/crates/nexum-venue-test/src/transport.rs +++ b/crates/nexum-venue-test/src/transport.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use nexum_sdk::host::{ChainError, ChainHost, Fault}; use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; -pub use nexum_sdk_test::{ChainCall, MockChain}; +pub use nexum_sdk_test::{ChainCall, MockChain, MockMessaging, PublishRecord}; pub use videre_sdk::transport::{Message, MessagingHost}; /// Composed in-memory transport. Each field exposes the per-seam mock @@ -68,141 +68,6 @@ impl Fetch for MockTransport { } } -// ------------------------------------------------------------ messaging - -/// One recorded [`MessagingHost::publish`] invocation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PublishRecord { - /// Content topic the adapter published to. - pub content_topic: String, - /// Payload bytes, verbatim. - pub payload: Vec, -} - -/// In-memory [`MessagingHost`]. Seeded messages answer queries, -/// publishes are recorded for assertion, and an optional topic scope -/// mirrors the host's `messaging_topics` grant. Seeded history and -/// published records are deliberately separate stores: a query answers -/// from what the test seeded, never from what the adapter published. -#[derive(Default)] -pub struct MockMessaging { - history: RefCell>, - published: RefCell>, - scope: RefCell>>, - faults: RefCell>, -} - -impl MockMessaging { - /// Seed one message into the queryable history. - pub fn seed(&self, message: Message) { - self.history.borrow_mut().push(message); - } - - /// Seed a payload on `content_topic` at `timestamp` (ms since the - /// Unix epoch, UTC), with no sender. - pub fn seed_payload( - &self, - content_topic: impl Into, - payload: impl Into>, - timestamp: u64, - ) { - self.seed(Message { - content_topic: content_topic.into(), - payload: payload.into(), - timestamp, - sender: None, - }); - } - - /// Confine the mock to `topics`, mirroring the adapter's - /// `messaging_topics` grant: any other topic fails as - /// [`Fault::Denied`]. Untouched, every topic is allowed. - pub fn scope_topics(&self, topics: impl IntoIterator>) { - *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); - } - - /// Inject a fault for any operation on a topic starting with - /// `prefix`. Multiple patterns can be registered; the first - /// matching one fires. - pub fn fail_on(&self, prefix: impl Into, fault: Fault) { - self.faults.borrow_mut().push((prefix.into(), fault)); - } - - /// All publishes received, in arrival order. - pub fn published(&self) -> Vec { - self.published.borrow().clone() - } - - /// Last publish received, if any. - pub fn last_published(&self) -> Option { - self.published.borrow().last().cloned() - } - - /// Total publish count. - pub fn publish_count(&self) -> usize { - self.published.borrow().len() - } - - fn admit(&self, content_topic: &str) -> Result<(), Fault> { - for (prefix, fault) in self.faults.borrow().iter() { - if content_topic.starts_with(prefix.as_str()) { - return Err(fault.clone()); - } - } - if let Some(scope) = self.scope.borrow().as_ref() - && !scope.iter().any(|topic| topic == content_topic) - { - return Err(Fault::Denied(format!( - "MockMessaging: {content_topic} is outside the scoped topics" - ))); - } - Ok(()) - } -} - -impl MessagingHost for MockMessaging { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.admit(content_topic)?; - self.published.borrow_mut().push(PublishRecord { - content_topic: content_topic.to_owned(), - payload: payload.to_vec(), - }); - Ok(()) - } - - /// Answer from the seeded history: exact-topic matches whose - /// timestamp lies within the inclusive `start_time..=end_time` - /// window, in seed order. Seed order is delivery order, so a - /// `limit` keeps the newest matches: the tail. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.admit(content_topic)?; - let mut matches: Vec = self - .history - .borrow() - .iter() - .filter(|message| { - message.content_topic == content_topic - && start_time.is_none_or(|start| message.timestamp >= start) - && end_time.is_none_or(|end| message.timestamp <= end) - }) - .cloned() - .collect(); - if let Some(limit) = limit { - let keep = usize::try_from(limit).unwrap_or(usize::MAX); - if matches.len() > keep { - matches.drain(..matches.len() - keep); - } - } - Ok(matches) - } -} - // ------------------------------------------------------------ http /// One recorded [`Fetch::fetch_with`] invocation. @@ -316,75 +181,6 @@ impl Fetch for MockFetch { mod tests { use super::*; - #[test] - fn messaging_records_publishes_and_answers_from_seeds() { - let messaging = MockMessaging::default(); - messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); - messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); - messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); - - messaging.publish("/acme/1/orders/proto", b"out").unwrap(); - assert_eq!(messaging.publish_count(), 1); - assert_eq!( - messaging.last_published().unwrap(), - PublishRecord { - content_topic: "/acme/1/orders/proto".to_owned(), - payload: b"out".to_vec(), - }, - ); - - // Publishes never leak into query results. - let all = messaging - .query("/acme/1/orders/proto", None, None, None) - .unwrap(); - assert_eq!(all.len(), 2); - assert_eq!(all[0].payload, b"one"); - assert_eq!(all[1].payload, b"two"); - } - - #[test] - fn messaging_query_applies_bounds_and_limit() { - let messaging = MockMessaging::default(); - for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { - messaging.seed_payload("/t", payload.to_vec(), ts); - } - - let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); - assert_eq!(window.len(), 2); - assert_eq!(window[0].payload, b"b"); - - // A limit keeps the newest matches: the tail of the window. - let limited = messaging.query("/t", None, None, Some(2)).unwrap(); - assert_eq!(limited.len(), 2); - assert_eq!(limited[0].payload, b"c"); - assert_eq!(limited[1].payload, b"d"); - } - - #[test] - fn messaging_scope_denies_off_grant_topics() { - let messaging = MockMessaging::default(); - messaging.scope_topics(["/acme/1/orders/proto"]); - - messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); - let err = messaging.publish("/other", b"no").unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - let err = messaging.query("/other", None, None, None).unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - // The refused publish was never recorded. - assert_eq!(messaging.publish_count(), 1); - } - - #[test] - fn messaging_fault_injection_fires_by_prefix() { - let messaging = MockMessaging::default(); - messaging.fail_on("/flaky", Fault::Timeout); - assert!(matches!( - messaging.publish("/flaky/topic", b"x").unwrap_err(), - Fault::Timeout, - )); - messaging.publish("/steady", b"x").unwrap(); - } - #[test] fn fetch_returns_programmed_response_and_records_the_request() { let fetch = MockFetch::default(); diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 2b39f860..01971b2d 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -47,7 +47,7 @@ pub const CORE: &[Capability] = &[ name: "identity", import: Some("nexum:host/identity@0.1.0"), packages: &[], - adapter: None, + adapter: Some("identity"), }, Capability { name: "local-store", @@ -59,13 +59,13 @@ pub const CORE: &[Capability] = &[ name: "remote-store", import: Some("nexum:host/remote-store@0.1.0"), packages: &[], - adapter: None, + adapter: Some("remote_store"), }, Capability { name: "messaging", import: Some("nexum:host/messaging@0.1.0"), packages: &[], - adapter: None, + adapter: Some("messaging"), }, Capability { name: "logging", @@ -405,6 +405,32 @@ mod tests { assert!(CORE.iter().all(|c| c.packages.is_empty())); } + #[test] + fn every_import_bearing_core_row_carries_an_adapter() { + // `http` has no world import (SDK wasi:http client) and no + // adapter; every other core row has both. + for cap in CORE { + assert_eq!(cap.import.is_some(), cap.adapter.is_some(), "{}", cap.name); + } + } + + #[test] + fn full_declaration_emits_the_six_adapters_in_core_order() { + let declared: Vec = CORE.iter().map(|c| c.name.to_string()).collect(); + let world = synthesize(&declared, &[]).unwrap(); + assert_eq!( + world.adapters, + vec![ + "chain", + "identity", + "local_store", + "remote_store", + "messaging", + "logging", + ], + ); + } + #[test] fn http_declares_no_world_import() { let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index 2e08b74f..e6a8468b 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -50,8 +50,14 @@ use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; -use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; +use nexum_sdk::host::{ + ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, + MessagingHost, RemoteStoreHost, +}; +use nexum_sdk::prelude::{Address, B256, Signature}; +use nexum_sdk_test::{ + MockChain, MockIdentity, MockLocalStore, MockLogging, MockMessaging, MockRemoteStore, +}; use shepherd_sdk::cow::{CowApiError, CowApiHost}; /// Composed in-memory host for CoW modules: the generic per-trait @@ -63,8 +69,14 @@ use shepherd_sdk::cow::{CowApiError, CowApiHost}; pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, + /// `nexum:host/identity` mock. + pub identity: MockIdentity, /// `nexum:host/local-store` mock. pub store: MockLocalStore, + /// `nexum:host/remote-store` mock. + pub remote_store: MockRemoteStore, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, /// `shepherd:cow/cow-api` mock. pub cow_api: V, /// `nexum:host/logging` mock. @@ -106,6 +118,49 @@ impl LocalStoreHost for MockHost { } } +impl IdentityHost for MockHost { + fn accounts(&self) -> Result, Fault> { + self.identity.accounts() + } + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.identity.sign(account, message) + } + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.identity.sign_typed_data(account, typed_data) + } +} + +impl RemoteStoreHost for MockHost { + fn upload(&self, data: &[u8]) -> Result { + self.remote_store.upload(data) + } + fn download(&self, reference: B256) -> Result, Fault> { + self.remote_store.download(reference) + } + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.remote_store.read_feed(owner, topic) + } + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.remote_store.write_feed(topic, data) + } +} + +impl MessagingHost for MockHost { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } +} + impl CowApiHost for MockHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { self.cow_api.submit_order(chain_id, body) diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs index fa7060d2..ff4b7e5d 100644 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ b/crates/shepherd-sdk/src/wit_bindgen_macro.rs @@ -2,11 +2,10 @@ //! CoW modules: the generic adapter plus the `CowApiHost` impl. //! //! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the -//! core adapter (`WitBindgenHost`, the `ChainHost` / `LocalStoreHost` -//! / `LoggingHost` impls, the fault and level `From` impls, and the -//! tracing wiring). This macro invokes it and adds the -//! [`CowApiHost`](crate::cow::CowApiHost) impl over the -//! `shepherd:cow/cow-api` import shims. +//! core adapter (`WitBindgenHost`, the six core host trait impls, the +//! fault and level `From` impls, and the tracing wiring). This macro +//! invokes it and adds the [`CowApiHost`](crate::cow::CowApiHost) +//! impl over the `shepherd:cow/cow-api` import shims. //! //! The macro assumes the module compiles against `shepherd:cow/shepherd` //! with `wit_bindgen::generate!({ ..., generate_all })`, so both the diff --git a/crates/videre-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs index a6b8f905..a5d62bd1 100644 --- a/crates/videre-sdk/src/transport.rs +++ b/crates/videre-sdk/src/transport.rs @@ -87,26 +87,9 @@ fn chain_error_into_sdk(err: chain::ChainError) -> ChainError { } } -/// `nexum:host/messaging` - publish to and query the venue's content -/// topics. The seam between adapter logic and the messaging transport; -/// [`HostMessaging`] is the bound impl. -pub trait MessagingHost { - /// Publish a payload to a content topic - /// (`////`). A topic outside the - /// adapter's `messaging_topics` scope fails as [`Fault::Denied`]. - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; - - /// Query historical messages from the store protocol, newest window - /// bounded by the optional `start_time` / `end_time` (ms since the - /// Unix epoch, UTC) and `limit`. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault>; -} +/// The messaging seam and its message mirror, canonical in the module +/// SDK; [`HostMessaging`] is this crate's bound impl. +pub use nexum_sdk::host::{Message, MessagingHost}; /// The adapter's `nexum:host/messaging` import behind the /// [`MessagingHost`] seam. @@ -131,21 +114,6 @@ impl MessagingHost for HostMessaging { } } -/// One delivered message, mirrored from `nexum:host/types.message` so -/// the [`MessagingHost`] seam stays mockable without naming bindgen -/// types. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Message { - /// Content topic the message arrived on. - pub content_topic: String, - /// Opaque payload bytes. - pub payload: Vec, - /// Delivery timestamp, ms since the Unix epoch, UTC. - pub timestamp: u64, - /// Optional sender identity (protocol-dependent). - pub sender: Option>, -} - impl From for Message { fn from(message: crate::bindings::nexum::host::types::Message) -> Self { Self { diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 7b89ece6..ee3ae410 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -9,7 +9,7 @@ //! ## Module layout //! //! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` +//! the `nexum_sdk::host` trait seam. It does not know `wit-bindgen` //! exists. //! - `lib.rs` (this file) declares the handlers and defers the //! per-cdylib glue to `#[nexum_sdk::module]`. diff --git a/modules/examples/balance-tracker/src/strategy.rs b/modules/examples/balance-tracker/src/strategy.rs index 91e25193..f1f5bf73 100644 --- a/modules/examples/balance-tracker/src/strategy.rs +++ b/modules/examples/balance-tracker/src/strategy.rs @@ -1,11 +1,13 @@ //! Pure strategy logic for the balance-tracker module. //! -//! Every interaction with the world flows through the [`Host`] trait -//! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- -//! generated free functions live here. The `lib.rs` glue wraps a -//! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen -//! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. +//! Every interaction with the world flows through the host trait +//! seam exposed by `nexum-sdk`, bounded to exactly the interfaces the +//! module declares ([`ChainHost`] + [`LocalStoreHost`]) - no direct +//! calls to wit-bindgen-generated free functions live here. The +//! `lib.rs` glue wraps a `WitBindgenHost` adapter around the module's +//! per-cdylib wit-bindgen imports and hands it to [`on_block`]; tests +//! under `#[cfg(test)]` hand the same function a +//! `nexum_sdk_test::MockHost`. //! //! Aligns balance-tracker with the M3 "host trait + adapter" recipe //! the other four modules already follow (PR #55 review). Previously @@ -15,7 +17,7 @@ use nexum_sdk::address::parse_address_list; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{Fault, Host}; +use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost}; use nexum_sdk::prelude::{Address, U256}; /// Resolved settings parsed from `[config]` at `init` and read on @@ -35,7 +37,11 @@ pub struct Settings { /// Each address is independent; a single flaky `eth_getBalance` does /// not abort the loop - the failure is logged and the next address is /// still polled. -pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result<(), Fault> { +pub fn on_block( + host: &H, + chain_id: u64, + settings: &Settings, +) -> Result<(), Fault> { for addr in &settings.addresses { if let Err(err) = check_one(host, chain_id, *addr, settings.change_threshold) { tracing::warn!("balance-tracker {addr:#x}: {err}"); @@ -47,7 +53,7 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result /// Poll one address: fetch latest balance, diff against the last /// stored value, emit a log if the delta crosses `threshold`, then /// persist the new value under `balance:{addr}`. -fn check_one( +fn check_one( host: &H, chain_id: u64, addr: Address, @@ -74,7 +80,7 @@ fn check_one( } /// `chain::request("eth_getBalance", [addr, "latest"])` -> `U256`. -fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { +fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { let params = format!("[\"{addr:#x}\",\"latest\"]"); let result_json = host.request(chain_id, "eth_getBalance", ¶ms)?; parse_balance_hex(&result_json).ok_or_else(|| { @@ -149,7 +155,7 @@ fn config_err(e: ConfigError) -> Fault { mod tests { use super::*; use nexum_sdk::Level; - use nexum_sdk::host::{ChainError, Fault, LocalStoreHost as _}; + use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::prelude::address; use nexum_sdk_test::{MockHost, capture_tracing}; From 632b7b9a03806befa1ee95200b83fbeb6d8281e5 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 07:27:23 +0000 Subject: [PATCH 33/53] sdk: split the macro crate into nexum-module-macros and videre-macros #[module] stays L1 in nexum-module-macros; #[venue] and derive(IntentBody) move to videre-macros with the venue-world synthesis. nexum-sdk re-exports module from nexum-module-macros; videre-sdk re-exports venue and IntentBody from videre-macros. No macro behaviour change. --- .github/workflows/ci.yml | 2 +- Cargo.lock | 16 +- Cargo.toml | 9 +- .../Cargo.toml | 4 +- .../src/lib.rs | 313 ++---------------- crates/nexum-sdk/Cargo.toml | 2 +- crates/nexum-sdk/src/lib.rs | 4 +- crates/videre-macros/Cargo.toml | 19 ++ .../src/intent_body.rs | 0 crates/videre-macros/src/lib.rs | 311 +++++++++++++++++ .../src/world.rs | 11 +- crates/videre-sdk/Cargo.toml | 6 +- crates/videre-sdk/src/lib.rs | 8 +- docs/05-sdk-design.md | 14 +- docs/sdk.md | 6 +- 15 files changed, 399 insertions(+), 326 deletions(-) rename crates/{nexum-macros => nexum-module-macros}/Cargo.toml (81%) rename crates/{nexum-macros => nexum-module-macros}/src/lib.rs (50%) create mode 100644 crates/videre-macros/Cargo.toml rename crates/{nexum-macros => videre-macros}/src/intent_body.rs (100%) create mode 100644 crates/videre-macros/src/lib.rs rename crates/{nexum-macros => videre-macros}/src/world.rs (93%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6207c455..c5d2fa81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ env: # Dockerfile build, and the local host sccache — one silo, zero egress cost. # Set at the workflow env level (not the composite) because composite actions # cannot read the `secrets` context. Keys are content-addressed on the full - # compiler input, and the sole build.rs (cow-venue) + nexum-macros are + # compiler input, and the sole build.rs (cow-venue) + the macro crates are # deterministic, so PR builds writing to the shared bucket write correct objects # under correct keys (no poisoning); bound storage with an R2 lifecycle-expiry # rule on the bucket (sccache does not evict cloud backends itself). diff --git a/Cargo.lock b/Cargo.lock index 43c1425c..5e215771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3592,7 +3592,7 @@ dependencies = [ ] [[package]] -name = "nexum-macros" +name = "nexum-module-macros" version = "0.1.0" dependencies = [ "nexum-world", @@ -3653,7 +3653,7 @@ dependencies = [ "alloy-sol-types", "alloy-transport", "http", - "nexum-macros", + "nexum-module-macros", "nexum-sdk-test", "nexum-status-body", "proptest", @@ -6059,16 +6059,26 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "videre-macros" +version = "0.1.0" +dependencies = [ + "nexum-world", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "videre-sdk" version = "0.1.0" dependencies = [ "borsh", - "nexum-macros", "nexum-sdk", "nexum-sdk-test", "strum", "thiserror 2.0.18", + "videre-macros", "wit-bindgen 0.59.0", ] diff --git a/Cargo.toml b/Cargo.toml index 13067951..d8e1be24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = [ "crates/cow-venue", "crates/nexum-cli", "crates/nexum-launch", - "crates/nexum-macros", + "crates/nexum-module-macros", "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", @@ -18,6 +18,7 @@ members = [ "crates/shepherd-sdk", "crates/shepherd-sdk-test", "crates/videre-host", + "crates/videre-macros", "crates/videre-sdk", "modules/ethflow-watcher", "modules/example", @@ -141,9 +142,9 @@ http-body = "1" http-body-util = "0.1" bytes = "1" -# Proc-macro toolkit backing `nexum-macros`. Host-side only: the -# proc-macro crate always builds for the host, even when the module -# consuming it targets wasm. +# Proc-macro toolkit backing `nexum-module-macros` and `videre-macros`. +# Host-side only: a proc-macro crate always builds for the host, even +# when the module consuming it targets wasm. proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-module-macros/Cargo.toml similarity index 81% rename from crates/nexum-macros/Cargo.toml rename to crates/nexum-module-macros/Cargo.toml index ec7785bd..4bef46f5 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-module-macros/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "nexum-macros" +name = "nexum-module-macros" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export; derive(IntentBody) emits the venue SDK's versioned body codec." +description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export." [lib] proc-macro = true diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs similarity index 50% rename from crates/nexum-macros/src/lib.rs rename to crates/nexum-module-macros/src/lib.rs index ec26d462..bb90eb72 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -7,44 +7,15 @@ //! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation //! whose `on-event` dispatches to the handlers present, and `export!`. //! -//! [`venue`] is the adapter counterpart: it emits the same per-cdylib -//! wit-bindgen and `export!`, but for a per-component venue-adapter -//! world exporting the `videre:venue/adapter` face and importing only -//! the manifest's declared scoped transport. +//! The venue-side macros (`#[venue]`, `derive(IntentBody)`) live in +//! `videre-macros`. //! -//! [`derive@IntentBody`] implements the venue SDK's versioned body codec -//! over a per-venue version enum. -//! -//! Consumers reach these through the SDK re-exports (`nexum_sdk::module`, -//! `videre_sdk::venue`, `videre_sdk::IntentBody`) rather than -//! depending on this crate directly. - -mod intent_body; -mod world; +//! Consumers reach this through the SDK re-export (`nexum_sdk::module`) +//! rather than depending on this crate directly. use proc_macro::TokenStream; use quote::quote; -use syn::{DeriveInput, ImplItem, ItemImpl, Type}; - -/// Derive the venue SDK's `IntentBody` codec on the outer per-venue -/// version enum: one newtype variant per published body version, each -/// payload a borsh type. -/// -/// The wire form is the borsh enum layout (a one-byte 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`. -/// -/// Generated code resolves the SDK by crate path, so use the -/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a -/// direct dependency. -#[proc_macro_derive(IntentBody)] -pub fn derive_intent_body(input: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(input as DeriveInput); - intent_body::expand(&input) - .unwrap_or_else(syn::Error::into_compile_error) - .into() -} +use syn::{ImplItem, ItemImpl, Type}; /// The handler names recognised on a `#[module]` impl. Any method not in /// this set is left untouched on the type, except that names starting @@ -271,251 +242,12 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// The associated functions the `videre:venue/adapter` face mandates. A -/// venue adapter must define all five; `init` is separate (a no-op when -/// absent, exactly as in a module). -const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", "cancel"]; - -/// Generate the per-cdylib glue for a venue adapter. -/// -/// Apply to an inherent `impl` block whose associated functions are the -/// adapter face: `derive_header`, `quote`, `submit`, `status`, `cancel` -/// (all required, from `videre:venue/adapter`), plus an optional `init` -/// (absent means a no-op) and an optional `body_versions` (absent -/// declares none). Each takes and returns the per-cdylib -/// wit-bindgen payloads for its signature. The macro reads the crate's -/// `module.toml`, synthesizes a per-component world exporting the -/// adapter face and importing exactly the manifest's declared scoped -/// transport, then emits `wit_bindgen::generate!`, the `Guest` impls -/// wiring the world to the adapter's functions, and `export!` around the -/// untouched impl. So the built component imports what the manifest -/// declares and nothing else, retiring the toolchain-elision dependency -/// on the venue side. -/// -/// A venue's capabilities are scoped transport only: 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 same crate-root resolution invariants as [`macro@module`] apply: -/// the wit-bindgen output lands at the module crate root (so the emitted -/// glue resolves `Guest`, `Fault`, and the `nexum::*`/`videre::*` type modules -/// there), the consuming crate must declare `wit-bindgen` as a direct -/// dependency, and the crate root must not shadow std prelude names. -#[proc_macro_attribute] -pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { - if !attr.is_empty() { - return syn::Error::new( - proc_macro2::Span::call_site(), - "#[videre_sdk::venue] takes no arguments", - ) - .to_compile_error() - .into(); - } - - let input = syn::parse_macro_input!(item as ItemImpl); - - let self_ty = &input.self_ty; - if !is_plain_type(self_ty) { - return syn::Error::new_spanned( - self_ty, - "#[videre_sdk::venue] must be applied to an inherent impl of a named type", - ) - .to_compile_error() - .into(); - } - if let Some((_, trait_path, _)) = &input.trait_ { - return syn::Error::new_spanned( - trait_path, - "#[videre_sdk::venue] must be applied to an inherent impl, not a trait impl", - ) - .to_compile_error() - .into(); - } - if !input.generics.params.is_empty() { - return syn::Error::new_spanned( - &input.generics, - "#[videre_sdk::venue] must be applied to a non-generic impl", - ) - .to_compile_error() - .into(); - } - - let defines = |name: &str| { - input - .items - .iter() - .any(|item| matches!(item, ImplItem::Fn(f) if f.sig.ident == name)) - }; - let missing: Vec<&str> = VENUE_EXPORTS - .into_iter() - .filter(|name| !defines(name)) - .collect(); - if !missing.is_empty() { - return syn::Error::new_spanned( - self_ty, - format!( - "#[videre_sdk::venue] requires the adapter face; this impl is missing {:?}. \ - Define all of `derive_header`, `quote`, `submit`, `status`, `cancel` (plus an \ - optional `init`)", - missing - ), - ) - .to_compile_error() - .into(); - } - - let (manifest_path, venue_world) = match derive_venue_world() { - Ok(parts) => parts, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let wit_paths = match resolve_wit_packages(&venue_world.packages) { - Ok(paths) => paths, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let inline_world = &venue_world.wit; - - // `body-versions` is a required adapter export; when the adapter - // omits it, declare none. Install asserts the export equals the - // manifest `[venue] body_versions` set. - let body_versions_impl = if defines("body_versions") { - quote! { - fn body_versions() -> ::std::vec::Vec { - <#self_ty>::body_versions() - } - } - } else { - quote! { - fn body_versions() -> ::std::vec::Vec { - ::std::vec::Vec::new() - } - } - }; - - // `init` is a required world export; when the adapter omits it the - // config is bound but unused, so drop it to stay warning-clean. - let init_impl = if defines("init") { - quote! { - fn init( - config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - <#self_ty>::init(config) - } - } - } else { - quote! { - fn init( - _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - ::core::result::Result::Ok(()) - } - } - }; - - quote! { - // Anchor a rebuild on the manifest: the emitted world is derived - // from it, so an edited [capabilities] must recompile the adapter. - const _: &[u8] = ::core::include_bytes!(#manifest_path); - - wit_bindgen::generate!({ - inline: #inline_world, - path: [#(#wit_paths),*], - world: "nexum:venue-world/venue-adapter", - generate_all, - }); - - #input - - #[doc(hidden)] - struct __NexumVenueAdapterExport; - - impl Guest for __NexumVenueAdapterExport { - #init_impl - } - - impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { - #body_versions_impl - - fn derive_header( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::IntentHeader, - videre::types::types::VenueError, - > { - <#self_ty>::derive_header(body) - } - - fn quote( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::Quotation, - videre::types::types::VenueError, - > { - <#self_ty>::quote(body) - } - - fn submit( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::SubmitOutcome, - videre::types::types::VenueError, - > { - <#self_ty>::submit(body) - } - - fn status( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::IntentStatus, - videre::types::types::VenueError, - > { - <#self_ty>::status(receipt) - } - - fn cancel( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result<(), videre::types::types::VenueError> { - <#self_ty>::cancel(receipt) - } - } - - export!(__NexumVenueAdapterExport); - } - .into() -} - /// Whether a type is a plain named path (`Foo`), the only shape a module /// export type may take. fn is_plain_type(ty: &Type) -> bool { matches!(ty, Type::Path(tp) if tp.qself.is_none()) } -/// Read the consuming crate's `module.toml` and return its declared -/// capability names alongside the manifest path (for the rebuild -/// anchor). Shared by the module and venue worlds, which differ only in -/// how they turn the declarations into a world. -fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), String> { - let manifest_path = manifest_dir()?.join("module.toml"); - let text = std::fs::read_to_string(&manifest_path).map_err(|e| { - format!( - "could not read {} ({e}); {attribute} derives the component's WIT world from the \ - manifest's [capabilities] section, so the manifest must sit next to Cargo.toml", - manifest_path.display() - ) - })?; - let declared = world::manifest_capabilities(&text) - .map_err(|e| format!("{}: {e}", manifest_path.display()))?; - Ok((manifest_path.to_string_lossy().into_owned(), declared)) -} - /// The consuming crate's manifest directory, the root every crate-local /// lookup starts from. fn manifest_dir() -> Result { @@ -529,36 +261,37 @@ fn manifest_dir() -> Result { /// extension rows registered in the nearest ancestor `extensions.toml`. /// Returns the rebuild anchor paths (the manifest, then the registry /// when one exists) alongside the world. -fn derive_module_world() -> Result<(Vec, world::ModuleWorld), String> { - let (manifest_path, declared) = read_manifest_capabilities("#[nexum_sdk::module]")?; +fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { + let manifest_path = manifest_dir()?.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[nexum_sdk::module] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let manifest_path = manifest_path.to_string_lossy().into_owned(); + let mut anchors = vec![manifest_path.clone()]; - let extensions = match world::find_extensions_manifest(&manifest_dir()?) { + let extensions = match nexum_world::find_extensions_manifest(&manifest_dir()?) { None => Vec::new(), Some(registry) => { let text = std::fs::read_to_string(®istry) .map_err(|e| format!("could not read {}: {e}", registry.display()))?; - let rows = world::manifest_extensions(&text) + let rows = nexum_world::manifest_extensions(&text) .map_err(|e| format!("{}: {e}", registry.display()))?; anchors.push(registry.to_string_lossy().into_owned()); rows } }; - let module_world = - world::synthesize(&declared, &extensions).map_err(|e| format!("{manifest_path}: {e}"))?; + let module_world = nexum_world::synthesize(&declared, &extensions) + .map_err(|e| format!("{manifest_path}: {e}"))?; Ok((anchors, module_world)) } -/// Read the consuming crate's `module.toml` and synthesize the -/// per-component venue-adapter world from its `[capabilities]` -/// declarations. Returns the manifest path (for the rebuild anchor) -/// alongside the world. -fn derive_venue_world() -> Result<(String, world::ModuleWorld), String> { - let (manifest_path, declared) = read_manifest_capabilities("#[videre_sdk::venue]")?; - let venue_world = - world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((manifest_path, venue_world)) -} - /// Resolve each needed WIT package directory crate-locally (vendored /// `wit/deps/`, then own `wit/`), falling back through /// ancestors for the transitional monorepo layout. diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 429aa4b5..c4dfb6b5 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -22,7 +22,7 @@ stderr-echo = [] # Re-exported as `nexum_sdk::module`; the proc-macro emits glue that # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). -nexum-macros = { path = "../nexum-macros" } +nexum-module-macros = { path = "../nexum-module-macros" } # Decoder for the opaque status body an `intent-status` event carries; # re-exported as `nexum_sdk::status_body`. nexum-status-body = { path = "../nexum-status-body" } diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 8042efa3..b766be1f 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -128,8 +128,8 @@ /// Generate the per-cdylib module glue (wit-bindgen, host adapter, /// `Guest`/`on-event` dispatch, `export!`) from an `impl` block of named -/// handlers. See [`nexum_macros::module`]. -pub use nexum_macros::module; +/// handlers. See [`nexum_module_macros::module`]. +pub use nexum_module_macros::module; pub mod address; pub mod chain; diff --git a/crates/videre-macros/Cargo.toml b/crates/videre-macros/Cargo.toml new file mode 100644 index 00000000..0b65cbde --- /dev/null +++ b/crates/videre-macros/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "videre-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Proc-macro glue for videre venue adapters: #[venue] emits the per-cdylib wit-bindgen and adapter export; derive(IntentBody) emits the versioned body codec." + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +nexum-world = { path = "../nexum-world" } +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/videre-macros/src/intent_body.rs similarity index 100% rename from crates/nexum-macros/src/intent_body.rs rename to crates/videre-macros/src/intent_body.rs diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs new file mode 100644 index 00000000..feb1b620 --- /dev/null +++ b/crates/videre-macros/src/lib.rs @@ -0,0 +1,311 @@ +//! Proc-macro glue for videre venue adapters. +//! +//! [`venue`] emits the per-cdylib wit-bindgen and `export!` for a +//! per-component venue-adapter world exporting the +//! `videre:venue/adapter` face and importing only the manifest's +//! declared scoped transport. +//! +//! [`derive@IntentBody`] implements the venue SDK's versioned body codec +//! over a per-venue version enum. +//! +//! The module-side macro (`#[module]`) lives in `nexum-module-macros`. +//! +//! Consumers reach these through the SDK re-exports +//! (`videre_sdk::venue`, `videre_sdk::IntentBody`) rather than +//! depending on this crate directly. + +mod intent_body; +mod world; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, ImplItem, ItemImpl, Type}; + +/// Derive the venue SDK's `IntentBody` codec on the outer per-venue +/// version enum: one newtype variant per published body version, each +/// payload a borsh type. +/// +/// The wire form is the borsh enum layout (a one-byte 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`. +/// +/// Generated code resolves the SDK by crate path, so use the +/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a +/// direct dependency. +#[proc_macro_derive(IntentBody)] +pub fn derive_intent_body(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + intent_body::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// The associated functions the `videre:venue/adapter` face mandates. A +/// venue adapter must define all five; `init` is separate (a no-op when +/// absent, exactly as in a module). +const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", "cancel"]; + +/// Generate the per-cdylib glue for a venue adapter. +/// +/// Apply to an inherent `impl` block whose associated functions are the +/// adapter face: `derive_header`, `quote`, `submit`, `status`, `cancel` +/// (all required, from `videre:venue/adapter`), plus an optional `init` +/// (absent means a no-op) and an optional `body_versions` (absent +/// declares none). Each takes and returns the per-cdylib +/// wit-bindgen payloads for its signature. The macro reads the crate's +/// `module.toml`, synthesizes a per-component world exporting the +/// adapter face and importing exactly the manifest's declared scoped +/// transport, then emits `wit_bindgen::generate!`, the `Guest` impls +/// wiring the world to the adapter's functions, and `export!` around the +/// untouched impl. So the built component imports what the manifest +/// declares and nothing else, retiring the toolchain-elision dependency +/// on the venue side. +/// +/// A venue's capabilities are scoped transport only: 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 same crate-root resolution invariants as `#[module]` apply: the +/// wit-bindgen output lands at the module crate root (so the emitted +/// glue resolves `Guest`, `Fault`, and the `nexum::*`/`videre::*` type modules +/// there), the consuming crate must declare `wit-bindgen` as a direct +/// dependency, and the crate root must not shadow std prelude names. +#[proc_macro_attribute] +pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[videre_sdk::venue] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + let self_ty = &input.self_ty; + if !is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[videre_sdk::venue] must be applied to an inherent impl of a named type", + ) + .to_compile_error() + .into(); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return syn::Error::new_spanned( + trait_path, + "#[videre_sdk::venue] must be applied to an inherent impl, not a trait impl", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::venue] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + let defines = |name: &str| { + input + .items + .iter() + .any(|item| matches!(item, ImplItem::Fn(f) if f.sig.ident == name)) + }; + let missing: Vec<&str> = VENUE_EXPORTS + .into_iter() + .filter(|name| !defines(name)) + .collect(); + if !missing.is_empty() { + return syn::Error::new_spanned( + self_ty, + format!( + "#[videre_sdk::venue] requires the adapter face; this impl is missing {:?}. \ + Define all of `derive_header`, `quote`, `submit`, `status`, `cancel` (plus an \ + optional `init`)", + missing + ), + ) + .to_compile_error() + .into(); + } + + let (manifest_path, venue_world) = match derive_venue_world() { + Ok(parts) => parts, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let wit_paths = match resolve_wit_packages(&venue_world.packages) { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let inline_world = &venue_world.wit; + + // `body-versions` is a required adapter export; when the adapter + // omits it, declare none. Install asserts the export equals the + // manifest `[venue] body_versions` set. + let body_versions_impl = if defines("body_versions") { + quote! { + fn body_versions() -> ::std::vec::Vec { + <#self_ty>::body_versions() + } + } + } else { + quote! { + fn body_versions() -> ::std::vec::Vec { + ::std::vec::Vec::new() + } + } + }; + + // `init` is a required world export; when the adapter omits it the + // config is bound but unused, so drop it to stay warning-clean. + let init_impl = if defines("init") { + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + <#self_ty>::init(config) + } + } + } else { + quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + } + }; + + quote! { + // Anchor a rebuild on the manifest: the emitted world is derived + // from it, so an edited [capabilities] must recompile the adapter. + const _: &[u8] = ::core::include_bytes!(#manifest_path); + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:venue-world/venue-adapter", + generate_all, + }); + + #input + + #[doc(hidden)] + struct __NexumVenueAdapterExport; + + impl Guest for __NexumVenueAdapterExport { + #init_impl + } + + impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { + #body_versions_impl + + fn derive_header( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + videre::types::types::IntentHeader, + videre::types::types::VenueError, + > { + <#self_ty>::derive_header(body) + } + + fn quote( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + videre::types::types::Quotation, + videre::types::types::VenueError, + > { + <#self_ty>::quote(body) + } + + fn submit( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + videre::types::types::SubmitOutcome, + videre::types::types::VenueError, + > { + <#self_ty>::submit(body) + } + + fn status( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result< + videre::types::types::IntentStatus, + videre::types::types::VenueError, + > { + <#self_ty>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), videre::types::types::VenueError> { + <#self_ty>::cancel(receipt) + } + } + + export!(__NexumVenueAdapterExport); + } + .into() +} + +/// Whether a type is a plain named path (`Foo`), the only shape a module +/// export type may take. +fn is_plain_type(ty: &Type) -> bool { + matches!(ty, Type::Path(tp) if tp.qself.is_none()) +} + +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + +/// Read the consuming crate's `module.toml` and synthesize the +/// per-component venue-adapter world from its `[capabilities]` +/// declarations. Returns the manifest path (for the rebuild anchor) +/// alongside the world. +fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { + let manifest_path = manifest_dir()?.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[videre_sdk::venue] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let manifest_path = manifest_path.to_string_lossy().into_owned(); + let venue_world = + world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((manifest_path, venue_world)) +} + +/// Resolve each needed WIT package directory crate-locally (vendored +/// `wit/deps/`, then own `wit/`), falling back through +/// ancestors for the transitional monorepo layout. +fn resolve_wit_packages(packages: &[String]) -> Result, String> { + Ok( + nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ) +} diff --git a/crates/nexum-macros/src/world.rs b/crates/videre-macros/src/world.rs similarity index 93% rename from crates/nexum-macros/src/world.rs rename to crates/videre-macros/src/world.rs index 82de0717..30a059ad 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/videre-macros/src/world.rs @@ -1,11 +1,8 @@ -//! World wiring for the macros: the venue-adapter world synthesis. The -//! module world synthesis, the core capability table, and the extension -//! registry parsing (`extensions.toml`, the composition root's data) -//! live in `nexum-world`, so no crate here carries a downstream name. +//! World wiring for the venue macro: the venue-adapter world synthesis. +//! The module world synthesis, the core capability table, and the +//! extension registry parsing live in `nexum-world`. -pub use nexum_world::{ - ModuleWorld, find_extensions_manifest, manifest_capabilities, manifest_extensions, synthesize, -}; +pub use nexum_world::ModuleWorld; /// Capabilities a venue adapter may import. A venue speaks one venue's /// protocol over scoped transport and nothing else: chain RPC, diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 674804aa..47b77c88 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -21,9 +21,9 @@ workspace = true # the derive's generated code so an adapter crate needs no direct borsh # declaration unless its payloads derive the borsh traits themselves. borsh.workspace = true -# Source of the `IntentBody` derive, re-exported at the crate root next -# to the trait it implements. -nexum-macros = { path = "../nexum-macros" } +# Source of `#[venue]` and the `IntentBody` derive, re-exported at the +# crate root next to the trait they implement against. +videre-macros = { path = "../videre-macros" } # Host-neutral SDK layer this crate builds on: the `ChainHost` seam the # chain wrapper implements, the shared `Fault` vocabulary, and the # wasi:http `fetch` surface re-exported as `transport::http`. diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index c268e7ca..e4d1da82 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -71,20 +71,20 @@ pub use client::{ClientError, IntentClient, Quoted, VenueClient, VenueId}; pub use faults::VenueFault; pub use keeper::{Keeper, Sweep, SweepReport}; /// Derive [`IntentBody`] on the outer per-venue version enum. See -/// [`nexum_macros::IntentBody`]. -pub use nexum_macros::IntentBody; +/// [`videre_macros::IntentBody`]. +pub use videre_macros::IntentBody; /// Emit the per-cdylib export glue and per-component world for a venue /// adapter. Apply to an inherent `impl` of the adapter face /// (`derive_header`, `quote`, `submit`, `status`, `cancel`, plus an /// optional `init`); the built component imports exactly the manifest's declared -/// scoped transport. See [`nexum_macros::venue`]. +/// scoped transport. See [`videre_macros::venue`]. /// /// The self-contained per-cdylib alternative to /// [`export_venue_adapter!`]: that macro exports through this crate's /// shared blanket-world bindgen (chain and messaging always imported, /// relying on toolchain elision), whereas `#[venue]` derives a narrowed /// world from the manifest and generates its own bindings. -pub use nexum_macros::venue; +pub use videre_macros::venue; /// The intent ontology at its plain spellings: the types the /// [`VenueAdapter`] face and the client core speak. diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 52ed08d0..1722ca1a 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -12,7 +12,8 @@ For the architectural decision behind the host-trait seam that the module-author persona builds on, see [ADR-0009](adr/0009-host-trait-surface.md). For the rustdoc-level API reference (the source of truth once you are writing module code), see [`sdk.md`](sdk.md) and the rustdoc under -`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and `crates/nexum-macros/`. +`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and +`crates/nexum-module-macros/`. ## The two personas @@ -38,7 +39,8 @@ things from the SDK: tree yet. See [Venue-adapter persona (planned)](#venue-adapter-persona-planned) below for the shape of the plan. -Both personas share one proc-macro crate, `nexum-macros`, and the +Each persona has its own proc-macro crate (`nexum-module-macros` for +modules, `videre-macros` for venue adapters) and 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` @@ -52,7 +54,7 @@ toolchain or a running wasmtime instance. nexum-sdk/ ├── Cargo.toml └── src/ - ├── lib.rs # crate docs, `pub use nexum_macros::module` + ├── lib.rs # crate docs, `pub use nexum_module_macros::module` ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) ├── host.rs # ChainHost / LocalStoreHost / LoggingHost + supertrait Host; Fault, ChainError, RpcError ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters @@ -65,7 +67,7 @@ nexum-sdk/ ├── tracing.rs # guest tracing facade + panic hook over a LogSink seam └── proptests.rs # cfg(test) property tests (not part of the public surface) -nexum-macros/ +nexum-module-macros/ ├── Cargo.toml # proc-macro = true └── src/ └── lib.rs # #[module] attribute macro @@ -153,7 +155,7 @@ layers the `CowApiHost` impl on top of the same `WitBindgenHost` type. ### The `#[nexum::module]` macro -`nexum-macros` ships one attribute macro, re-exported as +`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 @@ -268,7 +270,7 @@ The planned shape: CoW Protocol's intent-body codec and become the eventual home for the CoW helpers `shepherd-sdk::cow` carries today, once the clean break happens. -- **`#[nexum::venue]`** - a second attribute macro in `nexum-macros`, +- **`#[nexum::venue]`** - a second attribute macro, in `videre-macros`, parallel to `#[nexum::module]`: it would emit the per-cdylib export glue for an adapter and a per-component world matching the manifest's declared capabilities (retiring the import-elision diff --git a/docs/sdk.md b/docs/sdk.md index b27045af..c4f07f2f 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -13,13 +13,13 @@ site under `target/doc/nexum_sdk/` and `target/doc/shepherd_sdk/`, generated by: ```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-macros --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-module-macros --no-deps --open ``` ## Authoring a module Modules are authored with the `#[nexum_sdk::module]` attribute -(re-exported from `nexum-macros`). Apply it to an inherent `impl` +(re-exported from `nexum-module-macros`). Apply it to an inherent `impl` block whose associated functions are the named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - each taking its wit-bindgen payload and returning `Result<(), Fault>`. @@ -28,7 +28,7 @@ The macro generates the rest of the per-cdylib glue: the adapter, a `Guest` impl whose `on_event` dispatches to whichever handlers are present (absent handlers no-op), and `export!`. See [doc 05](05-sdk-design.md#the-nexummodule-macro) for a worked -example and the `nexum-macros` rustdoc for the fine print. +example and the `nexum-module-macros` rustdoc for the fine print. ## Supported host capabilities From 525a1f52497450c8da588722e53a000570add6b5 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 07:26:58 +0000 Subject: [PATCH 34/53] local-store: add contains, len and count metadata queries Answer existence, value size and key cardinality without transferring the payload across the wasm boundary. The SDK trait ships default bodies over get/list-keys so backends opt into a cheaper path; the redb backend answers contains and len from the entry in place and count from a bounded range scan. --- .../nexum-runtime/src/host/component/state.rs | 27 ++++++++ .../src/host/impls/local_store.rs | 12 ++++ .../src/host/local_store_redb.rs | 43 +++++++++++++ .../src/host/local_store_redb/tests.rs | 41 ++++++++++++ crates/nexum-sdk-test/src/lib.rs | 64 +++++++++++++++++++ crates/nexum-sdk/src/host.rs | 54 ++++++++++++++++ crates/nexum-sdk/src/wit_bindgen_macro.rs | 12 ++++ crates/shepherd-sdk-test/src/lib.rs | 10 +++ wit/nexum-host/local-store.wit | 11 ++++ 9 files changed, 274 insertions(+) diff --git a/crates/nexum-runtime/src/host/component/state.rs b/crates/nexum-runtime/src/host/component/state.rs index b3213f7f..635ca0b1 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/crates/nexum-runtime/src/host/component/state.rs @@ -29,6 +29,21 @@ pub trait StateHandle { fn delete(&self, key: &str) -> Result<(), StorageError>; /// Enumerate module-visible keys starting with `prefix`. fn list_keys(&self, prefix: &str) -> Result, StorageError>; + /// Whether `key` exists. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, StorageError> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } impl StateStore for LocalStore { @@ -59,4 +74,16 @@ impl StateHandle for ModuleStore { fn list_keys(&self, prefix: &str) -> Result, StorageError> { ModuleStore::list_keys(self, prefix) } + + fn contains(&self, key: &str) -> Result { + ModuleStore::contains(self, key) + } + + fn len(&self, key: &str) -> Result, StorageError> { + ModuleStore::len(self, key) + } + + fn count(&self, prefix: &str) -> Result { + ModuleStore::count(self, prefix) + } } diff --git a/crates/nexum-runtime/src/host/impls/local_store.rs b/crates/nexum-runtime/src/host/impls/local_store.rs index 32a17f09..e5abc7c2 100644 --- a/crates/nexum-runtime/src/host/impls/local_store.rs +++ b/crates/nexum-runtime/src/host/impls/local_store.rs @@ -21,4 +21,16 @@ impl nexum::host::local_store::Host for HostState { async fn list_keys(&mut self, prefix: String) -> Result, Fault> { self.store.list_keys(&prefix).map_err(Fault::from) } + + async fn contains(&mut self, key: String) -> Result { + self.store.contains(&key).map_err(Fault::from) + } + + async fn len(&mut self, key: String) -> Result, Fault> { + self.store.len(&key).map_err(Fault::from) + } + + async fn count(&mut self, prefix: String) -> Result { + self.store.count(&prefix).map_err(Fault::from) + } } diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index bd7e16c7..03610763 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -105,6 +105,49 @@ impl ModuleStore { Ok(value) } + /// Whether `key` exists, without copying the value out. + pub fn contains(&self, key: &str) -> Result { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .is_some()) + } + + /// Value byte length for `key`, `Ok(None)` when absent. Reads the + /// entry's length in place; the value bytes are never copied out. + pub fn len(&self, key: &str) -> Result, StorageError> { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .map(|v| v.value().len() as u64)) + } + + /// Number of module-visible keys starting with `prefix`. A bounded + /// B-tree range scan: no key strings are materialised. + pub fn count(&self, prefix: &str) -> Result { + let full_prefix = self.build_key(prefix); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + let mut count = 0u64; + for entry in table + .range(full_prefix.as_slice()..) + .map_err(StorageError::Storage)? + { + let (k, _v) = entry.map_err(StorageError::Storage)?; + if !k.value().starts_with(&full_prefix) { + break; + } + count += 1; + } + Ok(count) + } + /// Insert or overwrite. Under a quota, charges on-disk cost (prefix, key, /// value, overhead) and rejects an over-quota write untouched. The commit /// is fsync-durable. diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/crates/nexum-runtime/src/host/local_store_redb/tests.rs index 1191d8d1..3e1fd159 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/crates/nexum-runtime/src/host/local_store_redb/tests.rs @@ -66,6 +66,47 @@ fn list_keys_strips_namespace_prefix() { assert!(keys.iter().all(|k| k.starts_with("posted:"))); } +#[test] +fn contains_answers_without_the_value() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("k", b"v").unwrap(); + assert!(ms.contains("k").unwrap()); + assert!(!ms.contains("missing").unwrap()); + ms.delete("k").unwrap(); + assert!(!ms.contains("k").unwrap()); +} + +#[test] +fn len_reports_value_bytes_or_none() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("empty", b"").unwrap(); + ms.set("k", b"abcde").unwrap(); + assert_eq!(ms.len("empty").unwrap(), Some(0)); + assert_eq!(ms.len("k").unwrap(), Some(5)); + assert_eq!(ms.len("missing").unwrap(), None); +} + +#[test] +fn count_matches_list_keys_and_respects_namespaces() { + let (_dir, store) = fresh(); + let a = store.module("a").unwrap(); + let b = store.module("b").unwrap(); + a.set("posted:1", b"x").unwrap(); + a.set("posted:2", b"y").unwrap(); + a.set("other", b"z").unwrap(); + b.set("posted:9", b"w").unwrap(); + assert_eq!(a.count("posted:").unwrap(), 2); + assert_eq!(a.count("").unwrap(), 3); + assert_eq!(a.count("nope:").unwrap(), 0); + assert_eq!(b.count("posted:").unwrap(), 1); + assert_eq!( + a.count("posted:").unwrap(), + a.list_keys("posted:").unwrap().len() as u64 + ); +} + #[test] fn rejects_empty_namespace() { let (_dir, store) = fresh(); diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 2605ced4..a520855f 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -122,6 +122,16 @@ impl LocalStoreHost for MockHost { fn list_keys(&self, prefix: &str) -> Result, Fault> { self.store.list_keys(prefix) } + fn contains(&self, key: &str) -> Result { + self.store.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + // Qualified: the mock's inherent `len` counts rows. + LocalStoreHost::len(&self.store, key) + } + fn count(&self, prefix: &str) -> Result { + self.store.count(prefix) + } } impl IdentityHost for MockHost { @@ -742,6 +752,33 @@ impl LocalStoreHost for MockLocalStore { keys.sort(); Ok(keys) } + fn contains(&self, key: &str) -> Result { + self.check_injected_error(key)?; + Ok(self + .shared + .rows + .borrow() + .contains_key(&(self.namespace.clone(), key.to_string()))) + } + fn len(&self, key: &str) -> Result, Fault> { + self.check_injected_error(key)?; + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .map(|v| v.len() as u64)) + } + fn count(&self, prefix: &str) -> Result { + self.check_injected_error(prefix)?; + Ok(self + .shared + .rows + .borrow() + .keys() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .count() as u64) + } } // ---------------------------------------------------------------- logging @@ -1103,6 +1140,33 @@ mod tests { assert!(log.contains("uh oh")); } + #[test] + fn local_store_metadata_queries() { + let store = MockLocalStore::default(); + store.set("watch:a", b"abc").unwrap(); + store.set("watch:b", b"").unwrap(); + store.set("posted:1", b"x").unwrap(); + + assert!(store.contains("watch:a").unwrap()); + assert!(!store.contains("missing").unwrap()); + assert_eq!(LocalStoreHost::len(&store, "watch:a").unwrap(), Some(3)); + assert_eq!(LocalStoreHost::len(&store, "watch:b").unwrap(), Some(0)); + assert_eq!(LocalStoreHost::len(&store, "missing").unwrap(), None); + assert_eq!(store.count("watch:").unwrap(), 2); + assert_eq!(store.count("").unwrap(), 3); + + // Metadata queries stay namespace-scoped. + let other = store.namespaced("other"); + assert_eq!(other.count("").unwrap(), 0); + assert!(!other.contains("watch:a").unwrap()); + + // And respect fault injection. + store.fail_on("bad:", Fault::Internal("injected".into())); + assert!(store.contains("bad:k").is_err()); + assert!(LocalStoreHost::len(&store, "bad:k").is_err()); + assert!(store.count("bad:").is_err()); + } + #[test] fn local_store_error_injection() { let store = MockLocalStore::default(); diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 949c5eec..82b562d8 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -193,6 +193,21 @@ pub trait LocalStoreHost { fn delete(&self, key: &str) -> Result<(), Fault>; /// Enumerate keys whose raw form starts with `prefix`. fn list_keys(&self, prefix: &str) -> Result, Fault>; + /// Whether `key` exists. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, Fault> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } /// `nexum:host/logging` - structured runtime logs. @@ -400,6 +415,45 @@ mod tests { signature_from_wire, }; + #[test] + fn local_store_metadata_defaults_derive_from_required_methods() { + use super::LocalStoreHost; + + /// Two fixed rows; only the four required methods are written. + struct TwoRows; + impl LocalStoreHost for TwoRows { + fn get(&self, key: &str) -> Result>, Fault> { + Ok(match key { + "a" => Some(b"abc".to_vec()), + "b" => Some(Vec::new()), + _ => None, + }) + } + fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { + Ok(()) + } + fn delete(&self, _: &str) -> Result<(), Fault> { + Ok(()) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + Ok(["a", "b"] + .iter() + .filter(|k| k.starts_with(prefix)) + .map(|k| (*k).to_owned()) + .collect()) + } + } + + assert!(TwoRows.contains("a").unwrap()); + assert!(!TwoRows.contains("missing").unwrap()); + assert_eq!(TwoRows.len("a").unwrap(), Some(3)); + assert_eq!(TwoRows.len("b").unwrap(), Some(0)); + assert_eq!(TwoRows.len("missing").unwrap(), None); + assert_eq!(TwoRows.count("").unwrap(), 2); + assert_eq!(TwoRows.count("a").unwrap(), 1); + assert_eq!(TwoRows.count("z").unwrap(), 0); + } + #[test] fn wire_lifts_accept_exact_lengths() { let account = account_from_wire(&[0x11; 20]).unwrap(); diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 81f1c141..586df413 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -262,6 +262,18 @@ macro_rules! __bind_host_cap_via_wit_bindgen { { nexum::host::local_store::list_keys(prefix).map_err($crate::host::Fault::from) } + fn contains(&self, key: &str) -> ::core::result::Result { + nexum::host::local_store::contains(key).map_err($crate::host::Fault::from) + } + fn len( + &self, + key: &str, + ) -> ::core::result::Result<::core::option::Option, $crate::host::Fault> { + nexum::host::local_store::len(key).map_err($crate::host::Fault::from) + } + fn count(&self, prefix: &str) -> ::core::result::Result { + nexum::host::local_store::count(prefix).map_err($crate::host::Fault::from) + } } }; (remote_store) => { diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index e6a8468b..b0a8dd22 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -116,6 +116,16 @@ impl LocalStoreHost for MockHost { fn list_keys(&self, prefix: &str) -> Result, Fault> { self.store.list_keys(prefix) } + fn contains(&self, key: &str) -> Result { + self.store.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + // Qualified: the mock's inherent `len` counts rows. + LocalStoreHost::len(&self.store, key) + } + fn count(&self, prefix: &str) -> Result { + self.store.count(prefix) + } } impl IdentityHost for MockHost { diff --git a/wit/nexum-host/local-store.wit b/wit/nexum-host/local-store.wit index d0b54921..8521b37a 100644 --- a/wit/nexum-host/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -15,4 +15,15 @@ interface local-store { /// List all keys matching a prefix. Empty prefix returns all keys. list-keys: func(prefix: string) -> result, fault>; + + /// Whether the key exists, without transferring the value. + contains: func(key: string) -> result; + + /// Value byte length, none if the key is absent, without + /// transferring the value. On some backends this may be a scan. + len: func(key: string) -> result, fault>; + + /// Number of keys matching a prefix, without materialising the key + /// list. On some backends this may be a scan. + count: func(prefix: string) -> result; } From ca21ba6bc6ac0e0056ea383bacb4fd3e736c173c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 08:27:16 +0000 Subject: [PATCH 35/53] sdk: make the venue macro the single blessed authoring path #[videre_sdk::venue] now takes the impl VenueAdapter block itself: it asserts the manifest kind, keeps the manifest-derived world narrowing, remaps the type interfaces onto the SDK bindings for type identity, and expands to the demoted internal export codegen. export_venue_adapter! is no longer public API; echo-venue and flaky-venue author through the trait, the golden bridges are gone, and the cow body codec is held to the kit's typed vectors. --- Cargo.lock | 5 +- crates/cow-venue/Cargo.toml | 2 + crates/cow-venue/src/body.rs | 96 ++++++----- crates/nexum-venue-test/src/header.rs | 8 +- crates/nexum-venue-test/src/lib.rs | 10 +- crates/nexum-world/src/lib.rs | 33 ++++ crates/videre-macros/Cargo.toml | 2 +- crates/videre-macros/src/lib.rs | 214 +++++++----------------- crates/videre-sdk/src/adapter.rs | 34 ++-- crates/videre-sdk/src/bindings.rs | 12 +- crates/videre-sdk/src/lib.rs | 33 ++-- crates/videre-sdk/tests/adapter.rs | 14 +- modules/examples/echo-venue/Cargo.toml | 2 +- modules/examples/echo-venue/src/lib.rs | 68 ++------ modules/fixtures/flaky-venue/Cargo.toml | 2 +- modules/fixtures/flaky-venue/src/lib.rs | 11 +- 16 files changed, 229 insertions(+), 317 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e215771..173bbae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,7 @@ version = "0.1.0" dependencies = [ "borsh", "nexum-sdk", + "nexum-venue-test", "serde", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", @@ -2164,7 +2165,7 @@ version = "0.1.0" dependencies = [ "nexum-venue-test", "videre-sdk", - "wit-bindgen 0.58.0", + "wit-bindgen 0.59.0", ] [[package]] @@ -2382,7 +2383,7 @@ name = "flaky-venue" version = "0.1.0" dependencies = [ "videre-sdk", - "wit-bindgen 0.58.0", + "wit-bindgen 0.59.0", ] [[package]] diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index c7ab7fb4..8365b586 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -39,6 +39,8 @@ thiserror = { workspace = true } serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } +# The conformance kit: holds the body codec to its published vector set. +nexum-venue-test = { path = "../nexum-venue-test" } [features] # The body-type + codec slice ships by default; the `client` slice layers diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 8caed852..b346fd5e 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -36,7 +36,7 @@ pub enum CowIntentBody { #[cfg(test)] mod tests { use super::*; - use videre_sdk::BodyError; + use nexum_venue_test::{CodecVectors, Expectation}; use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; @@ -65,16 +65,52 @@ mod tests { } } + /// The codec conformance set: both v1 intents as round-trip vectors + /// plus the typed failure contract, in the kit's published form. + fn vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors + .push_round_trip( + "v1-order", + &CowIntentBody::V1(CowIntent::Order(order_body())), + ) + .expect("order body encodes"); + vectors + .push_round_trip( + "v1-composable", + &CowIntentBody::V1(CowIntent::Composable(composable_body())), + ) + .expect("composable body encodes"); + + let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); + let mut unknown = bytes(CowIntent::Order(order_body())); + unknown[0] = 9; + vectors.push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + let mut truncated = bytes(CowIntent::Order(order_body())); + truncated.truncate(truncated.len() - 1); + vectors.push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 0 }, + ); + let mut trailing = bytes(CowIntent::Composable(composable_body())); + trailing.push(0); + vectors.push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ); + vectors + } + #[test] - fn version_body_round_trips_through_the_derive() { - for intent in [ - CowIntent::Order(order_body()), - CowIntent::Composable(composable_body()), - ] { - let body = CowIntentBody::V1(intent); - let bytes = body.to_bytes().expect("derived payload encodes"); - assert_eq!(CowIntentBody::from_bytes(&bytes).unwrap(), body); - } + fn codec_conforms_to_its_vectors() { + vectors().assert_conforms::(); } #[test] @@ -86,37 +122,15 @@ mod tests { } #[test] - fn unknown_version_fails_typedly() { - let mut bytes = CowIntentBody::V1(CowIntent::Order(order_body())) - .to_bytes() - .unwrap(); - bytes[0] = 9; - assert_eq!( - CowIntentBody::from_bytes(&bytes), - Err(BodyError::UnknownVersion { version: 9 }) + fn divergent_codec_is_caught_by_the_vectors() { + // A vector claiming a different typed failure must fail the + // check, proving it has teeth on this schema. + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors.push_failure( + "empty", + Vec::new(), + Expectation::UnknownVersion { version: 1 }, ); - } - - #[test] - fn empty_and_malformed_bodies_fail_typedly() { - assert_eq!(CowIntentBody::from_bytes(&[]), Err(BodyError::Empty)); - - let mut bytes = CowIntentBody::V1(CowIntent::Order(order_body())) - .to_bytes() - .unwrap(); - bytes.truncate(bytes.len() - 1); - assert!(matches!( - CowIntentBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 0, .. }) - )); - - let mut bytes = CowIntentBody::V1(CowIntent::Composable(composable_body())) - .to_bytes() - .unwrap(); - bytes.push(0); - assert!(matches!( - CowIntentBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 0, .. }) - )); + assert!(vectors.check::().is_err()); } } diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index 5791859e..91063dc4 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -7,11 +7,9 @@ //! (JSON, a leading format version that fails closed on an unknown tag, //! kebab-case case names matching the WIT, bytes as lowercase hex, //! never zero goldens). The mirrors exist because wit-bindgen types -//! carry no serde; -//! [`GoldenHeader`] converts from the venue SDK's `IntentHeader`, and a -//! macro-built adapter whose bindgen mints its own header type bridges -//! with a field-for-field `From` impl on its crate boundary, the same -//! pattern `nexum-sdk-test` documents for `Fault`. +//! carry no serde; [`GoldenHeader`] converts from the venue SDK's +//! `IntentHeader`, which macro-built adapters speak too, so an +//! adapter's `derive_header` feeds the check directly. use std::fmt; use std::path::Path; diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/nexum-venue-test/src/lib.rs index a4c4fd83..9eae0b8e 100644 --- a/crates/nexum-venue-test/src/lib.rs +++ b/crates/nexum-venue-test/src/lib.rs @@ -52,11 +52,11 @@ //! //! ## Macro-built adapters //! -//! `#[nexum::venue]` adapters mint their own bindgen header type. The -//! codec check is unaffected (bodies are plain Rust types); for the -//! golden check, bridge with a field-for-field `From for -//! GoldenHeader` impl on the adapter crate's boundary, the same -//! trivial-converter pattern `nexum-sdk-test` documents for `Fault`. +//! `#[videre_sdk::venue]` adapters speak the SDK's own types (the +//! macro remaps the type interfaces onto `videre_sdk::bindings`), so +//! both checks apply directly: pass `MyAdapter::derive_header` to +//! [`HeaderGoldens::assert_conforms`] and the derived enum to +//! [`CodecVectors::assert_conforms`]. No bridge types. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 01971b2d..aae0b9db 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -144,6 +144,21 @@ pub fn manifest_capabilities(text: &str) -> Result, String> { Ok(names) } +/// Extract the declared `[module] kind` from the manifest text, `None` +/// when absent (the runtime defaults an absent kind to the worker). +pub fn manifest_kind(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + match value.get("module").and_then(|module| module.get("kind")) { + None => Ok(None), + Some(kind) => kind + .as_str() + .map(|kind| Some(kind.to_owned())) + .ok_or_else(|| "[module].kind must be a string".to_string()), + } +} + /// Parse the registered extension rows from an `extensions.toml`. Each /// `[extensions.]` table carries the WIT `import` the declaration /// turns into and the extra `packages` its resolve path needs. A file @@ -513,6 +528,24 @@ allow = [] assert_eq!(caps, vec!["logging", "chain", "remote-store"]); } + #[test] + fn manifest_kind_reads_the_module_kind() { + let kind = manifest_kind("[module]\nname = \"x\"\nkind = \"venue-adapter\"\n").unwrap(); + assert_eq!(kind.as_deref(), Some("venue-adapter")); + } + + #[test] + fn manifest_without_a_kind_is_none() { + assert_eq!(manifest_kind("[module]\nname = \"x\"\n").unwrap(), None); + assert_eq!(manifest_kind("").unwrap(), None); + } + + #[test] + fn manifest_with_a_non_string_kind_is_an_error() { + let err = manifest_kind("[module]\nkind = 3\n").unwrap_err(); + assert!(err.contains("[module].kind must be a string")); + } + #[test] fn manifest_without_capabilities_section_is_an_error() { let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); diff --git a/crates/videre-macros/Cargo.toml b/crates/videre-macros/Cargo.toml index 0b65cbde..31f0b9a3 100644 --- a/crates/videre-macros/Cargo.toml +++ b/crates/videre-macros/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Proc-macro glue for videre venue adapters: #[venue] emits the per-cdylib wit-bindgen and adapter export; derive(IntentBody) emits the versioned body codec." +description = "Proc-macro glue for videre venue adapters: #[venue] turns an impl VenueAdapter into the per-cdylib wit-bindgen and adapter export; derive(IntentBody) emits the versioned body codec." [lib] proc-macro = true diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs index feb1b620..77f83e0c 100644 --- a/crates/videre-macros/src/lib.rs +++ b/crates/videre-macros/src/lib.rs @@ -1,9 +1,9 @@ //! Proc-macro glue for videre venue adapters. //! -//! [`venue`] emits the per-cdylib wit-bindgen and `export!` for a -//! per-component venue-adapter world exporting the -//! `videre:venue/adapter` face and importing only the manifest's -//! declared scoped transport. +//! [`venue`] is the single blessed venue authoring path: applied to an +//! `impl VenueAdapter` block it emits the per-cdylib wit-bindgen for a +//! manifest-derived world exporting `videre:venue/adapter`, asserts the +//! manifest kind, and expands to the SDK's internal export codegen. //! //! [`derive@IntentBody`] implements the venue SDK's versioned body codec //! over a per-venue version enum. @@ -19,7 +19,7 @@ mod world; use proc_macro::TokenStream; use quote::quote; -use syn::{DeriveInput, ImplItem, ItemImpl, Type}; +use syn::{DeriveInput, ItemImpl, Type}; /// Derive the venue SDK's `IntentBody` codec on the outer per-venue /// version enum: one newtype variant per published body version, each @@ -41,26 +41,26 @@ pub fn derive_intent_body(input: TokenStream) -> TokenStream { .into() } -/// The associated functions the `videre:venue/adapter` face mandates. A -/// venue adapter must define all five; `init` is separate (a no-op when -/// absent, exactly as in a module). -const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", "cancel"]; +/// The manifest `kind` a venue adapter must declare. Mirrors the +/// venue-adapter provider kind's manifest spelling. +const VENUE_KIND: &str = "venue-adapter"; /// Generate the per-cdylib glue for a venue adapter. /// -/// Apply to an inherent `impl` block whose associated functions are the -/// adapter face: `derive_header`, `quote`, `submit`, `status`, `cancel` -/// (all required, from `videre:venue/adapter`), plus an optional `init` -/// (absent means a no-op) and an optional `body_versions` (absent -/// declares none). Each takes and returns the per-cdylib -/// wit-bindgen payloads for its signature. The macro reads the crate's -/// `module.toml`, synthesizes a per-component world exporting the -/// adapter face and importing exactly the manifest's declared scoped -/// transport, then emits `wit_bindgen::generate!`, the `Guest` impls -/// wiring the world to the adapter's functions, and `export!` around the -/// untouched impl. So the built component imports what the manifest -/// declares and nothing else, retiring the toolchain-elision dependency -/// on the venue side. +/// Apply 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 `wit_bindgen::generate!`, the +/// untouched trait impl, and the SDK's internal export codegen wiring +/// the world's `Guest` faces through the trait. So the built component +/// imports what the manifest declares and nothing else, by construction +/// of the emitted world. +/// +/// The generated world remaps `videre:types/types`, +/// `videre:value-flow/types`, and `nexum:host/types` onto the SDK's +/// bindings, so the impl speaks `videre_sdk` types directly and shares +/// type identity with the conformance kit and the client core. /// /// A venue's capabilities are scoped transport only: an undeclared /// capability's bindings do not exist (using one is a compile error), @@ -68,10 +68,10 @@ const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", /// `messaging`, `http`) is rejected at expansion. /// /// The same crate-root resolution invariants as `#[module]` apply: the -/// wit-bindgen output lands at the module crate root (so the emitted -/// glue resolves `Guest`, `Fault`, and the `nexum::*`/`videre::*` type modules -/// there), the consuming crate must declare `wit-bindgen` as a direct -/// dependency, and the crate root must not shadow std prelude names. +/// wit-bindgen output lands at the module crate root (so the export +/// codegen resolves `Guest`, `exports`, and `export!` there), and the +/// consuming crate must declare `wit-bindgen` and `videre-sdk` as +/// direct dependencies. #[proc_macro_attribute] pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { @@ -85,51 +85,39 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as ItemImpl); - let self_ty = &input.self_ty; - if !is_plain_type(self_ty) { + let Some((None, trait_path, _)) = &input.trait_ else { return syn::Error::new_spanned( - self_ty, - "#[videre_sdk::venue] must be applied to an inherent impl of a named type", + &input.self_ty, + "#[videre_sdk::venue] must be applied to an `impl VenueAdapter for ...` block", ) .to_compile_error() .into(); - } - if let Some((_, trait_path, _)) = &input.trait_ { + }; + if trait_path + .segments + .last() + .is_none_or(|segment| segment.ident != "VenueAdapter") + { return syn::Error::new_spanned( trait_path, - "#[videre_sdk::venue] must be applied to an inherent impl, not a trait impl", + "#[videre_sdk::venue] must be applied to an impl of `videre_sdk::VenueAdapter`", ) .to_compile_error() .into(); } - if !input.generics.params.is_empty() { + let self_ty = &input.self_ty; + if !is_plain_type(self_ty) { return syn::Error::new_spanned( - &input.generics, - "#[videre_sdk::venue] must be applied to a non-generic impl", + self_ty, + "#[videre_sdk::venue] must be applied to an impl on a named type", ) .to_compile_error() .into(); } - - let defines = |name: &str| { - input - .items - .iter() - .any(|item| matches!(item, ImplItem::Fn(f) if f.sig.ident == name)) - }; - let missing: Vec<&str> = VENUE_EXPORTS - .into_iter() - .filter(|name| !defines(name)) - .collect(); - if !missing.is_empty() { + if !input.generics.params.is_empty() { return syn::Error::new_spanned( - self_ty, - format!( - "#[videre_sdk::venue] requires the adapter face; this impl is missing {:?}. \ - Define all of `derive_header`, `quote`, `submit`, `status`, `cancel` (plus an \ - optional `init`)", - missing - ), + &input.generics, + "#[videre_sdk::venue] must be applied to a non-generic impl", ) .to_compile_error() .into(); @@ -153,43 +141,6 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { }; let inline_world = &venue_world.wit; - // `body-versions` is a required adapter export; when the adapter - // omits it, declare none. Install asserts the export equals the - // manifest `[venue] body_versions` set. - let body_versions_impl = if defines("body_versions") { - quote! { - fn body_versions() -> ::std::vec::Vec { - <#self_ty>::body_versions() - } - } - } else { - quote! { - fn body_versions() -> ::std::vec::Vec { - ::std::vec::Vec::new() - } - } - }; - - // `init` is a required world export; when the adapter omits it the - // config is bound but unused, so drop it to stay warning-clean. - let init_impl = if defines("init") { - quote! { - fn init( - config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - <#self_ty>::init(config) - } - } - } else { - quote! { - fn init( - _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - ::core::result::Result::Ok(()) - } - } - }; - quote! { // Anchor a rebuild on the manifest: the emitted world is derived // from it, so an edited [capabilities] must recompile the adapter. @@ -200,64 +151,17 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { path: [#(#wit_paths),*], world: "nexum:venue-world/venue-adapter", generate_all, + with: { + "nexum:host/types@0.1.0": ::videre_sdk::bindings::nexum::host::types, + "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, + "videre:value-flow/types@0.1.0": + ::videre_sdk::bindings::videre::value_flow::types, + }, }); #input - #[doc(hidden)] - struct __NexumVenueAdapterExport; - - impl Guest for __NexumVenueAdapterExport { - #init_impl - } - - impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { - #body_versions_impl - - fn derive_header( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::IntentHeader, - videre::types::types::VenueError, - > { - <#self_ty>::derive_header(body) - } - - fn quote( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::Quotation, - videre::types::types::VenueError, - > { - <#self_ty>::quote(body) - } - - fn submit( - body: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::SubmitOutcome, - videre::types::types::VenueError, - > { - <#self_ty>::submit(body) - } - - fn status( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result< - videre::types::types::IntentStatus, - videre::types::types::VenueError, - > { - <#self_ty>::status(receipt) - } - - fn cancel( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result<(), videre::types::types::VenueError> { - <#self_ty>::cancel(receipt) - } - } - - export!(__NexumVenueAdapterExport); + ::videre_sdk::__export_venue_adapter!(#self_ty); } .into() } @@ -276,10 +180,10 @@ fn manifest_dir() -> Result { .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) } -/// Read the consuming crate's `module.toml` and synthesize the -/// per-component venue-adapter world from its `[capabilities]` -/// declarations. Returns the manifest path (for the rebuild anchor) -/// alongside the world. +/// Read the consuming crate's `module.toml`, assert it declares the +/// venue-adapter kind, and synthesize the per-component venue-adapter +/// world from its `[capabilities]` declarations. Returns the manifest +/// path (for the rebuild anchor) alongside the world. fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { let manifest_path = manifest_dir()?.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { @@ -290,6 +194,16 @@ fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { manifest_path.display() ) })?; + let kind = nexum_world::manifest_kind(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + if kind.as_deref() != Some(VENUE_KIND) { + return Err(format!( + "{}: [module] kind must be \"{VENUE_KIND}\" for a #[videre_sdk::venue] adapter, \ + found {}", + manifest_path.display(), + kind.map_or_else(|| "none".to_owned(), |kind| format!("\"{kind}\"")), + )); + } let declared = nexum_world::manifest_capabilities(&text) .map_err(|e| format!("{}: {e}", manifest_path.display()))?; let manifest_path = manifest_path.to_string_lossy().into_owned(); diff --git a/crates/videre-sdk/src/adapter.rs b/crates/videre-sdk/src/adapter.rs index a2d58d9a..0e6f3edb 100644 --- a/crates/videre-sdk/src/adapter.rs +++ b/crates/videre-sdk/src/adapter.rs @@ -1,5 +1,5 @@ -//! The [`VenueAdapter`] trait and the export glue that turns an impl of -//! it into the component's `venue-adapter` world surface. +//! The [`VenueAdapter`] trait and the internal export codegen that turns +//! an impl of it into the component's `venue-adapter` world surface. //! //! The trait mirrors the world's export face one to one: `init` from the //! world itself, the intent functions and the body-version declaration @@ -11,8 +11,8 @@ use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; /// One venue's protocol speaker: the guest-side face of the -/// `venue-adapter` world. Implement it on a unit struct and hand that to -/// [`export_venue_adapter!`](crate::export_venue_adapter); bodies and +/// `venue-adapter` world. Implement it on a unit struct and apply +/// [`#[videre_sdk::venue]`](crate::venue) to the impl; bodies and /// receipts arrive as the opaque bytes the wire carries, and impls /// recover typing through [`IntentBody`](crate::IntentBody) (whose /// [`BodyError`](crate::BodyError) converts into [`VenueError`] via `?`). @@ -54,20 +54,20 @@ pub trait VenueAdapter { fn cancel(receipt: Vec) -> Result<(), VenueError>; } -/// Export a [`VenueAdapter`] impl as the crate's `venue-adapter` world. -/// -/// Invoke once at the top level of the adapter's cdylib crate. Emits a -/// hidden shim type wiring the world's `Guest` traits to the adapter's -/// associated functions, then the wit-bindgen export glue; the linker -/// rejects a second invocation in one component (duplicate export -/// symbols), matching the one-adapter-per-component contract. +/// Internal codegen `#[videre_sdk::venue]` expands to: a hidden shim +/// wiring a [`VenueAdapter`] impl to the macro-synthesized world's +/// `Guest` faces, then that world's `export!`. `Guest`, `exports`, and +/// `export!` resolve at the expansion site (the adapter crate root, +/// where the attribute put the world's bindgen), so the macro is +/// meaningful only inside the attribute's output. Not public API. +#[doc(hidden)] #[macro_export] -macro_rules! export_venue_adapter { +macro_rules! __export_venue_adapter { ($adapter:ty) => { #[doc(hidden)] struct __VidereVenueAdapterExport; - impl $crate::bindings::Guest for __VidereVenueAdapterExport { + impl Guest for __VidereVenueAdapterExport { fn init( config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, ) -> ::core::result::Result<(), $crate::Fault> { @@ -75,9 +75,7 @@ macro_rules! export_venue_adapter { } } - impl $crate::bindings::exports::videre::venue::adapter::Guest - for __VidereVenueAdapterExport - { + impl exports::videre::venue::adapter::Guest for __VidereVenueAdapterExport { fn body_versions() -> ::std::vec::Vec { <$adapter as $crate::VenueAdapter>::body_versions() } @@ -113,8 +111,6 @@ macro_rules! export_venue_adapter { } } - $crate::bindings::__export_venue_adapter_world!( - __VidereVenueAdapterExport with_types_in $crate::bindings - ); + export!(__VidereVenueAdapterExport); }; } diff --git a/crates/videre-sdk/src/bindings.rs b/crates/videre-sdk/src/bindings.rs index e735f93c..eb426521 100644 --- a/crates/videre-sdk/src/bindings.rs +++ b/crates/videre-sdk/src/bindings.rs @@ -4,11 +4,10 @@ //! the venue SDK generates the adapter world's bindings once, here: the //! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport //! wrappers, and the intent client core are all expressed over these -//! types, and [`export_venue_adapter!`](crate::export_venue_adapter) -//! emits the component export glue into the adapter's own cdylib via the -//! generated (hidden) export macro. Downstream bindgens wanting type -//! identity with this crate remap `videre:types/types` and -//! `videre:value-flow/types` onto these modules with `with`. +//! types. The `#[videre_sdk::venue]` attribute's per-cdylib bindgen +//! remaps `videre:types/types`, `videre:value-flow/types`, and +//! `nexum:host/types` onto these modules with `with`, so a macro-built +//! adapter shares type identity with the SDK and the conformance kit. wit_bindgen::generate!({ path: [ @@ -19,8 +18,5 @@ wit_bindgen::generate!({ ], world: "videre:venue/venue-adapter", generate_all, - pub_export_macro: true, - export_macro_name: "__export_venue_adapter_world", - default_bindings_module: "videre_sdk::bindings", additional_derives: [PartialEq], }); diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index e4d1da82..4c34ada2 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -9,9 +9,9 @@ //! ## What lives here //! //! - [`VenueAdapter`] - the trait mirroring the world's export face -//! (`init` plus the five intent functions), and -//! [`export_venue_adapter!`] which turns an impl into the component's -//! export glue. +//! (`init` plus the five intent functions). `#[videre_sdk::venue]` +//! on the impl turns it into the component's export glue: the single +//! blessed authoring path. //! //! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh //! codec over the outer per-venue version enum. The wire form is a @@ -42,11 +42,11 @@ //! //! ## Why the bindgen lives in this crate //! -//! Unlike event modules (per-cdylib `wit_bindgen::generate!`), the -//! adapter world's bindings generate once, in [`bindings`]: the trait, -//! wrappers, and client core are all typed over them, and the export -//! macro reaches back in via `with_types_in`. An adapter crate therefore -//! needs no wit-bindgen dependency and no world knowledge of its own. +//! The adapter world's types generate once, in [`bindings`]: the trait, +//! wrappers, and client core are all typed over them. `#[venue]`'s +//! per-cdylib bindgen remaps the type interfaces onto [`bindings`], so +//! an adapter speaks these types while its world imports stay derived +//! from its own manifest. //! //! [`ChainHost`]: nexum_sdk::host::ChainHost //! [`IntentClient`]: client::IntentClient @@ -73,17 +73,12 @@ pub use keeper::{Keeper, Sweep, SweepReport}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`videre_macros::IntentBody`]. pub use videre_macros::IntentBody; -/// Emit the per-cdylib export glue and per-component world for a venue -/// adapter. Apply to an inherent `impl` of the adapter face -/// (`derive_header`, `quote`, `submit`, `status`, `cancel`, plus an -/// optional `init`); the built component imports exactly the manifest's declared -/// scoped transport. See [`videre_macros::venue`]. -/// -/// The self-contained per-cdylib alternative to -/// [`export_venue_adapter!`]: that macro exports through this crate's -/// shared blanket-world bindgen (chain and messaging always imported, -/// relying on toolchain elision), whereas `#[venue]` derives a narrowed -/// world from the manifest and generates its own bindings. +/// The single blessed venue authoring path. Apply to the adapter's +/// `impl VenueAdapter for MyVenue` block: emits the per-cdylib bindgen +/// for a world derived from `module.toml` (asserting its +/// `kind = "venue-adapter"`), the `videre:venue/adapter` export glue, +/// and `export!`. The built component imports exactly the manifest's +/// declared scoped transport. See [`videre_macros::venue`]. pub use videre_macros::venue; /// The intent ontology at its plain spellings: the types the diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index 8af3621f..e71fe381 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -1,9 +1,9 @@ //! Acceptance surface for the venue SDK: a hand-written adapter -//! compiles against [`VenueAdapter`], exports through -//! `export_venue_adapter!`, and round-trips a versioned body through -//! `#[derive(IntentBody)]` - including the typed unknown-version -//! failure and the typed client core driving the adapter through the -//! [`VenueClient`] seam. +//! compiles against [`VenueAdapter`] and round-trips a versioned body +//! through `#[derive(IntentBody)]` - including the typed +//! unknown-version failure and the typed client core driving the +//! adapter through the [`VenueClient`] seam. The world-export glue is +//! `#[videre_sdk::venue]`'s alone; echo-venue is its worked target. use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::value_flow::{Asset, AssetAmount}; @@ -115,10 +115,6 @@ impl VenueAdapter for DemoAdapter { } } -// The acceptance gate proper: the hand-written adapter exports as the -// venue-adapter world. -videre_sdk::export_venue_adapter!(DemoAdapter); - /// In-process client: routes the demo venue id straight into the adapter, /// standing in for the host registry the keeper-side seam will bind. struct InProcessClient; diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml index cd7f6172..8db8e22d 100644 --- a/modules/examples/echo-venue/Cargo.toml +++ b/modules/examples/echo-venue/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["cdylib"] [dependencies] videre-sdk = { path = "../../../crates/videre-sdk" } -wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] # The conformance kit: holds this adapter's header derivation to the kit's diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 7e62a376..feb69b1a 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -4,10 +4,10 @@ //! as the receipt, and settles instantly (every receipt it issued reports //! `fulfilled`). It carries no real venue protocol, so it doubles as the //! smallest end-to-end demonstration of `#[videre_sdk::venue]` - the -//! attribute supplies the per-cdylib wit-bindgen call for a world derived -//! from `module.toml`, the `Guest` export glue, and `export!`, leaving only -//! the adapter face - and as the `nexum-venue-test` conformance target (see -//! the tests below). +//! attribute takes the `impl VenueAdapter` block and supplies the +//! per-cdylib wit-bindgen for a world derived from `module.toml` plus the +//! export glue - and as the `nexum-venue-test` conformance target (see the +//! tests below). //! //! It declares one capability (`chain`), so the built component imports //! `nexum:host/chain` and nothing else: the per-component world matches @@ -18,16 +18,19 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] +// `Config` and `Fault` come from the macro's world bindgen at the crate +// root: aliases of the SDK types, so the trait impl lines up. use nexum::host::chain; -use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, + VenueError, }; -use videre::value_flow::types::{Asset, AssetAmount}; struct EchoVenue; #[videre_sdk::venue] -impl EchoVenue { +impl VenueAdapter for EchoVenue { fn init(_config: Config) -> Result<(), Fault> { Ok(()) } @@ -105,11 +108,9 @@ fn minimal_be(value: u64) -> Vec { } /// echo-venue as the `nexum-venue-test` conformance target: the adapter's -/// pure header derivation is held to a hand-written golden through the kit's -/// serde mirror types. The macro mints echo-venue's own bindgen -/// `IntentHeader`, so the check bridges it to [`GoldenHeader`] field for -/// field - the pattern the kit documents for macro-built adapters - rather -/// than reusing the SDK's `From`. +/// pure header derivation is held to a hand-written golden. The macro +/// remaps the type interfaces onto the SDK bindings, so the derivation +/// feeds the kit directly through its `From` mirror. #[cfg(test)] mod conformance { use super::*; @@ -118,43 +119,6 @@ mod conformance { GoldenSettlement, HeaderGolden, HeaderGoldens, }; - fn asset_to_golden(asset: Asset) -> GoldenAsset { - match asset { - Asset::Native => GoldenAsset::Native, - Asset::Erc20(erc20) => GoldenAsset::Erc20 { token: erc20.token }, - } - } - - fn amount_to_golden(amount: AssetAmount) -> GoldenAssetAmount { - GoldenAssetAmount { - asset: asset_to_golden(amount.asset), - amount: amount.amount, - } - } - - fn auth_to_golden(scheme: AuthScheme) -> GoldenAuthScheme { - match scheme { - AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, - AuthScheme::Eip712 => GoldenAuthScheme::Eip712, - } - } - - fn header_to_golden(header: IntentHeader) -> GoldenHeader { - GoldenHeader { - gives: amount_to_golden(header.gives), - wants: amount_to_golden(header.wants), - settlement: GoldenSettlement { - chain: header.settlement.chain, - }, - authorisation: auth_to_golden(header.authorisation), - } - } - - /// The adapter derivation the kit checks, bridged to the golden mirror. - fn derive_golden(body: Vec) -> Result { - EchoVenue::derive_header(body).map(header_to_golden) - } - fn zero_native() -> GoldenAssetAmount { GoldenAssetAmount { asset: GoldenAsset::Native, @@ -187,7 +151,7 @@ mod conformance { venue: "echo-venue".to_owned(), goldens: vec![golden], }; - goldens.assert_conforms(derive_golden); + goldens.assert_conforms(EchoVenue::derive_header); } #[test] @@ -212,6 +176,6 @@ mod conformance { notes: None, }], }; - assert!(goldens.check(derive_golden).is_err()); + assert!(goldens.check(EchoVenue::derive_header).is_err()); } } diff --git a/modules/fixtures/flaky-venue/Cargo.toml b/modules/fixtures/flaky-venue/Cargo.toml index bcbd4c95..4987897e 100644 --- a/modules/fixtures/flaky-venue/Cargo.toml +++ b/modules/fixtures/flaky-venue/Cargo.toml @@ -14,4 +14,4 @@ crate-type = ["cdylib"] [dependencies] videre-sdk = { path = "../../../crates/videre-sdk" } -wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs index 0970a63c..10716274 100644 --- a/modules/fixtures/flaky-venue/src/lib.rs +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -14,11 +14,14 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] +// `Config` and `Fault` come from the macro's world bindgen at the crate +// root: aliases of the SDK types, so the trait impl lines up. use nexum::host::chain; -use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, + VenueError, }; -use videre::value_flow::types::{Asset, AssetAmount}; /// The chain-head response that detonates `submit`. const POISON_HEAD: &str = "0xdead"; @@ -26,7 +29,7 @@ const POISON_HEAD: &str = "0xdead"; struct FlakyVenue; #[videre_sdk::venue] -impl FlakyVenue { +impl VenueAdapter for FlakyVenue { fn init(_config: Config) -> Result<(), Fault> { Ok(()) } From a3e1a0baa3c2827fc18dc8364f15addc2fba0871 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 08:59:33 +0000 Subject: [PATCH 36/53] sdk: rename the conformance kit to videre-test and align mock-grant fidelity --- Cargo.lock | 36 +++---- Cargo.toml | 2 +- crates/cow-venue/Cargo.toml | 2 +- crates/cow-venue/src/body.rs | 2 +- crates/nexum-sdk-test/src/lib.rs | 59 +++++++++++- .../Cargo.toml | 2 +- .../goldens/reference-header.json | 2 +- .../src/codec.rs | 0 .../src/fixture.rs | 0 .../src/header.rs | 0 .../src/lib.rs | 8 +- .../src/reference.rs | 6 +- .../src/report.rs | 0 .../src/transport.rs | 95 +++++++++++++++++-- .../tests/conformance.rs | 8 +- .../vectors/reference-body.json | 2 +- docs/05-sdk-design.md | 4 +- modules/examples/echo-venue/Cargo.toml | 2 +- modules/examples/echo-venue/src/lib.rs | 6 +- 19 files changed, 185 insertions(+), 51 deletions(-) rename crates/{nexum-venue-test => videre-test}/Cargo.toml (98%) rename crates/{nexum-venue-test => videre-test}/goldens/reference-header.json (96%) rename crates/{nexum-venue-test => videre-test}/src/codec.rs (100%) rename crates/{nexum-venue-test => videre-test}/src/fixture.rs (100%) rename crates/{nexum-venue-test => videre-test}/src/header.rs (100%) rename crates/{nexum-venue-test => videre-test}/src/lib.rs (93%) rename crates/{nexum-venue-test => videre-test}/src/reference.rs (97%) rename crates/{nexum-venue-test => videre-test}/src/report.rs (100%) rename crates/{nexum-venue-test => videre-test}/src/transport.rs (68%) rename crates/{nexum-venue-test => videre-test}/tests/conformance.rs (97%) rename crates/{nexum-venue-test => videre-test}/vectors/reference-body.json (97%) diff --git a/Cargo.lock b/Cargo.lock index 173bbae5..9b4a3f73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,11 +1559,11 @@ version = "0.1.0" dependencies = [ "borsh", "nexum-sdk", - "nexum-venue-test", "serde", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", "videre-sdk", + "videre-test", ] [[package]] @@ -2163,8 +2163,8 @@ dependencies = [ name = "echo-venue" version = "0.1.0" dependencies = [ - "nexum-venue-test", "videre-sdk", + "videre-test", "wit-bindgen 0.59.0", ] @@ -3692,22 +3692,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nexum-venue-test" -version = "0.1.0" -dependencies = [ - "borsh", - "hex", - "http", - "nexum-sdk", - "nexum-sdk-test", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "videre-sdk", -] - [[package]] name = "nexum-world" version = "0.1.0" @@ -6083,6 +6067,22 @@ dependencies = [ "wit-bindgen 0.59.0", ] +[[package]] +name = "videre-test" +version = "0.1.0" +dependencies = [ + "borsh", + "hex", + "http", + "nexum-sdk", + "nexum-sdk-test", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "videre-sdk", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index d8e1be24..a21dfb89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "crates/nexum-sdk-test", "crates/nexum-status-body", "crates/nexum-tasks", - "crates/nexum-venue-test", "crates/nexum-world", "crates/no-std-probe", "crates/shepherd", @@ -20,6 +19,7 @@ members = [ "crates/videre-host", "crates/videre-macros", "crates/videre-sdk", + "crates/videre-test", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 8365b586..eb061887 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -40,7 +40,7 @@ serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. -nexum-venue-test = { path = "../nexum-venue-test" } +videre-test = { path = "../videre-test" } [features] # The body-type + codec slice ships by default; the `client` slice layers diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index b346fd5e..a4ff6aa3 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -36,7 +36,7 @@ pub enum CowIntentBody { #[cfg(test)] mod tests { use super::*; - use nexum_venue_test::{CodecVectors, Expectation}; + use videre_test::{CodecVectors, Expectation}; use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index a520855f..ceee3ec0 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -387,9 +387,12 @@ impl MockMessaging { }); } - /// Confine the mock to `topics`, mirroring the component's - /// `messaging_topics` grant: any other topic fails as - /// [`Fault::Denied`]. Untouched, every topic is allowed. + /// Confine the mock to `topics`, playing the component's + /// `messaging_topics` grant with the host's matching: a topic is + /// admitted when it equals a grant entry or descends from one read + /// as a `/`-bounded path prefix; anything else fails as + /// [`Fault::Denied`]. An empty grant is unscoped, the host's module + /// default, as is an untouched mock. pub fn scope_topics(&self, topics: impl IntoIterator>) { *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); } @@ -423,7 +426,7 @@ impl MockMessaging { } } if let Some(scope) = self.scope.borrow().as_ref() - && !scope.iter().any(|topic| topic == content_topic) + && !topic_in_scope(content_topic, scope) { return Err(Fault::Denied(format!( "MockMessaging: {content_topic} is outside the scoped topics" @@ -433,6 +436,25 @@ impl MockMessaging { } } +/// The host's `messaging_topics` matching: an empty scope admits every +/// topic; otherwise a topic is admitted when it equals a scope entry or +/// descends from one read as a path prefix bounded at `/`, so a grant +/// never leaks into a longer sibling segment. +fn topic_in_scope(topic: &str, scope: &[String]) -> bool { + if scope.is_empty() { + return true; + } + scope.iter().any(|allowed| { + if topic == allowed { + return true; + } + let prefix = allowed.strip_suffix('/').unwrap_or(allowed); + topic + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + impl MessagingHost for MockMessaging { fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { self.admit(content_topic)?; @@ -1361,6 +1383,35 @@ mod tests { assert_eq!(messaging.publish_count(), 1); } + #[test] + fn messaging_scope_matches_the_host_grant() { + // A prefix grant admits the family beneath it, bounded at `/`. + let messaging = MockMessaging::default(); + messaging.scope_topics(["/nexum/1/"]); + messaging + .publish("/nexum/1/acme-orders/proto", b"x") + .unwrap(); + messaging.publish("/nexum/1/twap/proto", b"x").unwrap(); + let err = messaging.publish("/nexum/2/acme/proto", b"x").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + + // No trailing slash still bounds on the separator: a grant never + // leaks into a longer sibling segment. + let messaging = MockMessaging::default(); + messaging.scope_topics(["/nexum/1/acme"]); + messaging.publish("/nexum/1/acme", b"x").unwrap(); + messaging.publish("/nexum/1/acme/orders", b"x").unwrap(); + let err = messaging + .publish("/nexum/1/acme-orders/proto", b"x") + .unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + + // An empty grant is unscoped, the host's module default. + let messaging = MockMessaging::default(); + messaging.scope_topics(Vec::::new()); + messaging.publish("/anywhere/at/all", b"x").unwrap(); + } + #[test] fn messaging_fault_injection_fires_by_prefix() { let messaging = MockMessaging::default(); diff --git a/crates/nexum-venue-test/Cargo.toml b/crates/videre-test/Cargo.toml similarity index 98% rename from crates/nexum-venue-test/Cargo.toml rename to crates/videre-test/Cargo.toml index c4fd08dc..02198e5a 100644 --- a/crates/nexum-venue-test/Cargo.toml +++ b/crates/videre-test/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nexum-venue-test" +name = "videre-test" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/videre-test/goldens/reference-header.json similarity index 96% rename from crates/nexum-venue-test/goldens/reference-header.json rename to crates/videre-test/goldens/reference-header.json index a90bc3c4..c56703fd 100644 --- a/crates/nexum-venue-test/goldens/reference-header.json +++ b/crates/videre-test/goldens/reference-header.json @@ -1,6 +1,6 @@ { "version": 1, - "venue": "nexum-venue-test/reference", + "venue": "videre-test/reference", "goldens": [ { "name": "v1-small", diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/videre-test/src/codec.rs similarity index 100% rename from crates/nexum-venue-test/src/codec.rs rename to crates/videre-test/src/codec.rs diff --git a/crates/nexum-venue-test/src/fixture.rs b/crates/videre-test/src/fixture.rs similarity index 100% rename from crates/nexum-venue-test/src/fixture.rs rename to crates/videre-test/src/fixture.rs diff --git a/crates/nexum-venue-test/src/header.rs b/crates/videre-test/src/header.rs similarity index 100% rename from crates/nexum-venue-test/src/header.rs rename to crates/videre-test/src/header.rs diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/videre-test/src/lib.rs similarity index 93% rename from crates/nexum-venue-test/src/lib.rs rename to crates/videre-test/src/lib.rs index 9eae0b8e..2485b000 100644 --- a/crates/nexum-venue-test/src/lib.rs +++ b/crates/videre-test/src/lib.rs @@ -1,4 +1,4 @@ -//! # nexum-venue-test +//! # videre-test //! //! Conformance kit for venue adapters: file-published codec vectors, //! header-derivation goldens, and an in-memory transport mock, so an @@ -25,16 +25,16 @@ //! //! ```toml //! [dev-dependencies] -//! nexum-venue-test = { path = "../../crates/nexum-venue-test" } +//! videre-test = { path = "../../crates/videre-test" } //! ``` //! //! Hold the adapter to its published fixtures: //! //! ```rust -//! use nexum_venue_test::reference::{ +//! use videre_test::reference::{ //! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, //! }; -//! use nexum_venue_test::{CodecVectors, HeaderGoldens}; +//! use videre_test::{CodecVectors, HeaderGoldens}; //! //! // In a real adapter test these load the venue's own published //! // files; the kit's reference venue stands in here. diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/videre-test/src/reference.rs similarity index 97% rename from crates/nexum-venue-test/src/reference.rs rename to crates/videre-test/src/reference.rs index 6ad7e546..020cd6f9 100644 --- a/crates/nexum-venue-test/src/reference.rs +++ b/crates/videre-test/src/reference.rs @@ -129,7 +129,7 @@ mod tests { /// Rebuild the published codec vectors from the reference schema. fn build_codec_vectors() -> CodecVectors { - let mut vectors = CodecVectors::new("nexum-venue-test/reference-body"); + let mut vectors = CodecVectors::new("videre-test/reference-body"); vectors .push_round_trip("v1-small", &v1_small()) @@ -218,7 +218,7 @@ mod tests { /// Rebuild the published header goldens from the reference /// derivation. fn build_header_goldens() -> HeaderGoldens { - let mut goldens = HeaderGoldens::new("nexum-venue-test/reference"); + let mut goldens = HeaderGoldens::new("videre-test/reference"); goldens .record( "v1-small", @@ -259,7 +259,7 @@ mod tests { } /// Rewrite the published files from the reference schema. Run with - /// `cargo test -p nexum-venue-test -- --ignored regenerate` after a + /// `cargo test -p videre-test -- --ignored regenerate` after a /// deliberate schema change, then commit the diff; the tests above /// compare against the compiled-in copy, so they go green on the /// next build. diff --git a/crates/nexum-venue-test/src/report.rs b/crates/videre-test/src/report.rs similarity index 100% rename from crates/nexum-venue-test/src/report.rs rename to crates/videre-test/src/report.rs diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/videre-test/src/transport.rs similarity index 68% rename from crates/nexum-venue-test/src/transport.rs rename to crates/videre-test/src/transport.rs index 37f9e7c1..a7295c97 100644 --- a/crates/nexum-venue-test/src/transport.rs +++ b/crates/videre-test/src/transport.rs @@ -4,9 +4,12 @@ //! [`MockTransport`] composes the three behind the same seams the SDK //! wrappers implement ([`ChainHost`], [`MessagingHost`], [`Fetch`]), so //! adapter logic written against `&impl Seam` runs unchanged in unit -//! tests. Scoping mirrors the host's: [`MockMessaging::scope_topics`] -//! plays the adapter's `messaging_topics` grant and refuses off-scope -//! topics as a typed `denied`, exactly as the host would. +//! tests. Grant play mirrors the host's: [`MockMessaging::scope_topics`] +//! plays the adapter's `messaging_topics` grant with the host's +//! `/`-bounded prefix matching, and [`MockFetch::scope_hosts`] plays the +//! `[capabilities.http].allow` list with the host's exact-or-`*.suffix` +//! matching; both refuse off-grant calls as a typed `denied`, exactly as +//! the host would. use std::cell::RefCell; use std::collections::HashMap; @@ -92,16 +95,29 @@ struct StoredResponse { } /// In-memory [`Fetch`] backed by a `(method, uri)` -> response map. -/// Records every request so tests can assert dispatch shape; an -/// allowlist refusal is programmed as [`FetchError::Denied`] via -/// [`fail_with`](Self::fail_with). +/// Records every request so tests can assert dispatch shape. An +/// optional host scope plays the adapter's `[capabilities.http].allow` +/// grant ([`scope_hosts`](Self::scope_hosts)); one-off refusals can +/// still be programmed via [`fail_with`](Self::fail_with). #[derive(Default)] pub struct MockFetch { responses: RefCell>>, requests: RefCell>, + scope: RefCell>>, } impl MockFetch { + /// Confine the mock to `hosts`, playing the adapter's + /// `[capabilities.http].allow` grant with the host's matching: + /// case-insensitive, an entry is an exact hostname or a `*.suffix` + /// wildcard, and an off-grant request fails as + /// [`FetchError::Denied`]. An empty grant denies every host, the + /// host's posture for an absent allow list; an untouched mock is + /// unscoped. + pub fn scope_hosts(&self, hosts: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(hosts.into_iter().map(Into::into).collect()); + } + /// Program a response for the `(method, uri)` pair. Overwrites any /// prior entry. /// @@ -164,6 +180,14 @@ impl Fetch for MockFetch { body: request.body().clone(), options, }); + if let Some(scope) = self.scope.borrow().as_ref() + && !request + .uri() + .host() + .is_some_and(|host| host_allowed(host, scope)) + { + return Err(FetchError::Denied); + } match self.responses.borrow().get(&(method.clone(), uri.clone())) { Some(Ok(stored)) => Ok(http::Response::builder() .status(stored.status) @@ -177,6 +201,21 @@ impl Fetch for MockFetch { } } +/// The host's `[capabilities.http].allow` matching: host-only and +/// case-insensitive, an entry admits its exact hostname or, as +/// `*.suffix`, any strict subdomain of the suffix. +fn host_allowed(host: &str, allowlist: &[String]) -> bool { + let host = host.to_ascii_lowercase(); + allowlist.iter().any(|pat| { + let pat = pat.to_ascii_lowercase(); + if let Some(suffix) = pat.strip_prefix("*.") { + host.ends_with(&format!(".{suffix}")) + } else { + host == pat + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -233,6 +272,50 @@ mod tests { assert_eq!(fetch.request_count(), 2); } + #[test] + fn fetch_scope_matches_the_host_grant() { + let fetch = MockFetch::default(); + fetch.scope_hosts(["api.acme.example", "*.discord.com"]); + fetch.respond_to(http::Method::GET, "https://api.acme.example/v1", 200, "ok"); + fetch.respond_to(http::Method::GET, "https://API.ACME.EXAMPLE/v1", 200, "ok"); + fetch.respond_to(http::Method::GET, "https://a.b.discord.com/", 200, "ok"); + + // Exact entry, case-insensitively; a wildcard admits strict + // subdomains only. + let get = |uri: &str| { + fetch.fetch( + http::Request::builder() + .uri(uri) + .body(Vec::new()) + .expect("test request builds"), + ) + }; + assert!(get("https://api.acme.example/v1").is_ok()); + assert!(get("https://API.ACME.EXAMPLE/v1").is_ok()); + assert!(get("https://a.b.discord.com/").is_ok()); + assert_eq!( + get("https://evil.api.acme.example/").unwrap_err(), + FetchError::Denied, + ); + assert_eq!(get("https://discord.com/").unwrap_err(), FetchError::Denied); + + // Refused requests are still recorded. + assert_eq!(fetch.request_count(), 5); + + // An empty grant denies every host, the host's posture for an + // absent allow list. + let sealed = MockFetch::default(); + sealed.scope_hosts(Vec::::new()); + sealed.respond_to(http::Method::GET, "https://anywhere.example/", 200, ""); + let denied = sealed.fetch( + http::Request::builder() + .uri("https://anywhere.example/") + .body(Vec::new()) + .expect("test request builds"), + ); + assert_eq!(denied.unwrap_err(), FetchError::Denied); + } + #[test] fn transport_dispatches_through_every_seam() { let transport = MockTransport::new(); diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/videre-test/tests/conformance.rs similarity index 97% rename from crates/nexum-venue-test/tests/conformance.rs rename to crates/videre-test/tests/conformance.rs index 0faa7196..da0bc538 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/videre-test/tests/conformance.rs @@ -3,15 +3,15 @@ //! golden files, and a deliberately divergent adapter is caught by //! them. -use nexum_venue_test::reference::{ - CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, -}; -use nexum_venue_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; use videre_sdk::value_flow::{Asset, AssetAmount}; use videre_sdk::{ AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, VenueError, }; +use videre_test::reference::{ + CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, +}; +use videre_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; /// An adapter under test: the reference venue implemented through the /// SDK trait, transport injected through the seams so the kit's mocks diff --git a/crates/nexum-venue-test/vectors/reference-body.json b/crates/videre-test/vectors/reference-body.json similarity index 97% rename from crates/nexum-venue-test/vectors/reference-body.json rename to crates/videre-test/vectors/reference-body.json index a2ffd1db..4bc46024 100644 --- a/crates/nexum-venue-test/vectors/reference-body.json +++ b/crates/videre-test/vectors/reference-body.json @@ -1,6 +1,6 @@ { "version": 1, - "schema": "nexum-venue-test/reference-body", + "schema": "videre-test/reference-body", "vectors": [ { "name": "v1-small", diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 1722ca1a..eff5eed4 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -34,7 +34,7 @@ things from the SDK: to know the venue's wire format. This persona is planned but not yet shipped: the crate (`videre-sdk`), the per-venue crates (e.g. a `cow-venue` crate carrying CoW's intent-body codec), the - `#[nexum::venue]` macro, and the `nexum-venue-test` conformance kit + `#[nexum::venue]` macro, and the `videre-test` conformance kit are all tracked by the SDK-surfaces epic and have no code in the tree yet. See [Venue-adapter persona (planned)](#venue-adapter-persona-planned) below for the shape of the plan. @@ -276,7 +276,7 @@ The planned shape: manifest's declared capabilities (retiring the import-elision dependency for the venue side from day one, rather than as follow-on work). -- **`nexum-venue-test`** - a conformance kit: published codec +- **`videre-test`** - a conformance kit: published codec round-trip vectors (so a non-Rust adapter author can prove byte-exact `IntentBody` encoding without linking Rust), header- derivation golden fixtures, and a `MockTransport` for adapter unit diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml index 8db8e22d..a1d818f1 100644 --- a/modules/examples/echo-venue/Cargo.toml +++ b/modules/examples/echo-venue/Cargo.toml @@ -19,4 +19,4 @@ wit-bindgen = { version = "0.59", default-features = false, features = ["macros" # The conformance kit: holds this adapter's header derivation to the kit's # golden mirror types, so echo-venue is both the tutorial artefact and the # kit's worked test target. -nexum-venue-test = { path = "../../../crates/nexum-venue-test" } +videre-test = { path = "../../../crates/videre-test" } diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index feb69b1a..2a3d8d50 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -6,7 +6,7 @@ //! smallest end-to-end demonstration of `#[videre_sdk::venue]` - the //! attribute takes the `impl VenueAdapter` block and supplies the //! per-cdylib wit-bindgen for a world derived from `module.toml` plus the -//! export glue - and as the `nexum-venue-test` conformance target (see the +//! export glue - and as the `videre-test` conformance target (see the //! tests below). //! //! It declares one capability (`chain`), so the built component imports @@ -107,14 +107,14 @@ fn minimal_be(value: u64) -> Vec { first.map_or(Vec::new(), |index| bytes[index..].to_vec()) } -/// echo-venue as the `nexum-venue-test` conformance target: the adapter's +/// echo-venue as the `videre-test` conformance target: the adapter's /// pure header derivation is held to a hand-written golden. The macro /// remaps the type interfaces onto the SDK bindings, so the derivation /// feeds the kit directly through its `From` mirror. #[cfg(test)] mod conformance { use super::*; - use nexum_venue_test::{ + use videre_test::{ FormatVersion, GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, }; From 67f61874adb79ba6ad2a6471dc02b1461c3aca62 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 09:20:43 +0000 Subject: [PATCH 37/53] sdk: add the keeper macro and the typed venue client The keeper author gets the mirror of #[venue]: #[videre_sdk::keeper] on a handler impl emits the per-cdylib module world (requiring the client capability), remaps the videre interfaces onto the SDK's shared shims, completes async handlers on the synchronous guest boundary, and folds ClientError into the wire fault so ? works in handlers. VenueClient replaces the stringly byte-level trait: a Venue marker carries the VenueId and body schema, calls encode through IntentBody, and the byte seam under it (VenueTransport, native AFIT) dispatches statically with zero boxing. HostVenues binds the seam to the module's own videre:venue/client import; the SDK bindgen collapses to one import-only world so linking it never obliges a module to export the adapter face. CowClient becomes the VenueClient alias, and the echo-keeper example drives echo-venue end to end (quote, submit, status, cancel), proven by a platform e2e test. --- .github/workflows/ci.yml | 4 +- Cargo.lock | 9 + Cargo.toml | 1 + crates/cow-venue/src/client.rs | 99 ++++---- crates/cow-venue/src/lib.rs | 2 +- crates/videre-host/tests/platform.rs | 88 +++++++ crates/videre-macros/Cargo.toml | 2 +- crates/videre-macros/src/keeper.rs | 292 +++++++++++++++++++++++ crates/videre-macros/src/lib.rs | 59 ++++- crates/videre-sdk/Cargo.toml | 2 +- crates/videre-sdk/src/bindings.rs | 37 ++- crates/videre-sdk/src/client.rs | 224 ++++++++++++----- crates/videre-sdk/src/faults.rs | 23 ++ crates/videre-sdk/src/keeper.rs | 98 +++++--- crates/videre-sdk/src/lib.rs | 41 +++- crates/videre-sdk/src/rt.rs | 38 +++ crates/videre-sdk/tests/adapter.rs | 80 ++++--- modules/examples/echo-keeper/Cargo.toml | 18 ++ modules/examples/echo-keeper/module.toml | 40 ++++ modules/examples/echo-keeper/src/lib.rs | 109 +++++++++ 20 files changed, 1055 insertions(+), 211 deletions(-) create mode 100644 crates/videre-macros/src/keeper.rs create mode 100644 crates/videre-sdk/src/rt.rs create mode 100644 modules/examples/echo-keeper/Cargo.toml create mode 100644 modules/examples/echo-keeper/module.toml create mode 100644 modules/examples/echo-keeper/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5d2fa81..c2ca1d19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 16 guest module wasms ONCE (release/wasm32-wasip2): the single + # Build all 17 guest module wasms ONCE (release/wasm32-wasip2): the single # source of truth for guest buildability and the artifacts the integration # tests load. Replaces the deleted 9-way build-module matrix, which recompiled # the shared wasm dependency graph ~9x cold. Per-module size report folded in; @@ -88,7 +88,7 @@ jobs: cargo build --release --target wasm32-wasip2 --locked \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ - -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue \ + -p echo-client -p echo-keeper -p clock-reader -p flaky-bomb -p flaky-venue \ -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host { echo "### module .wasm sizes" diff --git a/Cargo.lock b/Cargo.lock index 9b4a3f73..75b61f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2159,6 +2159,15 @@ dependencies = [ "wit-bindgen 0.58.0", ] +[[package]] +name = "echo-keeper" +version = "0.1.0" +dependencies = [ + "nexum-sdk", + "videre-sdk", + "wit-bindgen 0.59.0", +] + [[package]] name = "echo-venue" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a21dfb89..94377aa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "modules/example", "modules/examples/balance-tracker", "modules/examples/echo-client", + "modules/examples/echo-keeper", "modules/examples/echo-venue", "modules/examples/http-probe", "modules/examples/price-alert", diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index b8d4b302..274906c7 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -1,65 +1,40 @@ -//! The typed CoW intent client. +//! The CoW venue as a keeper types it. //! -//! [`CowClient`] binds the keeper-facing [`IntentClient`] to the CoW -//! venue id and speaks the venue's own [`CowIntentBody`] over it, so -//! keeper code submits a typed CoW body without naming the venue on -//! every call or handling wire bytes. The classification API +//! [`CowVenue`] names the venue once - the id its adapter registers +//! under and the [`CowIntentBody`] schema it decodes - so keeper code +//! drives it through [`VenueClient`] with typed bodies, never wire +//! bytes. The classification API //! ([`classify`](crate::classification::classify)) travels in the same //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use videre_sdk::client::{ClientError, IntentClient, VenueClient, VenueId}; -use videre_sdk::{IntentStatus, SubmitOutcome}; +use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId}; use crate::body::CowIntentBody; -/// The venue id the CoW adapter registers under and the registry resolves. -/// Every [`CowClient`] call routes here. -pub const VENUE: &str = "cow"; +/// The CoW venue marker: every [`CowClient`] call routes to +/// [`Venue::ID`] and encodes a [`CowIntentBody`]. +#[derive(Clone, Copy, Debug)] +pub struct CowVenue; -/// A typed intent client pre-bound to the CoW venue. A thin newtype over -/// [`IntentClient`] that fixes the venue id and the body type so callers -/// cannot mis-route or submit a foreign body. -#[derive(Clone, Debug)] -pub struct CowClient

{ - inner: IntentClient

, +impl Venue for CowVenue { + const ID: VenueId = VenueId::from_static("cow"); + type Body = CowIntentBody; } -impl CowClient

{ - /// Bind a client handle to the CoW venue. - pub fn new(venues: P) -> Self { - Self { - inner: IntentClient::new(venues, VENUE), - } - } - - /// The venue id every call routes to (always [`VENUE`]). - pub fn venue(&self) -> &VenueId { - self.inner.venue() - } - - /// Encode a typed CoW body and submit it to the venue. - pub fn submit(&self, body: &CowIntentBody) -> Result { - self.inner.submit(body) - } - - /// Report where a previously submitted intent is in its life. - pub fn status(&self, receipt: &[u8]) -> Result { - self.inner.status(receipt) - } - - /// Ask the venue to withdraw an intent. - pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - self.inner.cancel(receipt) - } -} +/// A typed client pre-bound to the CoW venue: callers cannot mis-route +/// or submit a foreign body. +pub type CowClient = VenueClient; #[cfg(test)] mod tests { - use super::*; use std::cell::RefCell; use std::rc::Rc; - use videre_sdk::VenueFault; + + use videre_sdk::client::VenueTransport; + use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; + + use super::*; /// One recorded submit: the venue it routed to and the wire bytes. type SubmitLog = Rc)>>>; @@ -72,27 +47,31 @@ mod tests { submitted: SubmitLog, } - impl VenueClient for SpyClient { - fn quote( - &self, - _venue: &VenueId, - _body: Vec, - ) -> Result { + impl VenueTransport for SpyClient { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { unreachable!("quote not exercised") } - fn submit(&self, venue: &VenueId, body: Vec) -> Result { + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { self.submitted .borrow_mut() .push((venue.to_string(), body.clone())); Ok(SubmitOutcome::Accepted(body)) } - fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { unreachable!("status not exercised") } - fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { unreachable!("cancel not exercised") } } @@ -124,13 +103,15 @@ mod tests { let body = sample_body(); let expected = body.to_bytes().expect("body encodes"); - let client = CowClient::new(spy.clone()); - assert_eq!(client.venue().as_str(), VENUE); - client.submit(&body).expect("submit succeeds"); + let client = CowClient::with_transport(spy.clone()); + assert_eq!(client.venue(), CowVenue::ID); + videre_sdk::rt::complete(client.submit(&body)) + .expect("guest futures complete in one poll") + .expect("submit succeeds"); let calls = spy.submitted.borrow(); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, VENUE); + assert_eq!(calls[0].0, CowVenue::ID.as_str()); assert_eq!(calls[0].1, expected); } } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 147d4920..3b4054fb 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -64,4 +64,4 @@ pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; #[cfg(feature = "client")] -pub use client::{CowClient, VENUE}; +pub use client::{CowClient, CowVenue}; diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 67ea2120..9c44c425 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -580,6 +580,94 @@ async fn e2e_echo_module_registry_adapter_round_trip() { ); } +/// The blessed keeper path over the same two real components: the +/// echo-keeper module (built by `#[videre_sdk::keeper]`) drives the +/// echo-venue adapter through the typed `VenueClient` - +/// quote, submit, status, cancel, all with a typed body - and receives +/// the fulfilled `intent-status` the registry polls back. Proves the +/// macro-emitted worker and the typed client end to end, with no +/// hand-written byte marshalling on the keeper side. +#[tokio::test] +async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-keeper"), + ) else { + return; + }; + + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); + let logs = components.logs.clone(); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/examples/echo-keeper/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!( + supervisor.adapter_alive_count(), + 1, + "echo-venue is routable" + ); + assert_eq!(supervisor.alive_count(), 1, "echo-keeper is alive"); + + // One block drives the keeper's async on_block: quote, submit, + // status, cancel, all through the typed client. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + + // The accepted receipt is under status watch; echo settles + // instantly, so the first poll fans the terminal status back. + let registry = registry_of(&supervisor); + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + assert_eq!(update.venue, "echo-venue"); + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!(delivered, 1, "one terminal status delivered to the keeper"); + assert_eq!(supervisor.alive_count(), 1, "keeper must remain alive"); + + // Every typed verb observably ran. + let runs = logs.list_runs("echo-keeper"); + assert_eq!(runs.len(), 1, "one run recorded for echo-keeper"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + for needle in [ + "quoted at echo-venue", + "submitted to echo-venue", + "status at echo-venue", + "cancelled at echo-venue", + "intent status from venue echo-venue", + ] { + assert!( + messages.iter().any(|m| m.contains(needle)), + "missing `{needle}`; records were: {messages:?}", + ); + } +} + /// The body-version handshake refuses a mismatched pair: an adapter /// decoding only v1 against a keeper encoding v2 fails the boot at the /// keeper's install, before instantiation, naming both sides' versions. diff --git a/crates/videre-macros/Cargo.toml b/crates/videre-macros/Cargo.toml index 31f0b9a3..7bb32f49 100644 --- a/crates/videre-macros/Cargo.toml +++ b/crates/videre-macros/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Proc-macro glue for videre venue adapters: #[venue] turns an impl VenueAdapter into the per-cdylib wit-bindgen and adapter export; derive(IntentBody) emits the versioned body codec." +description = "Proc-macro glue for the videre personas: #[venue] turns an impl VenueAdapter into the per-cdylib wit-bindgen and adapter export; #[keeper] emits the worker world wired to the typed venue client; derive(IntentBody) emits the versioned body codec." [lib] proc-macro = true diff --git a/crates/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs new file mode 100644 index 00000000..568796d8 --- /dev/null +++ b/crates/videre-macros/src/keeper.rs @@ -0,0 +1,292 @@ +//! Expansion for `#[keeper]`: the keeper-worker mirror of `#[module]`. +//! +//! Same world synthesis and event dispatch as the plain module macro, +//! with the keeper deltas: the `client` capability is required, the +//! videre interfaces remap onto the SDK bindings (one shim set, one +//! type identity for the typed client), async handlers complete via +//! `videre_sdk::rt::complete`, and `ClientError` folds into the wire +//! fault so `?` works in handlers. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ImplItem, ItemImpl}; + +/// The handler names recognised on a `#[keeper]` impl; the `#[module]` +/// set, since a keeper is a plain worker. +const HANDLERS: [&str; 6] = [ + "init", + "on_block", + "on_chain_logs", + "on_tick", + "on_message", + "on_intent_status", +]; + +/// The manifest capability granting the client import. +const CLIENT_CAPABILITY: &str = "client"; + +/// The import the `client` capability must map to. +const CLIENT_IMPORT: &str = "videre:venue/client@0.1.0"; + +/// WIT packages the client import needs on the resolve path, in +/// dependency order. +const CLIENT_PACKAGES: [&str; 3] = ["videre-value-flow", "videre-types", "videre-venue"]; + +/// The fault detail for a handler future that suspended. +const SUSPENDED: &str = "keeper handler suspended: guest futures complete in one poll"; + +/// Expand the handler impl into the keeper module glue, or a compile +/// error naming the rule the input broke. +pub(crate) fn expand(input: &ItemImpl) -> syn::Result { + let self_ty = &input.self_ty; + if !crate::is_plain_type(self_ty) { + return Err(syn::Error::new_spanned( + self_ty, + "#[videre_sdk::keeper] must be applied to an inherent impl of a named type", + )); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return Err(syn::Error::new_spanned( + trait_path, + "#[videre_sdk::keeper] must be applied to an inherent impl, not a trait impl", + )); + } + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::keeper] must be applied to a non-generic impl", + )); + } + + // Reserve the `on_` prefix for the recognised handler set, exactly + // as `#[module]` does: a typo'd handler must not silently no-op. + for item in &input.items { + if let ImplItem::Fn(f) = item { + let name = f.sig.ident.to_string(); + if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { + return Err(syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[videre_sdk::keeper] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + )); + } + } + } + + // Present handlers with their asyncness: async ones are completed + // on the synchronous guest boundary by the emitted dispatch. + let present: Vec<(&str, bool)> = input + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => { + let name = f.sig.ident.to_string(); + HANDLERS + .into_iter() + .find(|h| *h == name) + .map(|h| (h, f.sig.asyncness.is_some())) + } + _ => None, + }) + .collect(); + if present.is_empty() { + return Err(syn::Error::new_spanned( + self_ty, + "#[videre_sdk::keeper] found no recognised handlers on this impl; define at least one \ + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", + )); + } + let handler = |name: &str| present.iter().find(|(h, _)| *h == name).copied(); + + let (anchors, module_world) = derive_keeper_world() + .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; + let wit_paths = crate::resolve_wit_packages(&module_world.packages) + .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; + let inline_world = &module_world.wit; + let adapter_caps: Vec = module_world + .adapters + .iter() + .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) + .collect(); + + // Complete an async handler's future in one poll; a suspension is a + // typed internal fault, never a hang. + let drive = |call: TokenStream| { + quote! { + match ::videre_sdk::rt::complete(#call) { + ::core::option::Option::Some(result) => result, + ::core::option::Option::None => ::core::result::Result::Err( + nexum::host::types::Fault::Internal( + ::std::string::String::from(#SUSPENDED), + ), + ), + } + } + }; + + let init_impl = match handler("init") { + Some((_, is_async)) => { + let call = quote! { <#self_ty>::init(config) }; + let body = if is_async { drive(call) } else { call }; + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + #body + } + } + } + None => quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + }, + }; + + let arm = |name: &str, variant: &str| -> TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + match handler(name) { + Some((_, is_async)) => { + let call = syn::Ident::new(name, proc_macro2::Span::call_site()); + let call = quote! { <#self_ty>::#call(payload) }; + let body = if is_async { drive(call) } else { call }; + quote! { nexum::host::types::Event::#variant(payload) => #body, } + } + None => quote! { + nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), + }, + } + }; + let block_arm = arm("on_block", "Block"); + let logs_arm = arm("on_chain_logs", "ChainLogs"); + let tick_arm = arm("on_tick", "Tick"); + let message_arm = arm("on_message", "Message"); + let intent_status_arm = arm("on_intent_status", "IntentStatus"); + + Ok(quote! { + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:module-world/module", + generate_all, + with: { + "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, + "videre:value-flow/types@0.1.0": + ::videre_sdk::bindings::videre::value_flow::types, + "videre:venue/client@0.1.0": + ::videre_sdk::bindings::videre::venue::client, + }, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); + + #input + + // Folds a typed client failure into the wire fault, so `?` + // applies to client calls inside handlers. + impl ::core::convert::From<::videre_sdk::ClientError> for nexum::host::types::Fault { + fn from(err: ::videre_sdk::ClientError) -> Self { + ::core::convert::Into::into(::nexum_sdk::host::Fault::from(err)) + } + } + + #[doc(hidden)] + struct __VidereKeeperExport; + + impl Guest for __VidereKeeperExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + #intent_status_arm + } + } + } + + export!(__VidereKeeperExport); + }) +} + +/// The canonical `client` extension row, injected when the composition +/// root's registry does not carry one. +fn client_row() -> nexum_world::ExtensionRow { + nexum_world::ExtensionRow { + name: CLIENT_CAPABILITY.to_owned(), + import: CLIENT_IMPORT.to_owned(), + packages: CLIENT_PACKAGES.map(str::to_owned).into(), + } +} + +/// Read the consuming crate's `module.toml`, require the worker shape +/// (no `[module] kind`) and the `client` capability, and synthesize the +/// per-module world with the client extension row guaranteed. Returns +/// the rebuild anchor paths alongside the world. +fn derive_keeper_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { + let manifest_path = crate::manifest_dir()?.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[videre_sdk::keeper] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + if let Some(kind) = nexum_world::manifest_kind(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))? + { + return Err(format!( + "{}: a #[videre_sdk::keeper] module is a plain worker; drop `[module] kind = \ + \"{kind}\"`", + manifest_path.display() + )); + } + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + if !declared.iter().any(|cap| cap == CLIENT_CAPABILITY) { + return Err(format!( + "{}: a keeper drives venues through `{CLIENT_IMPORT}`; declare the \ + `{CLIENT_CAPABILITY}` capability under [capabilities]", + manifest_path.display() + )); + } + let manifest_path = manifest_path.to_string_lossy().into_owned(); + + let mut anchors = vec![manifest_path.clone()]; + let mut extensions = match nexum_world::find_extensions_manifest(&crate::manifest_dir()?) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = nexum_world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + match extensions.iter().find(|row| row.name == CLIENT_CAPABILITY) { + None => extensions.push(client_row()), + Some(row) if row.import == CLIENT_IMPORT => {} + Some(row) => { + return Err(format!( + "the registered `{CLIENT_CAPABILITY}` extension imports `{}`; \ + #[videre_sdk::keeper] requires `{CLIENT_IMPORT}`", + row.import + )); + } + } + let module_world = nexum_world::synthesize(&declared, &extensions) + .map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) +} diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs index 77f83e0c..665fb88a 100644 --- a/crates/videre-macros/src/lib.rs +++ b/crates/videre-macros/src/lib.rs @@ -1,20 +1,27 @@ -//! Proc-macro glue for videre venue adapters. +//! Proc-macro glue for the two videre personas. //! //! [`venue`] is the single blessed venue authoring path: applied to an //! `impl VenueAdapter` block it emits the per-cdylib wit-bindgen for a //! manifest-derived world exporting `videre:venue/adapter`, asserts the //! manifest kind, and expands to the SDK's internal export codegen. //! +//! [`keeper`] is its worker mirror: applied to a handler impl it emits +//! the per-cdylib wit-bindgen for a manifest-derived module world, +//! wires the `videre:venue/client` import onto the SDK's shared shims, +//! and dispatches events to the handlers, completing async ones on the +//! synchronous guest boundary. +//! //! [`derive@IntentBody`] implements the venue SDK's versioned body codec //! over a per-venue version enum. //! -//! The module-side macro (`#[module]`) lives in `nexum-module-macros`. +//! The plain module macro (`#[module]`) lives in `nexum-module-macros`. //! //! Consumers reach these through the SDK re-exports -//! (`videre_sdk::venue`, `videre_sdk::IntentBody`) rather than -//! depending on this crate directly. +//! (`videre_sdk::venue`, `videre_sdk::keeper`, `videre_sdk::IntentBody`) +//! rather than depending on this crate directly. mod intent_body; +mod keeper; mod world; use proc_macro::TokenStream; @@ -166,15 +173,53 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +/// Generate the per-cdylib glue for a keeper: a worker that drives +/// venues through the typed client. +/// +/// The keeper-author mirror of `#[module]`. Apply to an `impl` block +/// whose associated functions are the event handlers (`init`, +/// `on_block`, `on_chain_logs`, `on_tick`, `on_message`, +/// `on_intent_status`); handlers may be `async` and are completed on +/// the synchronous guest boundary (`videre_sdk::rt::complete`), so a +/// handler can await the typed `VenueClient` directly. +/// +/// The macro reads the crate's `module.toml`, requires the `client` +/// capability (the `videre:venue/client` import is what makes a keeper +/// a keeper), synthesizes the per-module world exactly as `#[module]` +/// does, and remaps the videre interfaces onto the SDK bindings: the +/// module's client import resolves to the SDK's shared shims, so the +/// `VenueClient` a handler drives and the wire speak one set of types. +/// A `From` impl onto the wire fault is emitted, so `?` +/// applies to client calls inside handlers. +/// +/// The same crate-root resolution invariants as `#[module]` apply, and +/// the consuming crate must declare `wit-bindgen`, `videre-sdk`, and +/// `nexum-sdk` as direct dependencies. +#[proc_macro_attribute] +pub fn keeper(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[videre_sdk::keeper] takes no arguments", + ) + .to_compile_error() + .into(); + } + let input = syn::parse_macro_input!(item as ItemImpl); + keeper::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + /// Whether a type is a plain named path (`Foo`), the only shape a module /// export type may take. -fn is_plain_type(ty: &Type) -> bool { +pub(crate) fn is_plain_type(ty: &Type) -> bool { matches!(ty, Type::Path(tp) if tp.qself.is_none()) } /// The consuming crate's manifest directory, the root every crate-local /// lookup starts from. -fn manifest_dir() -> Result { +pub(crate) fn manifest_dir() -> Result { std::env::var("CARGO_MANIFEST_DIR") .map(std::path::PathBuf::from) .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) @@ -215,7 +260,7 @@ fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { /// Resolve each needed WIT package directory crate-locally (vendored /// `wit/deps/`, then own `wit/`), falling back through /// ancestors for the transitional monorepo layout. -fn resolve_wit_packages(packages: &[String]) -> Result, String> { +pub(crate) fn resolve_wit_packages(packages: &[String]) -> Result, String> { Ok( nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? .into_iter() diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 47b77c88..54701fd4 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Guest-side videre SDK: the VenueAdapter trait over the venue-adapter world bindgen, the borsh-versioned IntentBody codec, the typed intent client core, the generic keeper sweep assembler, and typed wrappers over the scoped transport imports." +description = "Guest-side videre SDK: the VenueAdapter trait mirroring the venue-adapter world, the borsh-versioned IntentBody codec, the typed venue client over the native-AFIT transport seam, the generic keeper sweep assembler, and typed wrappers over the scoped transport imports." [lib] # Plain library - adapters link this and emit their own cdylib for the diff --git a/crates/videre-sdk/src/bindings.rs b/crates/videre-sdk/src/bindings.rs index eb426521..9f57dde8 100644 --- a/crates/videre-sdk/src/bindings.rs +++ b/crates/videre-sdk/src/bindings.rs @@ -1,22 +1,43 @@ -//! Guest bindings for the `videre:venue/venue-adapter` world. +//! Guest bindings for the videre SDK, generated once from an +//! import-only inline world carrying every interface both personas +//! speak: the videre type vocabulary, the host types and scoped +//! transport, and the keeper-facing `videre:venue/client` shims. //! //! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, -//! the venue SDK generates the adapter world's bindings once, here: the +//! the SDK generates these bindings once: the //! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport -//! wrappers, and the intent client core are all expressed over these -//! types. The `#[videre_sdk::venue]` attribute's per-cdylib bindgen -//! remaps `videre:types/types`, `videre:value-flow/types`, and -//! `nexum:host/types` onto these modules with `with`, so a macro-built -//! adapter shares type identity with the SDK and the conformance kit. +//! wrappers, and the client core are all expressed over them. The +//! per-cdylib bindgens (`#[videre_sdk::venue]`, `#[videre_sdk::keeper]`) +//! remap the shared interfaces onto these modules with `with`, so a +//! macro-built component shares type identity (and, for the keeper's +//! client, one shim set) with the SDK and the conformance kit. +//! +//! The world is import-only on purpose: an embedded world section is +//! unioned into every linking module at componentization, where an +//! unused import prunes but an export is a hard obligation. Keeping the +//! adapter export out of the SDK world is what lets a keeper module +//! link this crate without being asked to be a venue; the export face +//! is emitted per-cdylib by `#[videre_sdk::venue]` alone. wit_bindgen::generate!({ + inline: "package videre:sdk-shims; + +world sdk-imports { + import videre:types/types@0.1.0; + import videre:value-flow/types@0.1.0; + import nexum:host/types@0.1.0; + import nexum:host/chain@0.1.0; + import nexum:host/messaging@0.1.0; + import videre:venue/client@0.1.0; +} +", path: [ "../../wit/videre-value-flow", "../../wit/videre-types", "../../wit/nexum-host", "../../wit/videre-venue", ], - world: "videre:venue/venue-adapter", + world: "videre:sdk-shims/sdk-imports", generate_all, additional_derives: [PartialEq], }); diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index 8652f849..baa40118 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -1,27 +1,38 @@ -//! The typed intent client core: [`IntentClient`] over the byte-level -//! [`VenueClient`] seam. +//! The typed venue client: [`VenueClient`] binds one [`Venue`] over the +//! byte-level [`VenueTransport`] seam. //! -//! The client boundary carries opaque bodies; this module is where a -//! typed body meets it. [`IntentClient`] binds one [`VenueId`] and -//! encodes through [`IntentBody`] before submission, so keeper code -//! never handles wire bytes. The seam is byte-level on purpose: the -//! strategy-module SDK implements [`VenueClient`] over its own -//! `videre:venue/client` import shims, tests implement it in memory -//! (an in-process adapter works directly), and the typed layer above is -//! shared by both. +//! The wire carries opaque bodies and a stringly venue selector; typing +//! is recovered here. A venue is named once, as a [`Venue`] marker +//! carrying its [`VenueId`] and body schema, and every call encodes +//! through [`IntentBody`] before the seam, so keeper code never handles +//! wire bytes. [`HostVenues`] is the seam bound to the module's own +//! `videre:venue/client` import; tests and in-process adapters +//! implement [`VenueTransport`] directly. The transport methods are +//! native AFIT, so dispatch is static and nothing on the call path +//! boxes. +use std::borrow::Cow; use std::fmt; +use std::future::Future; +use std::marker::PhantomData; use strum::IntoStaticStr; +use crate::bindings::videre::venue::client as shims; use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueFault}; /// Venue identifier: the id an adapter registers under and every client /// call routes to. Opaque beyond equality. #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct VenueId(String); +pub struct VenueId(Cow<'static, str>); impl VenueId { + /// Wrap a static id without allocating: the [`Venue::ID`] spelling. + #[must_use] + pub const fn from_static(id: &'static str) -> Self { + Self(Cow::Borrowed(id)) + } + /// The id at its wire spelling. #[must_use] pub fn as_str(&self) -> &str { @@ -31,13 +42,13 @@ impl VenueId { impl From for VenueId { fn from(id: String) -> Self { - Self(id) + Self(Cow::Owned(id)) } } impl From<&str> for VenueId { fn from(id: &str) -> Self { - Self(id.to_owned()) + Self(Cow::Owned(id.to_owned())) } } @@ -53,52 +64,126 @@ impl fmt::Display for VenueId { } } -/// Byte-level access to the keeper-facing `videre:venue/client` -/// interface, the venue named per call. -pub trait VenueClient { +/// One venue as a keeper types it: the id its adapter registers under +/// and the body schema it decodes. Implement on a unit marker +/// (`struct CowVenue;`) and drive it through [`VenueClient`]. +pub trait Venue { + /// The id the venue's adapter registers under. + const ID: VenueId; + + /// The versioned body schema the venue decodes. + type Body: IntentBody; +} + +/// The byte-level seam under the typed client: `videre:venue/client` +/// with the venue named per call. Native AFIT, so a [`VenueClient`] +/// over any transport dispatches statically. [`HostVenues`] binds it to +/// the module's own import; tests implement it in memory. +pub trait VenueTransport { /// Price an opaque intent body at the named venue. - fn quote(&self, venue: &VenueId, body: Vec) -> Result; + fn quote( + &self, + venue: &VenueId, + body: Vec, + ) -> impl Future>; /// Submit an opaque intent body to the named venue. - fn submit(&self, venue: &VenueId, body: Vec) -> Result; + fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> impl Future>; /// Report where a previously submitted intent is in its life. - fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result; + fn status( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future>; /// Ask the venue to withdraw an intent. Success means the venue /// accepted the cancellation, not that an in-flight settlement can /// no longer win the race. - fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault>; + fn cancel( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future>; +} + +/// The module's `videre:venue/client` import behind the +/// [`VenueTransport`] seam: the transport every guest-side +/// [`VenueClient`] defaults to. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostVenues; + +impl VenueTransport for HostVenues { + async fn quote(&self, venue: &VenueId, body: Vec) -> Result { + shims::quote(venue.as_str(), &body).map_err(VenueFault::from) + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + shims::submit(venue.as_str(), &body).map_err(VenueFault::from) + } + + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + shims::status(venue.as_str(), receipt).map_err(VenueFault::from) + } + + async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + shims::cancel(venue.as_str(), receipt).map_err(VenueFault::from) + } +} + +/// A typed client bound to one [`Venue`]: encodes the venue's +/// [`IntentBody`] to wire bytes and forwards through the +/// [`VenueTransport`] seam under [`Venue::ID`]. Zero-sized over the +/// default [`HostVenues`] transport. +pub struct VenueClient { + transport: T, + venue: PhantomData, +} + +impl VenueClient { + /// Bind the venue over the module's own `videre:venue/client` + /// import. + #[must_use] + pub const fn new() -> Self { + Self { + transport: HostVenues, + venue: PhantomData, + } + } } -/// A typed intent client bound to one venue: encodes an [`IntentBody`] -/// to wire bytes and forwards through the [`VenueClient`] seam. -#[derive(Clone, Debug)] -pub struct IntentClient

{ - venues: P, - venue: VenueId, +impl Default for VenueClient { + fn default() -> Self { + Self::new() + } } -impl IntentClient

{ - /// Bind a client handle to the venue id the registry resolves. - pub fn new(venues: P, venue: impl Into) -> Self { +impl VenueClient { + /// Bind the venue over a caller-supplied transport (tests, + /// in-process adapters). + pub const fn with_transport(transport: T) -> Self { Self { - venues, - venue: venue.into(), + transport, + venue: PhantomData, } } - /// The venue every call on this client routes to. - pub fn venue(&self) -> &VenueId { - &self.venue + /// The venue id every call on this client routes to. + #[must_use] + pub fn venue(&self) -> VenueId { + V::ID } - /// Encode a typed body and price it at the bound venue. The returned - /// [`Quoted`] carries the encoded bytes, so `submit` sends exactly - /// the body the venue priced. - pub fn quote(&self, body: &B) -> Result, ClientError> { + /// Encode the typed body and price it at the bound venue. The + /// returned [`Quoted`] carries the encoded bytes, so `submit` sends + /// exactly the body the venue priced. + pub async fn quote(&self, body: &V::Body) -> Result, ClientError> { let bytes = body.to_bytes()?; - let quotation = self.venues.quote(&self.venue, bytes.clone())?; + let quotation = self.transport.quote(&V::ID, bytes.clone()).await?; Ok(Quoted { client: self, bytes, @@ -106,47 +191,74 @@ impl IntentClient

{ }) } - /// Encode a typed body and submit it to the bound venue. - pub fn submit(&self, body: &B) -> Result { + /// Encode the typed body and submit it to the bound venue. + pub async fn submit(&self, body: &V::Body) -> Result { let bytes = body.to_bytes()?; - Ok(self.venues.submit(&self.venue, bytes)?) + Ok(self.transport.submit(&V::ID, bytes).await?) } /// Report where a previously submitted intent is in its life. - pub fn status(&self, receipt: &[u8]) -> Result { - Ok(self.venues.status(&self.venue, receipt)?) + pub async fn status(&self, receipt: &[u8]) -> Result { + Ok(self.transport.status(&V::ID, receipt).await?) } /// Ask the bound venue to withdraw an intent. - pub fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - Ok(self.venues.cancel(&self.venue, receipt)?) + pub async fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { + Ok(self.transport.cancel(&V::ID, receipt).await?) + } +} + +impl Clone for VenueClient { + fn clone(&self) -> Self { + Self { + transport: self.transport.clone(), + venue: PhantomData, + } + } +} + +impl fmt::Debug for VenueClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VenueClient") + .field("venue", &V::ID) + .finish_non_exhaustive() } } /// A priced intent: the quotation plus the exact bytes it prices, bound -/// to the client that fetched it. Consuming it with [`submit`](Self::submit) -/// is the only way from a quote to a submission, so a keeper cannot -/// submit a body other than the one quoted. -#[derive(Debug)] -pub struct Quoted<'a, P> { - client: &'a IntentClient

, +/// to the client that fetched it. Consuming it with +/// [`submit`](Self::submit) is the only way from a quote to a +/// submission, so a keeper cannot submit a body other than the one +/// quoted. +pub struct Quoted<'a, V: Venue, T: VenueTransport> { + client: &'a VenueClient, bytes: Vec, quotation: Quotation, } -impl Quoted<'_, P> { +impl Quoted<'_, V, T> { /// The venue's indicative quotation for the body. + #[must_use] pub fn quotation(&self) -> &Quotation { &self.quotation } /// Submit the quoted body to the venue that priced it. - pub fn submit(self) -> Result { - Ok(self.client.venues.submit(&self.client.venue, self.bytes)?) + pub async fn submit(self) -> Result { + Ok(self.client.transport.submit(&V::ID, self.bytes).await?) + } +} + +impl fmt::Debug for Quoted<'_, V, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Quoted") + .field("venue", &V::ID) + .field("quotation", &self.quotation) + .finish_non_exhaustive() } } -/// Why a typed intent call failed: before the wire (the body failed to +/// Why a typed client call failed: before the wire (the body failed to /// encode) or beyond it (the registry or venue refused). /// /// `IntoStaticStr` yields a snake_case label per case for log and diff --git a/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs index 9e751747..00d6840c 100644 --- a/crates/videre-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -13,6 +13,7 @@ use nexum_sdk::host; use strum::IntoStaticStr; use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; +use crate::client::ClientError; use crate::{Fault, RateLimit, VenueError}; /// Owned mirror of the wire `venue-error` with `Display`: what typed @@ -130,6 +131,28 @@ impl From for VenueError { } } +/// Fold a typed client failure into the SDK-neutral fault a keeper +/// handler returns: an encode failure and a misnamed venue are the +/// caller's `invalid-input`; venue refusals map structurally. +impl From for host::Fault { + fn from(err: ClientError) -> Self { + match err { + ClientError::Body(body) => host::Fault::InvalidInput(body.to_string()), + ClientError::Venue(fault) => match fault { + VenueFault::UnknownVenue => host::Fault::InvalidInput(fault.to_string()), + VenueFault::InvalidBody(s) => host::Fault::InvalidInput(s), + VenueFault::Unsupported => host::Fault::Unsupported(fault.to_string()), + VenueFault::Denied(s) => host::Fault::Denied(s), + VenueFault::RateLimited { retry_after_ms } => { + host::Fault::RateLimited(host::RateLimit { retry_after_ms }) + } + VenueFault::Unavailable(s) => host::Fault::Unavailable(s), + VenueFault::Timeout => host::Fault::Timeout, + }, + } + } +} + /// Fold a wasi:http fetch failure into the venue error an intent /// function returns: an allowlist refusal stays `denied`, a timeout is /// `timeout`, and transport failures (including a request the adapter diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 23d2ae5d..3240faed 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -1,7 +1,7 @@ //! The generic keeper sweep: one pass assembling the world-neutral //! stores - [`WatchSet`] to [`Gates`] to [`ConditionalSource::poll`] to //! [`Retrier`] to [`Journal`] - and routing submissions through the -//! [`VenueClient`] seam. +//! [`VenueTransport`] seam. //! //! [`Sweep`] is the shared poll outcome: the concrete //! [`ConditionalSource::Outcome`] a keeper's sources produce so @@ -15,7 +15,7 @@ use nexum_sdk::keeper::{ }; use nexum_sdk::prelude::{hex, keccak256}; -use crate::client::{VenueClient, VenueId}; +use crate::client::{VenueId, VenueTransport}; use crate::{SubmitOutcome, UnsignedTx, VenueFault}; /// What one poll asks the sweep to do with its watch. @@ -59,9 +59,9 @@ impl Keeper { } } -impl Keeper { +impl Keeper { /// Sweep the watch set once at `tick`: poll every ready watch, - /// submit [`Sweep::Submit`] bodies through the venue client, and + /// submit [`Sweep::Submit`] bodies through the venue seam, and /// run every other outcome and every venue refusal through the /// [`Retrier`]. A venue-and-body key is checked against the /// `submitted:` [`Journal`] before every submit and recorded on @@ -69,7 +69,7 @@ impl Keeper { /// a `requires-signing` answer journals nothing and is surfaced /// afresh each sweep. Store faults abort the sweep; venue refusals /// never do - they fold into per-watch retry actions. - pub fn sweep(&self, host: &H, tick: &Tick) -> Result + pub async fn sweep(&self, host: &H, tick: &Tick) -> Result where H: LocalStoreHost, S: ConditionalSource, @@ -102,7 +102,7 @@ impl Keeper { report.duplicates += 1; continue; } - match self.venues.submit(&self.venue, body) { + match self.venues.submit(&self.venue, body).await { Ok(SubmitOutcome::Accepted(_)) => { journal.record(&key)?; report.submitted += 1; @@ -192,9 +192,14 @@ mod tests { use nexum_sdk_test::MockLocalStore; use super::{Keeper, Sweep, SweepReport}; - use crate::client::{VenueClient, VenueId}; + use crate::client::{VenueId, VenueTransport}; use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; + /// Drive a sweep on the test's synchronous boundary. + fn run(future: F) -> F::Output { + crate::rt::complete(future).expect("sweep futures complete in one poll") + } + /// Answers every poll with one programmed outcome. struct StubSource(Sweep); @@ -232,21 +237,29 @@ mod tests { } } - impl VenueClient for &StubVenue { - fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + impl VenueTransport for &StubVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { unreachable!("quote not exercised") } - fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + async fn submit( + &self, + _venue: &VenueId, + body: Vec, + ) -> Result { self.submitted.borrow_mut().push(body); self.outcome.clone() } - fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { unreachable!("status not exercised") } - fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { unreachable!("cancel not exercised") } } @@ -274,7 +287,7 @@ mod tests { let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xA5, 0x5A]))); let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.polled, 1); assert_eq!(report.submitted, 1); assert_eq!(venue.submitted.borrow().as_slice(), [b"body".to_vec()]); @@ -285,7 +298,7 @@ mod tests { assert_eq!(WatchSet::new(&host).list().expect("list reads").len(), 1); // A later sweep re-polls the watch but never re-posts the body. - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.submitted, 0); assert_eq!(report.duplicates, 1); assert_eq!(venue.submitted.borrow().len(), 1); @@ -303,8 +316,18 @@ mod tests { ])); let keeper = Keeper::new(source, &venue, "stub"); - assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").submitted, 1); - assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").submitted, 1); + assert_eq!( + run(keeper.sweep(&host, &TICK)) + .expect("sweep runs") + .submitted, + 1 + ); + assert_eq!( + run(keeper.sweep(&host, &TICK)) + .expect("sweep runs") + .submitted, + 1 + ); assert_eq!( venue.submitted.borrow().as_slice(), [b"one".to_vec(), b"two".to_vec()] @@ -324,13 +347,13 @@ mod tests { let venue = StubVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.unsigned, vec![tx.clone()]); assert_eq!(report.submitted, 0); // Nothing accepted, nothing journalled: the next sweep // surfaces the same transaction again. - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.unsigned, vec![tx]); } @@ -344,8 +367,7 @@ mod tests { .expect("gate writes"); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = keeper(Sweep::Submit(b"body".to_vec()), &venue) - .sweep(&host, &TICK) + let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) .expect("sweep runs"); assert_eq!(report.gated, 1); assert_eq!(report.polled, 0); @@ -358,9 +380,7 @@ mod tests { put_watch(&host); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = keeper(Sweep::Drop, &venue) - .sweep(&host, &TICK) - .expect("sweep runs"); + let report = run(keeper(Sweep::Drop, &venue).sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.dropped, 1); assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); } @@ -372,11 +392,11 @@ mod tests { let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); let keeper = keeper(Sweep::Backoff { seconds: 30 }, &venue); - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.retried, 1); // Still inside the backoff window: gated, not polled. - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.gated, 1); // At the threshold the gate opens again. @@ -384,7 +404,7 @@ mod tests { epoch_s: TICK.epoch_s + 30, ..TICK }; - let report = keeper.sweep(&host, &later).expect("sweep runs"); + let report = run(keeper.sweep(&host, &later)).expect("sweep runs"); assert_eq!(report.polled, 1); } @@ -397,7 +417,7 @@ mod tests { })); let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.retried, 1); // 2500 ms rounds up to a 3 s epoch gate. @@ -405,12 +425,18 @@ mod tests { epoch_s: TICK.epoch_s + 2, ..TICK }; - assert_eq!(keeper.sweep(&host, &at_2s).expect("sweep runs").gated, 1); + assert_eq!( + run(keeper.sweep(&host, &at_2s)).expect("sweep runs").gated, + 1 + ); let at_3s = Tick { epoch_s: TICK.epoch_s + 3, ..TICK }; - assert_eq!(keeper.sweep(&host, &at_3s).expect("sweep runs").polled, 1); + assert_eq!( + run(keeper.sweep(&host, &at_3s)).expect("sweep runs").polled, + 1 + ); } #[test] @@ -419,8 +445,7 @@ mod tests { put_watch(&host); let venue = StubVenue::new(Err(VenueFault::Denied("blocked".into()))); - let report = keeper(Sweep::Submit(b"body".to_vec()), &venue) - .sweep(&host, &TICK) + let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) .expect("sweep runs"); assert_eq!(report.dropped, 1); assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); @@ -433,9 +458,12 @@ mod tests { let venue = StubVenue::new(Err(VenueFault::Unavailable("down".into()))); let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - let report = keeper.sweep(&host, &TICK).expect("sweep runs"); + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report.retried, 1); - assert_eq!(keeper.sweep(&host, &TICK).expect("sweep runs").polled, 1); + assert_eq!( + run(keeper.sweep(&host, &TICK)).expect("sweep runs").polled, + 1 + ); } #[test] @@ -443,9 +471,7 @@ mod tests { let host = MockLocalStore::default(); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = keeper(Sweep::WaitBlock, &venue) - .sweep(&host, &TICK) - .expect("sweep runs"); + let report = run(keeper(Sweep::WaitBlock, &venue).sweep(&host, &TICK)).expect("sweep runs"); assert_eq!(report, SweepReport::default()); } } diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 4c34ada2..29dcd772 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -18,17 +18,21 @@ //! one-byte version tag plus the borsh payload; an unknown tag fails //! typedly rather than as a stringly decode error. //! -//! - [`client`] - the typed intent client core: [`VenueId`] and -//! [`IntentClient`], which binds a venue and encodes through -//! [`IntentBody`] before the byte-level [`VenueClient`] seam. Lives -//! here (not in the strategy SDK) so the codec and the client that -//! speaks it version together. +//! - [`client`] - the typed venue client: a [`Venue`] marker (its +//! [`VenueId`] plus body schema) drives [`VenueClient`], which +//! encodes through [`IntentBody`] before the byte-level, native-AFIT +//! [`VenueTransport`] seam ([`HostVenues`] binds it to the module's +//! own `videre:venue/client` import). Lives here (not in the +//! strategy SDK) so the codec and the client that speaks it version +//! together. `#[videre_sdk::keeper]` on a handler impl wires the +//! import and drives async handlers; [`rt`] completes their futures +//! on the synchronous guest boundary. //! //! - [`keeper`] - the generic sweep assembler: [`Keeper::sweep`] runs //! the world-neutral `nexum_sdk::keeper` stores over a //! [`ConditionalSource`](nexum_sdk::keeper::ConditionalSource) //! producing the shared [`Sweep`] outcome, submitting through the -//! [`VenueClient`] seam. +//! [`VenueTransport`] seam. //! //! - [`transport`] - typed wrappers over the world's scoped imports: //! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] @@ -42,15 +46,16 @@ //! //! ## Why the bindgen lives in this crate //! -//! The adapter world's types generate once, in [`bindings`]: the trait, -//! wrappers, and client core are all typed over them. `#[venue]`'s -//! per-cdylib bindgen remaps the type interfaces onto [`bindings`], so -//! an adapter speaks these types while its world imports stay derived -//! from its own manifest. +//! The shared interfaces generate once, in [`bindings`], from an +//! import-only world: the trait, wrappers, and client core are all +//! typed over them. The per-cdylib bindgens (`#[venue]`, `#[keeper]`) +//! remap the shared interfaces onto [`bindings`], so a macro-built +//! component speaks these types while its world stays derived from its +//! own manifest. //! //! [`ChainHost`]: nexum_sdk::host::ChainHost -//! [`IntentClient`]: client::IntentClient //! [`VenueClient`]: client::VenueClient +//! [`VenueTransport`]: client::VenueTransport #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] @@ -63,16 +68,26 @@ pub mod body; pub mod client; pub mod faults; pub mod keeper; +pub mod rt; pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, Quoted, VenueClient, VenueId}; +pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; pub use faults::VenueFault; pub use keeper::{Keeper, Sweep, SweepReport}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`videre_macros::IntentBody`]. pub use videre_macros::IntentBody; +/// The blessed keeper authoring path. Apply to a worker's handler impl: +/// emits the per-cdylib bindgen for a world derived from `module.toml` +/// (asserting the `client` capability), remaps the videre interfaces +/// onto the SDK bindings so the module drives a [`VenueClient`] with +/// shared type identity, dispatches events to the handlers (async ones +/// completed through [`rt::complete`]), and folds [`ClientError`] into +/// the wire fault so `?` works in handlers. See +/// [`videre_macros::keeper`]. +pub use videre_macros::keeper; /// The single blessed venue authoring path. Apply to the adapter's /// `impl VenueAdapter for MyVenue` block: emits the per-cdylib bindgen /// for a world derived from `module.toml` (asserting its diff --git a/crates/videre-sdk/src/rt.rs b/crates/videre-sdk/src/rt.rs new file mode 100644 index 00000000..6a62f4f3 --- /dev/null +++ b/crates/videre-sdk/src/rt.rs @@ -0,0 +1,38 @@ +//! Futures on the synchronous guest boundary. + +use std::future::Future; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +/// Complete a future in one poll. Guest host imports are synchronous, +/// so every await in a keeper future resolves immediately and one poll +/// runs it to completion. `None` reports a future that suspended, +/// which nothing built over the host imports does; the keeper macro's +/// emitted glue folds it to a typed fault. +pub fn complete(future: F) -> Option { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => Some(output), + Poll::Pending => None, + } +} + +#[cfg(test)] +mod tests { + use super::complete; + + #[test] + fn ready_chain_completes_in_one_poll() { + async fn two() -> u8 { + let one = async { 1u8 }.await; + one + async { 1u8 }.await + } + assert_eq!(complete(two()), Some(2)); + } + + #[test] + fn suspending_future_reports_none() { + assert_eq!(complete(std::future::pending::<()>()), None); + } +} diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index e71fe381..9c0814f9 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -1,18 +1,23 @@ //! Acceptance surface for the venue SDK: a hand-written adapter //! compiles against [`VenueAdapter`] and round-trips a versioned body //! through `#[derive(IntentBody)]` - including the typed -//! unknown-version failure and the typed client core driving the -//! adapter through the [`VenueClient`] seam. The world-export glue is -//! `#[videre_sdk::venue]`'s alone; echo-venue is its worked target. +//! unknown-version failure and the typed [`VenueClient`] driving the +//! adapter through the [`VenueTransport`] seam. The world-export glue +//! is `#[videre_sdk::venue]`'s alone; echo-venue is its worked target. use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::value_flow::{Asset, AssetAmount}; use videre_sdk::{ - AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, - IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, - VenueFault, VenueId, + AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentHeader, IntentStatus, + Quotation, Settlement, SubmitOutcome, Venue, VenueAdapter, VenueClient, VenueError, VenueFault, + VenueId, VenueTransport, }; +/// Drive a client future on the test's synchronous boundary. +fn run(future: F) -> F::Output { + videre_sdk::rt::complete(future).expect("client futures complete in one poll") +} + /// First published body version: a fixed-price quote. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] struct QuoteV1 { @@ -115,33 +120,50 @@ impl VenueAdapter for DemoAdapter { } } -/// In-process client: routes the demo venue id straight into the adapter, -/// standing in for the host registry the keeper-side seam will bind. +/// The demo venue as a keeper types it. +struct DemoVenue; + +impl Venue for DemoVenue { + const ID: VenueId = VenueId::from_static("demo"); + type Body = QuoteBody; +} + +/// A venue id no adapter answers for, over the same body schema. +struct NowhereVenue; + +impl Venue for NowhereVenue { + const ID: VenueId = VenueId::from_static("nowhere"); + type Body = QuoteBody; +} + +/// In-process transport: routes the demo venue id straight into the +/// adapter, standing in for the host registry the keeper-side seam +/// binds. struct InProcessClient; -impl VenueClient for InProcessClient { - fn quote(&self, venue: &VenueId, body: Vec) -> Result { +impl VenueTransport for InProcessClient { + async fn quote(&self, venue: &VenueId, body: Vec) -> Result { if venue.as_str() != "demo" { return Err(VenueFault::UnknownVenue); } DemoAdapter::quote(body).map_err(Into::into) } - fn submit(&self, venue: &VenueId, body: Vec) -> Result { + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { if venue.as_str() != "demo" { return Err(VenueFault::UnknownVenue); } DemoAdapter::submit(body).map_err(Into::into) } - fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { if venue.as_str() != "demo" { return Err(VenueFault::UnknownVenue); } DemoAdapter::status(receipt.to_vec()).map_err(Into::into) } - fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { if venue.as_str() != "demo" { return Err(VenueFault::UnknownVenue); } @@ -237,50 +259,54 @@ fn adapter_reports_an_unknown_version_as_invalid_body() { } #[test] -fn typed_client_round_trips_through_the_client_seam() { - let client = IntentClient::new(InProcessClient, "demo"); +fn typed_client_round_trips_through_the_transport_seam() { + let client = VenueClient::::with_transport(InProcessClient); + assert_eq!(client.venue(), DemoVenue::ID); - let outcome = client.submit(&v2_body()).unwrap(); + let outcome = run(client.submit(&v2_body())).unwrap(); let SubmitOutcome::Accepted(receipt) = outcome else { panic!("demo venue always accepts"); }; assert_eq!(receipt, RECEIPT.to_vec()); - assert_eq!(client.status(&receipt).unwrap(), IntentStatus::Open); - client.cancel(&receipt).unwrap(); + assert_eq!(run(client.status(&receipt)).unwrap(), IntentStatus::Open); + run(client.cancel(&receipt)).unwrap(); assert!(matches!( - client.status(&[0, 1]).unwrap_err(), + run(client.status(&[0, 1])).unwrap_err(), ClientError::Venue(VenueFault::Denied(_)) )); } #[test] fn quote_typestate_prices_then_submits_the_quoted_body() { - fn drive(client: &IntentClient) -> Result { + async fn drive( + client: &VenueClient, + ) -> Result { // The typestate chain under test: a quotation is the only path - // from a priced body to its submission. - client.quote(&v2_body())?.submit() + // from a priced body to its submission. Static dispatch end to + // end: the transport is native AFIT, nothing boxes. + client.quote(&v2_body()).await?.submit().await } - let client = IntentClient::new(InProcessClient, "demo"); + let client = VenueClient::::with_transport(InProcessClient); - let quoted = client.quote(&v2_body()).unwrap(); + let quoted = run(client.quote(&v2_body())).unwrap(); assert_eq!( quoted.quotation().gives.amount, 1_000_000u64.to_be_bytes().to_vec() ); assert_eq!(quoted.quotation().valid_until_ms, 1_700_000_000_000); - let outcome = drive(&client).unwrap(); + let outcome = run(drive(&client)).unwrap(); assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == RECEIPT.to_vec())); } #[test] fn unbound_venue_is_unknown_at_the_client() { - let client = IntentClient::new(InProcessClient, "nowhere"); + let client = VenueClient::::with_transport(InProcessClient); assert!(matches!( - client.submit(&v2_body()).unwrap_err(), + run(client.submit(&v2_body())).unwrap_err(), ClientError::Venue(VenueFault::UnknownVenue) )); } diff --git a/modules/examples/echo-keeper/Cargo.toml b/modules/examples/echo-keeper/Cargo.toml new file mode 100644 index 00000000..ff843e85 --- /dev/null +++ b/modules/examples/echo-keeper/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "echo-keeper" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shepherd example keeper paired with the echo-venue adapter: drives it through the typed VenueClient emitted by #[videre_sdk::keeper] - quote, submit, status, cancel - and logs the intent-status transitions the registry fans back." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-sdk = { path = "../../../crates/nexum-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-keeper/module.toml b/modules/examples/echo-keeper/module.toml new file mode 100644 index 00000000..0620286c --- /dev/null +++ b/modules/examples/echo-keeper/module.toml @@ -0,0 +1,40 @@ +# echo-keeper module manifest - the blessed keeper half of the echo +# pair. It drives the echo-venue adapter through the typed client, so it +# declares the `client` capability alongside `logging`; the per-module +# world the macro derives imports exactly videre:venue/client and +# nexum:host/logging. + +[module] +name = "echo-keeper" +version = "0.1.0" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# `client` grants the videre:venue/client import (required by +# #[videre_sdk::keeper]); `logging` the log sink. +required = ["client", "logging"] +optional = [] + +[capabilities.http] +allow = [] + +# Drive the venue on every chain-1 block. +[[subscription]] +kind = "block" +chain_id = 1 + +# Observe the status transitions the registry polls from the echo-venue +# adapter. +[[subscription]] +kind = "intent-status" +venue = "echo-venue" + +[config] +name = "echo-keeper" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/modules/examples/echo-keeper/src/lib.rs b/modules/examples/echo-keeper/src/lib.rs new file mode 100644 index 00000000..05e41619 --- /dev/null +++ b/modules/examples/echo-keeper/src/lib.rs @@ -0,0 +1,109 @@ +//! # echo-keeper (reference videre keeper module) +//! +//! The blessed keeper half of the echo pair: on every chain-1 block it +//! drives the echo-venue adapter through the typed +//! `VenueClient` - quote, submit, status, cancel, all with a +//! typed body - and logs each `intent-status` transition the registry +//! fans back. Where echo-client hand-writes byte marshalling over the +//! raw `videre:venue/client` import, this module is +//! `#[videre_sdk::keeper]`: the macro wires the world and the client +//! import, and the author never sees wire bytes. +//! +//! It declares two capabilities (`client`, `logging`), so the built +//! component imports `videre:venue/client` and `nexum:host/logging` and +//! nothing else: the per-module world matches the manifest by +//! construction. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::{logging, types}; +use videre_sdk::{SubmitOutcome, Venue, VenueClient, VenueId}; + +/// The echo venue as this keeper types it: the id the paired adapter +/// answers for and the body schema below. +struct EchoVenue; + +impl Venue for EchoVenue { + const ID: VenueId = VenueId::from_static("echo-venue"); + type Body = EchoBody; +} + +/// The keeper's published body schema. The echo venue accepts any +/// bytes, so v1 is just the block number: enough to exercise the typed +/// codec end to end. +#[derive(videre_sdk::IntentBody)] +enum EchoBody { + V1(u64), +} + +struct EchoKeeper; + +#[videre_sdk::keeper] +impl EchoKeeper { + async fn on_block(block: types::Block) -> Result<(), Fault> { + let venue = VenueClient::::new(); + let body = EchoBody::V1(block.number); + + // Quote-then-submit through the typestate: the venue prices + // exactly the bytes it is later handed. ClientError folds into + // the wire fault, so `?` applies throughout. + let quoted = venue.quote(&body).await?; + logging::log( + logging::Level::Info, + &format!( + "quoted at {}: gives {} amount bytes", + EchoVenue::ID, + quoted.quotation().gives.amount.len(), + ), + ); + let receipt = match quoted.submit().await? { + SubmitOutcome::Accepted(receipt) => receipt, + SubmitOutcome::RequiresSigning(_) => { + logging::log( + logging::Level::Warn, + &format!("{} unexpectedly asked for a signature", EchoVenue::ID), + ); + return Ok(()); + } + }; + logging::log( + logging::Level::Info, + &format!( + "submitted to {}: receipt {} bytes", + EchoVenue::ID, + receipt.len(), + ), + ); + + let status = venue.status(&receipt).await?; + logging::log( + logging::Level::Info, + &format!("status at {}: {status:?}", EchoVenue::ID), + ); + + venue.cancel(&receipt).await?; + logging::log( + logging::Level::Info, + &format!("cancelled at {}", EchoVenue::ID), + ); + Ok(()) + } + + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + logging::log( + logging::Level::Info, + &format!( + "intent status from venue {}: {:?} ({} receipt bytes)", + update.venue, + body.status, + update.receipt.len(), + ), + ); + Ok(()) + } +} From b4bd0e53aedba2c2d32c46410297bf39b7d7ed49 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 10:21:45 +0000 Subject: [PATCH 38/53] sdk: land the alloy-grade DX polish cluster Add an Order typestate builder over the 12-field CoW OrderBody with SellToken and BuyToken newtypes, so a keeper cannot swap sides or skip a required field: sell/buy entry fixes the kind, the counter-side limit and expiry are compile-time required states, and the optionals default. Seal the extension traits: Host and HostFault (nexum-sdk), RuntimeTypes and Runtime (nexum-runtime), and VenueTransport (videre-sdk, the pool seam's successor) each gain a doc-hidden sealing marker an implementor opts into, so the surfaces can grow without silent downstream breakage. Apply #[non_exhaustive] uniformly across the public error and label enums, adding wildcard folds at the cross-crate match sites. Emit the mirrored vocabularies from single-source consts in nexum-world: the capability name consts and CORE_IFACES feed both the world-synthesis table and the runtime registry, and the fault-label consts feed the runtime's label projection with the SDK's strum labels pinned to them in test. Fix the operator logs that debug-formatted venue faults: the SDK Fault's rate-limited Display now carries the retry-after hint, the runtime's fault_message keeps it, and the venue registry renders wire errors through message projections instead of the bindgen's debug Display, so rate-limited retry-after-ms survives to the log line. The golden-bridge sweep found no residue. --- Cargo.lock | 2 + crates/cow-venue/src/body.rs | 22 +- crates/cow-venue/src/classification_data.rs | 1 + crates/cow-venue/src/client.rs | 26 +- crates/cow-venue/src/lib.rs | 4 +- crates/cow-venue/src/order.rs | 229 ++++++++++++++++++ crates/nexum-runtime/Cargo.toml | 3 + crates/nexum-runtime/src/builder.rs | 4 + crates/nexum-runtime/src/host/actor.rs | 1 + .../src/host/component/builder.rs | 1 + .../nexum-runtime/src/host/component/chain.rs | 1 + .../nexum-runtime/src/host/component/mod.rs | 2 + .../src/host/component/runtime_types.rs | 4 +- crates/nexum-runtime/src/host/error.rs | 39 +-- .../src/host/local_store_redb.rs | 1 + crates/nexum-runtime/src/host/logs/mod.rs | 1 + crates/nexum-runtime/src/lib.rs | 8 + .../src/manifest/capabilities.rs | 5 +- crates/nexum-runtime/src/manifest/error.rs | 1 + crates/nexum-runtime/src/manifest/types.rs | 12 +- crates/nexum-runtime/src/preset.rs | 7 +- .../nexum-runtime/src/runtime/event_loop.rs | 1 + crates/nexum-runtime/src/supervisor.rs | 7 +- crates/nexum-runtime/src/test_utils/types.rs | 2 + crates/nexum-sdk/Cargo.toml | 2 + crates/nexum-sdk/src/chain/method.rs | 1 + crates/nexum-sdk/src/config.rs | 1 + crates/nexum-sdk/src/host.rs | 65 ++++- crates/nexum-sdk/src/http.rs | 1 + crates/nexum-status-body/src/lib.rs | 1 + crates/nexum-world/src/lib.rs | 119 ++++++++- crates/shepherd-cow-host/tests/cow_boot.rs | 2 + crates/shepherd-sdk/src/cow/composable.rs | 4 +- crates/shepherd-sdk/src/cow/error.rs | 2 + crates/shepherd-sdk/src/cow/mod.rs | 4 +- crates/shepherd/src/main.rs | 4 + crates/videre-host/src/bindings.rs | 39 +++ crates/videre-host/src/registry.rs | 5 +- crates/videre-sdk/src/body.rs | 1 + crates/videre-sdk/src/client.rs | 13 +- crates/videre-sdk/src/faults.rs | 6 +- crates/videre-sdk/src/keeper.rs | 2 + crates/videre-sdk/tests/adapter.rs | 2 + crates/videre-test/src/fixture.rs | 1 + modules/examples/http-probe/src/strategy.rs | 4 +- 45 files changed, 573 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 75b61f32..9759d512 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3633,6 +3633,7 @@ dependencies = [ "metrics-exporter-prometheus", "nexum-runtime", "nexum-tasks", + "nexum-world", "redb", "serde", "serde_json", @@ -3666,6 +3667,7 @@ dependencies = [ "nexum-module-macros", "nexum-sdk-test", "nexum-status-body", + "nexum-world", "proptest", "serde_json", "strum", diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index a4ff6aa3..e7e40fe4 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -38,23 +38,15 @@ mod tests { use super::*; use videre_test::{CodecVectors, Expectation}; - use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; + use crate::order::{BuyToken, SellToken}; fn order_body() -> OrderBody { - OrderBody { - sell_token: [0x11; 20], - buy_token: [0x22; 20], - receiver: None, - sell_amount: [0x01; 32], - buy_amount: [0x02; 32], - valid_to: 1_700_000_000, - app_data: [0x44; 32], - fee_amount: [0u8; 32], - kind: OrderKind::Sell, - partially_fillable: true, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - } + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build() } fn composable_body() -> ComposableBody { diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs index 3938eddd..49162e0b 100644 --- a/crates/cow-venue/src/classification_data.rs +++ b/crates/cow-venue/src/classification_data.rs @@ -46,6 +46,7 @@ struct Document { /// Why the shipped classification data could not be turned into a table. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum ClassificationError { /// The TOML did not parse or a field had the wrong type. #[error("classification data is not valid TOML: {0}")] diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 274906c7..892e4f4e 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -47,6 +47,8 @@ mod tests { submitted: SubmitLog, } + impl videre_sdk::client::sealed::SealedTransport for SpyClient {} + impl VenueTransport for SpyClient { async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { unreachable!("quote not exercised") @@ -78,21 +80,15 @@ mod tests { fn sample_body() -> CowIntentBody { use crate::body::CowIntent; - use crate::order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; - CowIntentBody::V1(CowIntent::Order(OrderBody { - sell_token: [0x11; 20], - buy_token: [0x22; 20], - receiver: None, - sell_amount: [0x01; 32], - buy_amount: [0x02; 32], - valid_to: 1_700_000_000, - app_data: [0x44; 32], - fee_amount: [0u8; 32], - kind: OrderKind::Sell, - partially_fillable: true, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - })) + use crate::order::{BuyToken, OrderBody, SellToken}; + CowIntentBody::V1(CowIntent::Order( + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build(), + )) } #[test] diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 3b4054fb..440fa77b 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -59,7 +59,9 @@ pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] pub use composable::ComposableBody; #[cfg(feature = "body")] -pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; +pub use order::{ + BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource, +}; #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index cc31b934..a8ac4a57 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -9,6 +9,8 @@ //! marker enums are canonical wire forms, not on-chain keccak markers, //! so the adapter, not this type, owns the projection to and from chain. +use core::marker::PhantomData; + use borsh::{BorshDeserialize, BorshSerialize}; /// A 20-byte EVM address in wire form. @@ -17,6 +19,28 @@ pub type Address = [u8; 20]; /// A 256-bit amount as its 32-byte big-endian representation. pub type U256 = [u8; 32]; +/// The token an order sells, typed so a builder call cannot swap +/// sides with the buy token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SellToken(pub Address); + +impl From

for SellToken { + fn from(address: Address) -> Self { + Self(address) + } +} + +/// The token an order buys, typed so a builder call cannot swap sides +/// with the sell token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BuyToken(pub Address); + +impl From
for BuyToken { + fn from(address: Address) -> Self { + Self(address) + } +} + /// Which side of the trade is fixed. #[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] pub enum OrderKind { @@ -79,6 +103,162 @@ pub struct OrderBody { pub buy_token_balance: BuyTokenDestination, } +impl OrderBody { + /// Start a sell order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn sell(token: SellToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Sell, token.0, amount, [0; 20], [0; 32]) + } + + /// Start a buy order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn buy(token: BuyToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Buy, [0; 20], [0; 32], token.0, amount) + } +} + +/// Builder state: the buy-side limit is unset. +pub enum NeedsBuy {} +/// Builder state: the sell-side limit is unset. +pub enum NeedsSell {} +/// Builder state: the expiry is unset. +pub enum NeedsValidTo {} +/// Builder state: every required field is set. +pub enum Ready {} + +/// Typestate builder for [`OrderBody`]: [`OrderBody::sell`] or +/// [`OrderBody::buy`] fixes the kind and its side, the counter-side +/// limit and the expiry are compile-time required, and the optionals +/// default (self-receive, zero `app_data` and `fee_amount`, +/// fill-or-kill, ERC-20 balances). +#[derive(Clone, Debug)] +pub struct OrderBuilder { + body: OrderBody, + state: PhantomData, +} + +impl OrderBuilder { + const fn start( + kind: OrderKind, + sell_token: Address, + sell_amount: U256, + buy_token: Address, + buy_amount: U256, + ) -> Self { + Self { + body: OrderBody { + sell_token, + buy_token, + receiver: None, + sell_amount, + buy_amount, + valid_to: 0, + app_data: [0; 32], + fee_amount: [0; 32], + kind, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }, + state: PhantomData, + } + } + + const fn into_state(self) -> OrderBuilder { + OrderBuilder { + body: self.body, + state: PhantomData, + } + } +} + +impl OrderBuilder { + /// Demand at least `amount` of `token` in return. + #[must_use] + pub const fn for_at_least( + mut self, + token: BuyToken, + amount: U256, + ) -> OrderBuilder { + self.body.buy_token = token.0; + self.body.buy_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Spend at most `amount` of `token`. + #[must_use] + pub const fn for_at_most( + mut self, + token: SellToken, + amount: U256, + ) -> OrderBuilder { + self.body.sell_token = token.0; + self.body.sell_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Expire at `valid_to` (Unix seconds). + #[must_use] + pub const fn valid_to(mut self, valid_to: u32) -> OrderBuilder { + self.body.valid_to = valid_to; + self.into_state() + } +} + +impl OrderBuilder { + /// Deliver the buy token to `receiver` instead of the owner. + #[must_use] + pub const fn receiver(mut self, receiver: Address) -> Self { + self.body.receiver = Some(receiver); + self + } + + /// Set the 32-byte on-chain app-data hash. + #[must_use] + pub const fn app_data(mut self, app_data: [u8; 32]) -> Self { + self.body.app_data = app_data; + self + } + + /// Set the fee taken in the sell token. + #[must_use] + pub const fn fee_amount(mut self, fee_amount: U256) -> Self { + self.body.fee_amount = fee_amount; + self + } + + /// Allow the order to fill partially. + #[must_use] + pub const fn partially_fillable(mut self) -> Self { + self.body.partially_fillable = true; + self + } + + /// Source the sell token from `source`. + #[must_use] + pub const fn sell_token_balance(mut self, source: SellTokenSource) -> Self { + self.body.sell_token_balance = source; + self + } + + /// Deliver the buy token to `destination`. + #[must_use] + pub const fn buy_token_balance(mut self, destination: BuyTokenDestination) -> Self { + self.body.buy_token_balance = destination; + self + } + + /// The finished body. + #[must_use] + pub const fn build(self) -> OrderBody { + self.body + } +} + #[cfg(test)] mod tests { use super::*; @@ -104,6 +284,55 @@ mod tests { } } + #[test] + fn sell_builder_matches_the_literal() { + let built = OrderBody::sell(SellToken([0x11; 20]), sample().sell_amount) + .for_at_least(BuyToken([0x22; 20]), [0xff; 32]) + .valid_to(0xffff_ffff) + .receiver([0x33; 20]) + .app_data([0x44; 32]) + .build(); + assert_eq!(built, sample()); + } + + #[test] + fn buy_builder_fixes_the_buy_side() { + let built = OrderBody::buy(BuyToken([0x22; 20]), [0xff; 32]) + .for_at_most(SellToken([0x11; 20]), [0x01; 32]) + .valid_to(100) + .partially_fillable() + .sell_token_balance(SellTokenSource::External) + .buy_token_balance(BuyTokenDestination::Internal) + .fee_amount([0x05; 32]) + .build(); + assert_eq!(built.kind, OrderKind::Buy); + assert_eq!(built.sell_token, [0x11; 20]); + assert_eq!(built.buy_token, [0x22; 20]); + assert_eq!(built.sell_amount, [0x01; 32]); + assert_eq!(built.buy_amount, [0xff; 32]); + assert_eq!(built.valid_to, 100); + assert!(built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::External); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Internal); + assert_eq!(built.fee_amount, [0x05; 32]); + assert_eq!(built.receiver, None); + } + + #[test] + fn builder_defaults_are_the_wire_defaults() { + let built = OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1) + .build(); + assert_eq!(built.receiver, None); + assert_eq!(built.app_data, [0; 32]); + assert_eq!(built.fee_amount, [0; 32]); + assert!(!built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Erc20); + assert_eq!(built.kind, OrderKind::Sell); + } + #[test] fn order_body_borsh_round_trips() { let body = sample(); diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index cb29b7b9..c867cbd3 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -36,6 +36,9 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } +# Single-source capability and fault-label vocabularies; the registry's +# core interface set is emitted from its table. +nexum-world = { path = "../nexum-world" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 47145902..fda2b575 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -674,6 +674,8 @@ mod tests { linked: Arc, } + impl crate::sealed::SealedRuntime for ExtPreset {} + impl RuntimePreset for ExtPreset { type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; @@ -740,6 +742,8 @@ mod tests { logs: LogPipeline, } + impl crate::sealed::SealedRuntime for PrebuiltLogsPreset {} + impl RuntimePreset for PrebuiltLogsPreset { type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs index f55bf2c7..c10f8e0a 100644 --- a/crates/nexum-runtime/src/host/actor.rs +++ b/crates/nexum-runtime/src/host/actor.rs @@ -60,6 +60,7 @@ impl Liveness { /// A guest call failed outside the component's typed error space. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum ActorFault { /// The pre-call refuel failed; the guest was never entered. #[error("refuel failed: {0}")] diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 60b734cf..6b4111cc 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -98,6 +98,7 @@ impl ComponentBuilder for LogPipelineBuilder { /// `anyhow::Error` because the backends fail for heterogeneous reasons /// (I/O for the store, network for the chain). #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum BuildError { /// The chain backend builder failed. #[error("build the chain backend: {0}")] diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 072ebfe8..687abf3a 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -16,6 +16,7 @@ use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, /// structural ceiling; an operator allowlist narrows within it and /// never widens it. #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] +#[non_exhaustive] pub enum ChainMethod { #[strum(serialize = "eth_blockNumber")] EthBlockNumber, diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index eaa0e70a..fd9d24e1 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -52,6 +52,8 @@ mod tests { #[derive(Clone, Copy, Default)] struct CoreTypes; + impl crate::sealed::SealedRuntimeTypes for CoreTypes {} + impl RuntimeTypes for CoreTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index 0540e4d4..33741499 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -13,7 +13,9 @@ use crate::host::component::{ChainProvider, StateStore}; /// Names the core backend seams a runtime assembly provides, plus the /// extension slot ([`Ext`](RuntimeTypes::Ext)) that carries any non-core /// backend an extension needs. -pub trait RuntimeTypes: 'static { +/// +/// Sealed: a lattice opts in by also implementing the sealing marker. +pub trait RuntimeTypes: crate::sealed::SealedRuntimeTypes + 'static { /// JSON-RPC dispatch and subscriptions. type Chain: ChainProvider + Clone + Send + Sync + 'static; /// Process-wide store vending per-module handles. diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 65f0c303..65fbe9d7 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -15,32 +15,39 @@ pub(crate) fn chain_denied(detail: impl Into) -> ChainError { } /// Stable snake_case label for a [`Fault`], used as a metric label and -/// structured-log `kind` field. Mirrors the SDK `HostFault::label` -/// vocabulary. -pub(crate) fn fault_label(fault: &Fault) -> &'static str { +/// structured-log `kind` field. Emitted from the single-source +/// `nexum_world::fault_labels` vocabulary the SDK `HostFault::label` +/// mirrors. +pub fn fault_label(fault: &Fault) -> &'static str { + use nexum_world::fault_labels as labels; match fault { - Fault::Unsupported(_) => "unsupported", - Fault::Unavailable(_) => "unavailable", - Fault::Denied(_) => "denied", - Fault::RateLimited(_) => "rate_limited", - Fault::Timeout => "timeout", - Fault::InvalidInput(_) => "invalid_input", - Fault::Internal(_) => "internal", + Fault::Unsupported(_) => labels::UNSUPPORTED, + Fault::Unavailable(_) => labels::UNAVAILABLE, + Fault::Denied(_) => labels::DENIED, + Fault::RateLimited(_) => labels::RATE_LIMITED, + Fault::Timeout => labels::TIMEOUT, + Fault::InvalidInput(_) => labels::INVALID_INPUT, + Fault::Internal(_) => labels::INTERNAL, } } /// Human-readable detail carried by a [`Fault`], for the log `message` -/// field. The payload-bearing cases carry their own detail; the two -/// payload-free cases render a fixed phrase. -pub(crate) fn fault_message(fault: &Fault) -> &str { +/// field. The bindgen `Display` is the `{0:?}` debug form, so operator +/// logs render through this instead. The payload-bearing cases carry +/// their own detail; a rate limit keeps its `retry-after-ms` hint; +/// `timeout` renders a fixed phrase. +pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { match fault { Fault::Unsupported(m) | Fault::Unavailable(m) | Fault::Denied(m) | Fault::InvalidInput(m) - | Fault::Internal(m) => m, - Fault::RateLimited(_) => "rate limited", - Fault::Timeout => "timeout", + | Fault::Internal(m) => std::borrow::Cow::Borrowed(m), + Fault::RateLimited(rl) => match rl.retry_after_ms { + Some(ms) => std::borrow::Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => std::borrow::Cow::Borrowed("rate limited"), + }, + Fault::Timeout => std::borrow::Cow::Borrowed("timeout"), } } diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index 03610763..8e2d95e4 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -285,6 +285,7 @@ impl ModuleStore { /// Errors surfaced by [`LocalStore`] and [`ModuleStore`]. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StorageError { #[error("open redb: {0}")] Open(#[source] redb::DatabaseError), diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/crates/nexum-runtime/src/host/logs/mod.rs index 74e0760f..b619bf3e 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/crates/nexum-runtime/src/host/logs/mod.rs @@ -61,6 +61,7 @@ impl RunId { /// `source` field on the host tracing event. #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum LogSource { /// The `nexum:host/logging` glue: an explicit guest `log` call. HostInterface, diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index b4cccbdd..4a29dd47 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -17,6 +17,14 @@ use alloy_rpc_client as _; use alloy_transport as _; use alloy_transport_ws as _; +/// Sealing markers for [`preset::Runtime`] and +/// [`host::component::RuntimeTypes`]: implement alongside the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedRuntimeTypes {} + pub trait SealedRuntime {} +} + pub mod addons; pub mod bindings; pub mod bootstrap; diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index dc6edeb4..3492634f 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -44,7 +44,8 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { /// moves bytes to and from its counterparty and nothing else. `http` is /// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const PROVIDER_CAPABILITIES: &[&str] = &["chain", "messaging"]; +pub const PROVIDER_CAPABILITIES: &[&str] = + &[nexum_world::caps::CHAIN, nexum_world::caps::MESSAGING]; /// The provider namespace: the same `nexum:host/` prefix as core but only /// the scoped-transport interfaces. Validating a provider manifest against @@ -62,7 +63,7 @@ const WASI_HTTP_PREFIX: &str = "wasi:http/"; /// Capability name a module declares to import any `wasi:http/*` /// interface; the per-module `[capabilities.http].allow` list scopes it. -const HTTP_CAPABILITY: &str = "http"; +const HTTP_CAPABILITY: &str = nexum_world::caps::HTTP; /// Gated WASI capability names. Declaring one grants the matching `wasi:` /// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index 470cc0bb..ed5858c7 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -51,6 +51,7 @@ pub struct CapabilityViolation { /// Error returned when a component's WIT imports exceed its declared /// capabilities. #[derive(Debug, Error)] +#[non_exhaustive] pub enum CapabilityError { /// A gated import was not declared in `[capabilities]`. #[error(transparent)] diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index f6fbc95c..1960dc61 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -11,19 +11,13 @@ use serde::Deserialize; use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker. The `http` capability is not a +/// world links into every module linker, emitted from the +/// `nexum-world` capability table. The `http` capability is not a /// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled /// separately by the registry. Domain-extension capabilities are not /// listed here; each extension contributes its own namespace to the /// [`super::capabilities::CapabilityRegistry`] at the composition root. -pub const CORE_CAPABILITIES: &[&str] = &[ - "chain", - "identity", - "local-store", - "remote-store", - "messaging", - "logging", -]; +pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; #[derive(Debug, Deserialize, Default)] pub struct Manifest { diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 9ec4dd64..56fe1629 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -30,7 +30,9 @@ use crate::host::provider_pool::ProviderPool; /// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) /// binds a value, so a preset can hand back already-built backends through a /// pass-through builder such as `Prebuilt`. -pub trait Runtime { +/// +/// Sealed: a preset opts in by also implementing the sealing marker. +pub trait Runtime: crate::sealed::SealedRuntime { /// The lattice the preset assembles. type Types: RuntimeTypes; /// Builds the chain backend ([`RuntimeTypes::Chain`]). @@ -73,6 +75,9 @@ pub trait Runtime { #[derive(Debug, Clone, Copy, Default)] pub struct CoreRuntime; +impl crate::sealed::SealedRuntimeTypes for CoreRuntime {} +impl crate::sealed::SealedRuntime for CoreRuntime {} + impl RuntimeTypes for CoreRuntime { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 3f1aa287..3b848306 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -50,6 +50,7 @@ use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; /// supervisor consumes. Library-side code keeps `anyhow::Error` out /// of long-lived stream item types per the rust idiomatic rubric. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StreamError { /// Underlying provider / transport failure while opening or /// pumping the subscription. diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index e7c49ecf..fd369eeb 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -106,6 +106,9 @@ pub struct Supervisor { #[derive(Clone, Copy, Default)] pub(crate) struct TestTypes; +#[cfg(test)] +impl crate::sealed::SealedRuntimeTypes for TestTypes {} + #[cfg(test)] impl RuntimeTypes for TestTypes { type Chain = ProviderPool; @@ -738,7 +741,7 @@ impl Supervisor { warn!( module = %module_namespace, kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + message = %crate::host::error::fault_message(&e), "init failed - module loaded but marked dead; dispatcher will skip it", ); false @@ -1483,7 +1486,7 @@ impl Supervisor { block_number, latency_ms, kind, - message = crate::host::error::fault_message(&fault), + message = %crate::host::error::fault_message(&fault), "on-event returned fault", ); metrics::counter!( diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/crates/nexum-runtime/src/test_utils/types.rs index 089d197a..4180b328 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/crates/nexum-runtime/src/test_utils/types.rs @@ -15,6 +15,8 @@ use crate::test_utils::{MockChainProvider, MockStateStore}; /// it derives no traits and is zero-sized at runtime. pub struct MockTypes(PhantomData E>); +impl crate::sealed::SealedRuntimeTypes for MockTypes {} + impl RuntimeTypes for MockTypes { type Chain = MockChainProvider; type Store = MockStateStore; diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index c4dfb6b5..445c894a 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -63,6 +63,8 @@ proptest.workspace = true # The keeper never touches the orderbook, so a CoW-layer mock would only drag # the domain crates into this crate's dev graph. nexum-sdk-test = { path = "../nexum-sdk-test" } +# Pins the strum-derived fault labels to the single-source vocabulary. +nexum-world = { path = "../nexum-world" } # The wasi:http client only links on the wasm guest target; host-side # consumers (tests, backtest tooling) compile the `http` module's types diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs index 3f45013b..58ecfae6 100644 --- a/crates/nexum-sdk/src/chain/method.rs +++ b/crates/nexum-sdk/src/chain/method.rs @@ -8,6 +8,7 @@ use strum::{EnumString, IntoStaticStr}; /// WIT edge; [`HostTransport`](super::HostTransport) rejects anything /// outside this set before calling the host. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] +#[non_exhaustive] pub enum ChainMethod { /// `eth_blockNumber`. #[strum(serialize = "eth_blockNumber")] diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index 4a278cfc..fdfcfc9c 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -20,6 +20,7 @@ use thiserror::Error; /// /// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput #[derive(Debug, Error)] +#[non_exhaustive] pub enum ConfigError { /// The key was not present in the `entries` slice. #[error("missing key {key:?}")] diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 82b562d8..9fc0cb68 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -45,7 +45,7 @@ pub enum Fault { Denied(String), /// Rate-limited by an upstream service; may carry backoff guidance /// when the host knows the retry window. - #[error("rate limited")] + #[error("rate limited{}", .0.retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] RateLimited(RateLimit), /// Operation took too long. #[error("timeout")] @@ -66,13 +66,32 @@ pub struct RateLimit { pub retry_after_ms: Option, } +/// Sealing markers for [`Host`] and [`HostFault`]: implement alongside +/// the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedHost {} + pub trait SealedHostFault {} +} + +impl sealed::SealedHost for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} + +impl sealed::SealedHostFault for Fault {} +impl sealed::SealedHostFault for ChainError {} + /// Recovers the shared [`Fault`] from a richer, per-interface error. /// /// Typed interface errors that embed a fault case implement this so a /// caller can dispatch on the structured cause and pull a stable /// snake_case [`label`](HostFault::label) for logs and metrics without /// matching the outer type. -pub trait HostFault { +/// +/// Sealed: an error type opts in by also implementing the sealing +/// marker. +pub trait HostFault: sealed::SealedHostFault { /// The embedded fault, when this value represents one. fn fault(&self) -> Option<&Fault>; /// Stable snake_case label for logs and metrics. @@ -121,6 +140,7 @@ pub struct RpcError { /// [`HostFault`] recovers the embedded [`Fault`] (present only on the /// `Fault` case) and a stable snake_case label for logs and metrics. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum ChainError { /// A shared host fault. #[error(transparent)] @@ -397,8 +417,15 @@ pub fn reference_from_wire(raw: &[u8]) -> Result { /// # } /// record_block(&StubHost, 1, "block:42").unwrap(); /// ``` +/// Sealed: the blanket impl is the only implementation. pub trait Host: - ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost + sealed::SealedHost + + ChainHost + + IdentityHost + + LocalStoreHost + + RemoteStoreHost + + MessagingHost + + LoggingHost { } impl Host for T where @@ -481,15 +508,19 @@ mod tests { } #[test] - fn fault_labels_are_stable_snake_case() { + fn fault_labels_match_the_single_source_vocabulary() { + use nexum_world::fault_labels as labels; let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), "unsupported"), - (Fault::Unavailable(String::new()), "unavailable"), - (Fault::Denied(String::new()), "denied"), - (Fault::RateLimited(RateLimit::default()), "rate_limited"), - (Fault::Timeout, "timeout"), - (Fault::InvalidInput(String::new()), "invalid_input"), - (Fault::Internal(String::new()), "internal"), + (Fault::Unsupported(String::new()), labels::UNSUPPORTED), + (Fault::Unavailable(String::new()), labels::UNAVAILABLE), + (Fault::Denied(String::new()), labels::DENIED), + ( + Fault::RateLimited(RateLimit::default()), + labels::RATE_LIMITED, + ), + (Fault::Timeout, labels::TIMEOUT), + (Fault::InvalidInput(String::new()), labels::INVALID_INPUT), + (Fault::Internal(String::new()), labels::INTERNAL), ]; for (fault, label) in cases { assert_eq!(fault.label(), label); @@ -497,6 +528,18 @@ mod tests { } } + #[test] + fn rate_limit_display_carries_the_retry_hint() { + let hinted = Fault::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!(hinted.to_string(), "rate limited, retry after 250 ms"); + assert_eq!( + Fault::RateLimited(RateLimit::default()).to_string(), + "rate limited" + ); + } + #[test] fn host_fault_is_object_safe() { let boxed: Box = Box::new(Fault::Timeout); diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index 7ca0c551..e4a6dec0 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -56,6 +56,7 @@ impl Default for FetchOptions { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum FetchError { /// The host's `[capabilities.http].allow` list refused the request /// before any connection was made. diff --git a/crates/nexum-status-body/src/lib.rs b/crates/nexum-status-body/src/lib.rs index d5cc4fab..2bd9c009 100644 --- a/crates/nexum-status-body/src/lib.rs +++ b/crates/nexum-status-body/src/lib.rs @@ -97,6 +97,7 @@ pub struct EncodeError { /// Why bytes failed to decode as a status body. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum DecodeError { /// No bytes at all: not even a version tag. #[error("empty status body: missing the version tag")] diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index aae0b9db..5ca9e1ca 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -17,6 +17,54 @@ use std::path::{Path, PathBuf}; +/// Capability name consts: the single source the [`CORE`] table and the +/// runtime's capability registry emit from. +pub mod caps { + /// `nexum:host/chain`. + pub const CHAIN: &str = "chain"; + /// `nexum:host/identity`. + pub const IDENTITY: &str = "identity"; + /// `nexum:host/local-store`. + pub const LOCAL_STORE: &str = "local-store"; + /// `nexum:host/remote-store`. + pub const REMOTE_STORE: &str = "remote-store"; + /// `nexum:host/messaging`. + pub const MESSAGING: &str = "messaging"; + /// `nexum:host/logging`. + pub const LOGGING: &str = "logging"; + /// Gates `wasi:http/*`; no world import. + pub const HTTP: &str = "http"; +} + +/// Snake_case labels of the `nexum:host/types.fault` cases, in +/// declaration order: the single source every label mirror emits from. +pub mod fault_labels { + /// `fault.unsupported`. + pub const UNSUPPORTED: &str = "unsupported"; + /// `fault.unavailable`. + pub const UNAVAILABLE: &str = "unavailable"; + /// `fault.denied`. + pub const DENIED: &str = "denied"; + /// `fault.rate-limited`. + pub const RATE_LIMITED: &str = "rate_limited"; + /// `fault.timeout`. + pub const TIMEOUT: &str = "timeout"; + /// `fault.invalid-input`. + pub const INVALID_INPUT: &str = "invalid_input"; + /// `fault.internal`. + pub const INTERNAL: &str = "internal"; + /// All seven, in declaration order. + pub const ALL: [&str; 7] = [ + UNSUPPORTED, + UNAVAILABLE, + DENIED, + RATE_LIMITED, + TIMEOUT, + INVALID_INPUT, + INTERNAL, + ]; +} + /// One manifest capability and its world wiring. pub struct Capability { /// The name declared under `[capabilities].required` / `optional`. @@ -38,49 +86,79 @@ pub struct Capability { /// core registry and nothing else; extension rows are the caller's. pub const CORE: &[Capability] = &[ Capability { - name: "chain", + name: caps::CHAIN, import: Some("nexum:host/chain@0.1.0"), packages: &[], adapter: Some("chain"), }, Capability { - name: "identity", + name: caps::IDENTITY, import: Some("nexum:host/identity@0.1.0"), packages: &[], adapter: Some("identity"), }, Capability { - name: "local-store", + name: caps::LOCAL_STORE, import: Some("nexum:host/local-store@0.1.0"), packages: &[], adapter: Some("local_store"), }, Capability { - name: "remote-store", + name: caps::REMOTE_STORE, import: Some("nexum:host/remote-store@0.1.0"), packages: &[], adapter: Some("remote_store"), }, Capability { - name: "messaging", + name: caps::MESSAGING, import: Some("nexum:host/messaging@0.1.0"), packages: &[], adapter: Some("messaging"), }, Capability { - name: "logging", + name: caps::LOGGING, import: Some("nexum:host/logging@0.1.0"), packages: &[], adapter: Some("logging"), }, Capability { - name: "http", + name: caps::HTTP, import: None, packages: &[], adapter: None, }, ]; +/// Number of import-bearing [`CORE`] rows. +const fn core_iface_count() -> usize { + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + n += 1; + } + i += 1; + } + n +} + +/// Names of the import-bearing [`CORE`] rows, in emission order: the +/// `nexum:host` interface set the runtime's capability registry +/// enforces. `http` is absent (no world import). +pub const CORE_IFACES: [&str; core_iface_count()] = { + let mut out = [""; core_iface_count()]; + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + out[n] = CORE[i].name; + n += 1; + } + i += 1; + } + out +}; + /// One registered extension row: a per-namespace capability a /// composition root declares in its `extensions.toml`. An extension /// always has a WIT import and never a host-adapter ident (adapter @@ -411,6 +489,33 @@ mod tests { assert!(err.contains("extension capability `acme` collides")); } + #[test] + fn core_ifaces_are_the_import_bearing_rows() { + assert_eq!( + CORE_IFACES, + [ + caps::CHAIN, + caps::IDENTITY, + caps::LOCAL_STORE, + caps::REMOTE_STORE, + caps::MESSAGING, + caps::LOGGING, + ], + ); + assert!(!CORE_IFACES.contains(&caps::HTTP)); + } + + #[test] + fn fault_labels_are_snake_case_and_distinct() { + for label in fault_labels::ALL { + assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); + } + let mut labels = fault_labels::ALL.to_vec(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), fault_labels::ALL.len()); + } + #[test] fn core_table_carries_no_extension_row() { assert!( diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 4612f12b..99080dac 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -27,6 +27,8 @@ const SEPOLIA: u64 = 11_155_111; #[derive(Debug, Clone, Copy, Default)] struct CowTestTypes; +impl nexum_runtime::sealed::SealedRuntimeTypes for CowTestTypes {} + impl RuntimeTypes for CowTestTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index a7a4674e..fdb28adf 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -181,7 +181,9 @@ impl LegacyRevertAdapter { } _ => Verdict::TryNextBlock { reason: [0; 4] }, }, - ChainError::Fault(_) => Verdict::TryNextBlock { reason: [0; 4] }, + // `ChainError` is `#[non_exhaustive]`: transport faults and + // any future case are payload-free, so they stay retryable. + _ => Verdict::TryNextBlock { reason: [0; 4] }, } } } diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index b626ab94..3f676861 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -67,6 +67,8 @@ pub enum CowApiError { Rejected(OrderRejection), } +impl nexum_sdk::host::sealed::SealedHostFault for CowApiError {} + impl HostFault for CowApiError { fn fault(&self) -> Option<&Fault> { match self { diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 1d216924..19e00a6d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -33,8 +33,8 @@ pub use run::run; /// codec, re-exported from the `cow-venue` default slice. The shim keeps /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ - BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, OrderKind, - SellTokenSource, + BuyToken, BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, + OrderBuilder, OrderKind, SellToken, SellTokenSource, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index 9a32d9fd..f7c629cd 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -23,6 +23,8 @@ use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; #[derive(Debug, Clone, Copy, Default)] struct ReferenceTypes; +impl nexum_runtime::sealed::SealedRuntimeTypes for ReferenceTypes {} + impl RuntimeTypes for ReferenceTypes { type Chain = ProviderPool; type Store = LocalStore; @@ -34,6 +36,8 @@ impl RuntimeTypes for ReferenceTypes { #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; +impl nexum_runtime::sealed::SealedRuntime for ShepherdRuntime {} + impl Runtime for ShepherdRuntime { type Types = ReferenceTypes; type ChainBuilder = ProviderPoolBuilder; diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs index c6281016..c07618a9 100644 --- a/crates/videre-host/src/bindings.rs +++ b/crates/videre-host/src/bindings.rs @@ -77,6 +77,45 @@ pub use venue_adapter::videre::types::types::{ /// The value-flow vocabulary the header is expressed in. pub use venue_adapter::videre::value_flow::types as value_flow; +/// Operator-log rendering of the wire `venue-error`: the bindgen +/// `Display` is the `{0:?}` debug form, so logs render through this +/// instead and the rate-limit `retry-after-ms` hint survives. +pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; + match err { + VenueError::UnknownVenue => Cow::Borrowed("unknown venue"), + VenueError::InvalidBody(detail) => Cow::Owned(format!("invalid body: {detail}")), + VenueError::Unsupported => Cow::Borrowed("unsupported"), + VenueError::Denied(detail) => Cow::Owned(format!("denied: {detail}")), + VenueError::RateLimited(rate_limit) => match rate_limit.retry_after_ms { + Some(ms) => Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => Cow::Borrowed("rate limited"), + }, + VenueError::Unavailable(detail) => Cow::Owned(format!("unavailable: {detail}")), + VenueError::Timeout => Cow::Borrowed("timeout"), + } +} + +#[cfg(test)] +mod display_smoke { + use super::{RateLimit, VenueError, venue_error_message}; + + #[test] + fn venue_error_message_keeps_the_rate_limit_hint() { + let hinted = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!( + venue_error_message(&hinted), + "rate limited, retry after 250 ms" + ); + let unhinted = VenueError::RateLimited(RateLimit { + retry_after_ms: None, + }); + assert_eq!(venue_error_message(&unhinted), "rate limited"); + } +} + /// Bindgen smoke for the `videre:value-flow` types package, compiled under /// test through a throwaway world that imports the interface. Its value is /// the identifier-hygiene gate: the test names every generated type, diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index 84da2630..6a47fdf7 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -567,7 +567,7 @@ impl VenueRegistry { Err(err) => { warn!( venue = %venue, - error = ?err, + error = %crate::bindings::venue_error_message(&err), "status poll failed - retrying on the next cadence", ); } @@ -722,7 +722,8 @@ impl ProviderKind for VenueAdapterKind { Err(e) => { warn!( adapter = %venue_id, - fault = ?e, + kind = nexum_runtime::host::error::fault_label(&e), + fault = %nexum_runtime::host::error::fault_message(&e), "adapter init failed - loaded but marked dead", ); return Ok(Installed::Dead); diff --git a/crates/videre-sdk/src/body.rs b/crates/videre-sdk/src/body.rs index eb3a64dd..33c40378 100644 --- a/crates/videre-sdk/src/body.rs +++ b/crates/videre-sdk/src/body.rs @@ -38,6 +38,7 @@ pub trait IntentBody: Sized + __private::Derived { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum BodyError { /// No bytes at all: not even a version tag. #[error("empty body: missing the version tag")] diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index baa40118..c3e9b1af 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -75,11 +75,20 @@ pub trait Venue { type Body: IntentBody; } +/// Sealing marker for [`VenueTransport`]: a transport opts in by also +/// implementing it. +#[doc(hidden)] +pub mod sealed { + pub trait SealedTransport {} +} + /// The byte-level seam under the typed client: `videre:venue/client` /// with the venue named per call. Native AFIT, so a [`VenueClient`] /// over any transport dispatches statically. [`HostVenues`] binds it to /// the module's own import; tests implement it in memory. -pub trait VenueTransport { +/// +/// Sealed: a transport opts in by also implementing the sealing marker. +pub trait VenueTransport: sealed::SealedTransport { /// Price an opaque intent body at the named venue. fn quote( &self, @@ -117,6 +126,8 @@ pub trait VenueTransport { #[derive(Clone, Copy, Debug, Default)] pub struct HostVenues; +impl sealed::SealedTransport for HostVenues {} + impl VenueTransport for HostVenues { async fn quote(&self, venue: &VenueId, body: Vec) -> Result { shims::quote(venue.as_str(), &body).map_err(VenueFault::from) diff --git a/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs index 00d6840c..ac57203f 100644 --- a/crates/videre-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -163,9 +163,9 @@ impl From for VenueError { match err { FetchError::Denied => VenueError::Denied(err.to_string()), FetchError::Timeout(_) => VenueError::Timeout, - FetchError::Transport(_) | FetchError::InvalidRequest(_) => { - VenueError::Unavailable(err.to_string()) - } + // `FetchError` is `#[non_exhaustive]`: a future transport + // case folds to retryable `unavailable` with its detail. + _ => VenueError::Unavailable(err.to_string()), } } } diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 3240faed..f94a88f8 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -237,6 +237,8 @@ mod tests { } } + impl crate::client::sealed::SealedTransport for &StubVenue {} + impl VenueTransport for &StubVenue { async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { unreachable!("quote not exercised") diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index 9c0814f9..0c490331 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -141,6 +141,8 @@ impl Venue for NowhereVenue { /// binds. struct InProcessClient; +impl videre_sdk::client::sealed::SealedTransport for InProcessClient {} + impl VenueTransport for InProcessClient { async fn quote(&self, venue: &VenueId, body: Vec) -> Result { if venue.as_str() != "demo" { diff --git a/crates/videre-test/src/fixture.rs b/crates/videre-test/src/fixture.rs index 624ec1c9..6b2b5118 100644 --- a/crates/videre-test/src/fixture.rs +++ b/crates/videre-test/src/fixture.rs @@ -54,6 +54,7 @@ where /// serde's rendered detail rather than the error value so the type /// stays independent of `serde_json`'s feature set. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum FixtureError { /// The file could not be read. #[error("failed to read {path}: {source}")] diff --git a/modules/examples/http-probe/src/strategy.rs b/modules/examples/http-probe/src/strategy.rs index 09eb6980..3d3d80b7 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/modules/examples/http-probe/src/strategy.rs @@ -86,7 +86,9 @@ fn fetch_err(url: &str, error: &FetchError) -> Fault { FetchError::Denied => Fault::Denied(detail), FetchError::InvalidRequest(_) => Fault::InvalidInput(detail), FetchError::Timeout(_) => Fault::Timeout, - FetchError::Transport(_) => Fault::Unavailable(detail), + // `FetchError` is `#[non_exhaustive]`: a future case folds to + // retryable `unavailable` with its detail. + _ => Fault::Unavailable(detail), } } From 72b7b37dbf9752532570d5dd2f959753ec1dffc9 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 11:16:50 +0000 Subject: [PATCH 39/53] cow: cleave the cow venue from the composable-cow keeper The venue crate is the orderbook alone. ComposableBody and the structured poll seam (Verdict, LegacyRevertAdapter, IConditionalOrder) move to the new composable-cow keeper crate, the Composable variant drops from the venue body, the goldens shed the composable vectors, and a blocking CI gate keeps composable symbols out of crates/cow-venue. --- .github/workflows/ci.yml | 12 +++++++ Cargo.lock | 16 ++++++++- Cargo.toml | 1 + crates/composable-cow/Cargo.toml | 24 ++++++++++++++ .../src/body.rs} | 25 +++++++------- crates/composable-cow/src/lib.rs | 15 +++++++++ .../src/poll.rs} | 14 ++++++++ crates/cow-venue/Cargo.toml | 2 +- crates/cow-venue/src/body.rs | 33 +++++-------------- crates/cow-venue/src/lib.rs | 14 +++----- crates/shepherd-sdk-test/Cargo.toml | 1 + crates/shepherd-sdk-test/tests/mock_venue.rs | 3 +- crates/shepherd-sdk/Cargo.toml | 5 +-- crates/shepherd-sdk/src/cow/mod.rs | 18 +++++----- crates/shepherd-sdk/src/cow/run.rs | 3 +- crates/shepherd-sdk/src/lib.rs | 14 ++++---- crates/shepherd-sdk/src/proptests.rs | 17 ++-------- crates/shepherd-sdk/tests/run.rs | 3 +- justfile | 5 +++ modules/twap-monitor/Cargo.toml | 1 + modules/twap-monitor/src/strategy.rs | 5 +-- scripts/check-cow-orderbook-only.sh | 29 ++++++++++++++++ 22 files changed, 171 insertions(+), 89 deletions(-) create mode 100644 crates/composable-cow/Cargo.toml rename crates/{cow-venue/src/composable.rs => composable-cow/src/body.rs} (72%) create mode 100644 crates/composable-cow/src/lib.rs rename crates/{shepherd-sdk/src/cow/composable.rs => composable-cow/src/poll.rs} (96%) create mode 100755 scripts/check-cow-orderbook-only.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2ca1d19..74809b37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,3 +158,15 @@ jobs: with: tool: wasm-tools,ripgrep - run: ./scripts/check-venue-agnostic.sh + + # Blocking orderbook-only gate: the CoW venue crate carries no + # composable symbol (scripts/check-cow-orderbook-only.sh). + cow-orderbook-only: + name: cow-orderbook-only + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: ripgrep + - run: ./scripts/check-cow-orderbook-only.sh diff --git a/Cargo.lock b/Cargo.lock index 9759d512..69e6ec52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,6 +1483,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "composable-cow" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "borsh", + "cowprotocol", + "nexum-sdk", + "proptest", +] + [[package]] name = "const-hex" version = "1.19.1" @@ -5218,7 +5230,7 @@ name = "shepherd-sdk" version = "0.1.0" dependencies = [ "alloy-primitives", - "alloy-sol-types", + "composable-cow", "cow-venue", "cowprotocol", "nexum-sdk", @@ -5236,6 +5248,7 @@ name = "shepherd-sdk-test" version = "0.1.0" dependencies = [ "alloy-primitives", + "composable-cow", "cowprotocol", "nexum-sdk", "nexum-sdk-test", @@ -5907,6 +5920,7 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "composable-cow", "cowprotocol", "nexum-sdk", "nexum-sdk-test", diff --git a/Cargo.toml b/Cargo.toml index 94377aa5..4de3f985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/composable-cow", "crates/cow-venue", "crates/nexum-cli", "crates/nexum-launch", diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml new file mode 100644 index 00000000..ceb66cc6 --- /dev/null +++ b/crates/composable-cow/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "composable-cow" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "ComposableCoW keeper machinery: the conditional-order body and the structured poll Verdict, with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter." + +[lib] +# Plain library, keeper-side only. The CoW venue crate is orderbook-only +# and never links this. + +[lints] +workspace = true + +[dependencies] +alloy-primitives.workspace = true +alloy-sol-types.workspace = true +borsh.workspace = true +cowprotocol = { version = "0.2.0", default-features = false } +nexum-sdk = { path = "../nexum-sdk" } + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/cow-venue/src/composable.rs b/crates/composable-cow/src/body.rs similarity index 72% rename from crates/cow-venue/src/composable.rs rename to crates/composable-cow/src/body.rs index 5e7a94a4..b5f6bf02 100644 --- a/crates/cow-venue/src/composable.rs +++ b/crates/composable-cow/src/body.rs @@ -1,28 +1,24 @@ -//! The venue-neutral composable (conditional) order body. +//! The composable (conditional) order body. //! //! ComposableCoW expresses a conditional order as the //! `ConditionalOrderParams` tuple: the handler contract that mints the //! tradeable order, a salt that distinguishes otherwise-identical -//! conditional orders, and the opaque handler-specific static input. This -//! body type is that tuple in wire form. The one non-obvious invariant: -//! `static_input` is opaque to the venue; only the named handler parses +//! conditional orders, and the opaque handler-specific static input. +//! This body type is that tuple in wire form. The one non-obvious +//! invariant: `static_input` is opaque; only the named handler parses //! it, so this crate never inspects its bytes. -use alloc::vec::Vec; - use borsh::{BorshDeserialize, BorshSerialize}; -use crate::order::Address; - -/// The venue-neutral conditional order body: `ConditionalOrderParams` in -/// wire form. +/// The conditional order body: `ConditionalOrderParams` in wire form. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub struct ComposableBody { /// The `IConditionalOrder` handler that mints the tradeable order. - pub handler: Address, + pub handler: [u8; 20], /// Salt distinguishing otherwise-identical conditional orders. pub salt: [u8; 32], - /// Handler-specific static input; opaque to the venue. + /// Handler-specific static input; opaque to everything but the + /// named handler. pub static_input: Vec, } @@ -53,6 +49,9 @@ mod tests { let mut body = sample(); body.static_input = Vec::new(); let bytes = borsh::to_vec(&body).expect("encode"); - assert_eq!(ComposableBody::try_from_slice(&bytes).unwrap(), body); + assert_eq!( + ComposableBody::try_from_slice(&bytes).expect("decode"), + body + ); } } diff --git a/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs new file mode 100644 index 00000000..505fa115 --- /dev/null +++ b/crates/composable-cow/src/lib.rs @@ -0,0 +1,15 @@ +//! # composable-cow +//! +//! ComposableCoW keeper machinery, kept out of the CoW venue: the +//! conditional-order body ([`ComposableBody`]) and the structured poll +//! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined +//! behind [`LegacyRevertAdapter`]. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod body; +pub mod poll; + +pub use body::ComposableBody; +pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/composable-cow/src/poll.rs similarity index 96% rename from crates/shepherd-sdk/src/cow/composable.rs rename to crates/composable-cow/src/poll.rs index fdb28adf..590549cc 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/composable-cow/src/poll.rs @@ -355,4 +355,18 @@ mod tests { Verdict::TryNextBlock { .. } )); } + + use proptest::prelude::*; + + proptest! { + /// `decode` on arbitrary revert bytes never panics and returns + /// `None` for inputs shorter than the 4-byte EVM selector. + #[test] + fn decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { + let outcome = LegacyRevertAdapter::decode(&bytes); + if bytes.len() < 4 { + prop_assert!(outcome.is_none()); + } + } + } } diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index eb061887..8e303765 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "CoW venue slices. The default `body` slice carries the venue-neutral order/composable intent body types and their borsh IntentBody codec, linkable by adapters and modules." +description = "CoW venue slices, orderbook-only. The default `body` slice carries the venue-neutral order intent body types and their borsh IntentBody codec, linkable by adapters and modules." [lib] # Plain library. The default `body` slice is dependency-light so a venue diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index e7e40fe4..feb7d3c8 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -1,10 +1,10 @@ //! The CoW intent body and its versioned `IntentBody` codec. //! -//! A CoW intent is either a direct order or a composable (conditional) -//! order; [`CowIntent`] is that sum. [`CowIntentBody`] is the outer -//! per-venue version enum the venue publishes, and `#[derive(IntentBody)]` -//! gives it the borsh codec: a one-byte version tag plus the borsh -//! payload, with an unknown tag failing as a typed +//! A CoW intent is a direct order for the orderbook; [`CowIntent`] is +//! that sum, open for future intent kinds. [`CowIntentBody`] is the +//! outer per-venue version enum the venue publishes, and +//! `#[derive(IntentBody)]` gives it the borsh codec: a one-byte version +//! tag plus the borsh payload, with an unknown tag failing as a typed //! [`BodyError`](videre_sdk::BodyError) rather than a stringly borsh //! error. The one non-obvious invariant: the tag order is the schema, so //! new versions append at the end and no variant is ever reordered or @@ -13,16 +13,13 @@ use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::IntentBody; -use crate::composable::ComposableBody; use crate::order::OrderBody; -/// What the CoW venue accepts: a direct order or a conditional order. +/// What the CoW venue accepts: a direct order for the orderbook. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub enum CowIntent { /// A direct `GPv2Order` to place on the orderbook. Order(OrderBody), - /// A ComposableCoW conditional order that mints tradeable orders. - Composable(ComposableBody), } /// The outer per-venue version enum: the schema the CoW venue publishes. @@ -49,15 +46,7 @@ mod tests { .build() } - fn composable_body() -> ComposableBody { - ComposableBody { - handler: [0xab; 20], - salt: [0xcd; 32], - static_input: vec![9, 8, 7], - } - } - - /// The codec conformance set: both v1 intents as round-trip vectors + /// The codec conformance set: the v1 intent as a round-trip vector /// plus the typed failure contract, in the kit's published form. fn vectors() -> CodecVectors { let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); @@ -67,12 +56,6 @@ mod tests { &CowIntentBody::V1(CowIntent::Order(order_body())), ) .expect("order body encodes"); - vectors - .push_round_trip( - "v1-composable", - &CowIntentBody::V1(CowIntent::Composable(composable_body())), - ) - .expect("composable body encodes"); let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); let mut unknown = bytes(CowIntent::Order(order_body())); @@ -90,7 +73,7 @@ mod tests { truncated, Expectation::Malformed { version: 0 }, ); - let mut trailing = bytes(CowIntent::Composable(composable_body())); + let mut trailing = bytes(CowIntent::Order(order_body())); trailing.push(0); vectors.push_failure( "trailing-bytes", diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 440fa77b..fc4392b5 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -1,9 +1,10 @@ //! # cow-venue //! -//! The CoW venue, staged as a crate of feature slices. Today only the -//! default [`body`] slice exists: the venue-neutral order and composable -//! intent body types and the borsh `IntentBody` codec over them. The -//! typed client and the adapter component are later slices. +//! The CoW venue, staged as a crate of feature slices: the orderbook +//! and nothing else. The default [`body`] slice carries the +//! venue-neutral order intent body types and the borsh `IntentBody` +//! codec over them; conditional-order keeper machinery lives in its +//! own crate and never here. //! //! The body slice is dependency-light on purpose. It links only the //! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) @@ -35,9 +36,6 @@ extern crate alloc; #[cfg(feature = "body")] pub mod body; -#[cfg(feature = "body")] -pub mod composable; - #[cfg(feature = "body")] pub mod order; @@ -57,8 +55,6 @@ pub mod client; #[cfg(feature = "body")] pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] -pub use composable::ComposableBody; -#[cfg(feature = "body")] pub use order::{ BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource, }; diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml index 83a3525c..ea2a4a47 100644 --- a/crates/shepherd-sdk-test/Cargo.toml +++ b/crates/shepherd-sdk-test/Cargo.toml @@ -20,4 +20,5 @@ serde_json = { workspace = true, features = ["std"] } # Order construction for the MockVenue acceptance tests that drive # the keeper run end to end. alloy-primitives.workspace = true +composable-cow = { path = "../composable-cow" } cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index e9b3bef0..6867ecdd 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -4,10 +4,11 @@ //! strategy code polling the venue directly. use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, order_uid_hex, run}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index c4ef8455..5fe969d0 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, revert decoding, and prelude on top of cowprotocol types." +description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, and prelude on top of cowprotocol types." [lib] # Plain library - modules link this and emit their own cdylib for the @@ -18,10 +18,11 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or # the table-driven retry classification the cow error surface delegates # to. cow-venue = { path = "../cow-venue", features = ["client"] } +# The structured poll seam the keeper run dispatches on. +composable-cow = { path = "../composable-cow" } nexum-sdk = { path = "../nexum-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true -alloy-sol-types.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 19e00a6d..620e379d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -1,14 +1,14 @@ //! CoW Protocol bridging. //! //! Type conversions and ABI decoding helpers that translate between -//! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, -//! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `Verdict`, `RetryAction`), plus [`run()`] - the +//! the on-chain shape (`GPv2OrderData`, orderbook JSON) and the typed +//! Rust surface (`OrderData`, `RetryAction`), plus [`run()`] - the //! poll/submit composition over the keeper stores. //! -//! The poll seam is the structured [`Verdict`]; the deployed -//! ComposableCoW 1.x reverting wire is decoded behind the quarantined -//! [`LegacyRevertAdapter`]. +//! The poll seam is the structured +//! [`Verdict`](composable_cow::Verdict), carried by the +//! `composable-cow` keeper crate together with the quarantined revert +//! decoding; only orderbook concerns live here. //! //! The codec submodules stay purely host-neutral: helpers take //! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can @@ -16,12 +16,10 @@ //! unchanged by TWAP, EthFlow, and future strategy modules. The //! keeper run is generic over the host traits alone. -pub mod composable; pub mod error; pub mod order; pub mod run; -pub use composable::{IConditionalOrder, LegacyRevertAdapter, Verdict}; pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, @@ -33,8 +31,8 @@ pub use run::run; /// codec, re-exported from the `cow-venue` default slice. The shim keeps /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ - BuyToken, BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, - OrderBuilder, OrderKind, SellToken, SellTokenSource, + BuyToken, BuyTokenDestination, CowIntent, CowIntentBody, OrderBody, OrderBuilder, OrderKind, + SellToken, SellTokenSource, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index dbabd179..910090e1 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -17,6 +17,7 @@ //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes}; +use composable_cow::Verdict; use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; use nexum_sdk::host::Fault; use nexum_sdk::keeper::{ @@ -24,7 +25,7 @@ use nexum_sdk::keeper::{ }; use super::{ - CowApiError, CowHost, Verdict, classify_submit_error, gpv2_to_order_data, is_already_submitted, + CowApiError, CowHost, classify_submit_error, gpv2_to_order_data, is_already_submitted, order_uid_hex, }; diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 491e7752..40798f85 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -16,12 +16,11 @@ //! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! the structured poll seam ([`Verdict`]) with the deployed 1.x -//! revert decoding quarantined behind [`LegacyRevertAdapter`], the -//! classifiers mapping submit failures into -//! the keeper [`RetryAction`], and [`run`] - the poll -> -//! outcome -> gate/journal/submit composition over the keeper -//! stores. +//! the classifiers mapping submit failures into the keeper +//! [`RetryAction`], and [`run`] - the poll -> outcome -> +//! gate/journal/submit composition over the keeper stores, +//! dispatching the structured [`Verdict`] from the `composable-cow` +//! keeper crate. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -50,8 +49,7 @@ //! [`CowHost`]: cow::CowHost //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`Verdict`]: cow::Verdict -//! [`LegacyRevertAdapter`]: cow::LegacyRevertAdapter +//! [`Verdict`]: composable_cow::Verdict //! [`RetryAction`]: cow::RetryAction //! [`run`]: cow::run() diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index 6d5c1453..a9b4d5a2 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -4,29 +4,16 @@ //! //! Covered here: //! -//! - `LegacyRevertAdapter::decode` selector dispatch (no-panic guard). //! - `gpv2_to_order_data` marker mapping (no-panic guard). //! //! The generic properties (`eth_call` round-trip, `scale_decimal`) -//! live in `nexum-sdk`. +//! live in `nexum-sdk`; the revert-decode guard lives in +//! `composable-cow`. #![cfg(test)] use proptest::prelude::*; -proptest! { - /// `LegacyRevertAdapter::decode` on arbitrary revert bytes must - /// never panic and must return `None` for inputs shorter than the - /// 4-byte EVM selector. - #[test] - fn legacy_revert_decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { - let outcome = crate::cow::LegacyRevertAdapter::decode(&bytes); - if bytes.len() < 4 { - prop_assert!(outcome.is_none()); - } - } -} - proptest! { /// `gpv2_to_order_data` is exhaustive over the marker enum; /// fuzzing the inputs as raw u8 (not the typed enum) is the only diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index cda235ba..76677e0a 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -7,11 +7,12 @@ use std::cell::Cell; use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{CowApiError, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, OrderRejection, order_uid_hex, run}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; diff --git a/justfile b/justfile index 696d781f..9c89a2f9 100644 --- a/justfile +++ b/justfile @@ -78,6 +78,11 @@ run-e2e: build-e2e build-engine check-venue-agnostic: ./scripts/check-venue-agnostic.sh +# Orderbook-only gate: the CoW venue crate carries no composable +# symbol. Blocking in CI. +check-cow-orderbook-only: + ./scripts/check-cow-orderbook-only.sh + # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 91a37a90..5533d59a 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] +composable-cow = { path = "../../crates/composable-cow" } nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } cowprotocol = { version = "0.2.0", default-features = false } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index dd6da48c..0223f5b8 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -16,6 +16,7 @@ use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use composable_cow::{LegacyRevertAdapter, Verdict}; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; @@ -23,7 +24,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, LegacyRevertAdapter, Verdict, run}; +use shepherd_sdk::cow::{CowHost, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -735,8 +736,8 @@ mod tests { // wire shape the chain backend forwards: a `ChainError::Rpc` // carrying the already-decoded `OrderNotValid` revert bytes. use alloy_sol_types::SolError; + use composable_cow::IConditionalOrder; use nexum_sdk::host::RpcError; - use shepherd_sdk::cow::IConditionalOrder; let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); diff --git a/scripts/check-cow-orderbook-only.sh b/scripts/check-cow-orderbook-only.sh new file mode 100755 index 00000000..074d85bf --- /dev/null +++ b/scripts/check-cow-orderbook-only.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Orderbook-only check for the CoW venue crate: crates/cow-venue carries +# no composable symbol (Composable*, getTradeableOrder*, the +# IConditionalOrder revert selectors, LegacyRevertAdapter) and no +# dependency edge to the composable-cow keeper crate - the Cargo.toml +# scan covers the edge, since the dep line names the crate. Blocking in +# CI; run locally via `just check-cow-orderbook-only`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[cow PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[cow FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +symbols='composable|getTradeableOrder|IConditionalOrder|LegacyRevertAdapter|OrderNotValid|PollTryNextBlock|PollTryAtBlock|PollTryAtEpoch|PollNever' +rg -in --no-heading -e "$symbols" crates/cow-venue +case $? in + 0) fail "composable symbols leak into crates/cow-venue" ;; + 1) pass "cow-venue symbol scan empty" ;; + *) fail "symbol scan errored (crates/cow-venue missing?)" ;; +esac + +exit "$status" From 6b739d746f66cff161b6e4996d3ffdfd5e6c5f9c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 11:15:41 +0000 Subject: [PATCH 40/53] wit: own the cow event ABIs at the bundle layer Pin ConditionalOrderCreated and EthFlow OrderPlacement in a new shepherd:cow/cow-events package of record; keepers resolve topic-0s from the mirrored shepherd-sdk constants, parity-tested against the WIT, the sol! decoders and each module.toml. Mark the legacy cow-api extension surface retiring and gate the generic WIT packages against any shepherd:cow reference. --- Cargo.lock | 1 + crates/shepherd-sdk/Cargo.toml | 1 + crates/shepherd-sdk/src/cow/events.rs | 111 ++++++++++++++++++++++++ crates/shepherd-sdk/src/cow/mod.rs | 1 + docs/deployment/multi-chain.md | 3 +- modules/ethflow-watcher/module.toml | 4 +- modules/ethflow-watcher/src/strategy.rs | 27 +++--- modules/twap-monitor/module.toml | 7 +- modules/twap-monitor/src/strategy.rs | 24 +++-- wit/shepherd-cow/cow-api.wit | 2 + wit/shepherd-cow/cow-events.wit | 20 +++++ wit/shepherd-cow/cow-ext.wit | 1 + 12 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 crates/shepherd-sdk/src/cow/events.rs create mode 100644 wit/shepherd-cow/cow-events.wit diff --git a/Cargo.lock b/Cargo.lock index 69e6ec52..a53d316d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5230,6 +5230,7 @@ name = "shepherd-sdk" version = "0.1.0" dependencies = [ "alloy-primitives", + "alloy-sol-types", "composable-cow", "cow-venue", "cowprotocol", diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index 5fe969d0..a6cc1049 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -32,6 +32,7 @@ tracing.workspace = true # `capture_tracing` observes the keeper run's diagnostics in the # acceptance tests. nexum-sdk-test = { path = "../nexum-sdk-test" } +alloy-sol-types.workspace = true proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, # and the keeper run acceptance-tests against the composed MockHost diff --git a/crates/shepherd-sdk/src/cow/events.rs b/crates/shepherd-sdk/src/cow/events.rs new file mode 100644 index 00000000..20acdd42 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/events.rs @@ -0,0 +1,111 @@ +//! CoW on-chain event ABIs, mirroring `shepherd:cow/cow-events`. +//! +//! `wit/shepherd-cow/cow-events.wit` is the package of record; the +//! constants here are parity-tested against it, the `cowprotocol` +//! `sol!` types, and each keeper's `module.toml`. + +use alloy_primitives::{B256, b256}; + +/// One on-chain event surface: the canonical Solidity signature and +/// its keccak256 topic-0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EventAbi { + /// Canonical Solidity event signature. + pub signature: &'static str, + /// keccak256 of [`Self::signature`]: the log's topic-0. + pub topic0: B256, +} + +/// `ComposableCoW.ConditionalOrderCreated`. +pub const CONDITIONAL_ORDER_CREATED: EventAbi = EventAbi { + signature: "ConditionalOrderCreated(address,(address,bytes32,bytes))", + topic0: b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), +}; + +/// `CoWSwapOnchainOrders.OrderPlacement` (EthFlow). +pub const ORDER_PLACEMENT: EventAbi = EventAbi { + signature: "OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,\ + uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)", + topic0: b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), +}; + +/// Every event surface the keepers decode. +pub const ALL: &[EventAbi] = &[CONDITIONAL_ORDER_CREATED, ORDER_PLACEMENT]; + +#[cfg(test)] +mod tests { + use alloy_primitives::keccak256; + use alloy_sol_types::SolEvent; + use cowprotocol::{CoWSwapOnchainOrders, ComposableCoW}; + + use super::*; + + #[test] + fn topic0_is_keccak_of_signature() { + for abi in ALL { + assert_eq!(abi.topic0, keccak256(abi.signature), "{}", abi.signature); + } + } + + #[test] + fn matches_the_sol_decoder_types() { + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE, + CONDITIONAL_ORDER_CREATED.signature, + ); + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE_HASH, + CONDITIONAL_ORDER_CREATED.topic0, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE, + ORDER_PLACEMENT.signature, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE_HASH, + ORDER_PLACEMENT.topic0, + ); + } + + #[test] + fn wit_package_of_record_pins_every_surface() { + let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); + let flat: String = wit + .lines() + .map(|l| l.trim().trim_start_matches("/// ")) + .collect(); + for abi in ALL { + assert!( + flat.contains(abi.signature), + "cow-events.wit must pin the signature {}", + abi.signature, + ); + assert!( + flat.contains(&format!("{:#x}", abi.topic0)), + "cow-events.wit must pin the topic-0 {:#x}", + abi.topic0, + ); + } + } + + /// Layering gate: no generic WIT package references `shepherd:cow`. + #[test] + fn generic_wit_packages_never_reference_shepherd_cow() { + let wit_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); + for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { + let pkg = pkg.expect("wit dir entry").path(); + if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { + continue; + } + for file in std::fs::read_dir(&pkg).expect("wit package dir") { + let path = file.expect("wit package entry").path(); + let text = std::fs::read_to_string(&path).expect("read wit file"); + assert!( + !text.contains("shepherd:cow"), + "{} references shepherd:cow", + path.display(), + ); + } + } + } +} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 620e379d..84a97643 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -17,6 +17,7 @@ //! keeper run is generic over the host traits alone. pub mod error; +pub mod events; pub mod order; pub mod run; diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index ed627e11..5ed09852 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -213,7 +213,8 @@ event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362b ## Event topic reference These are keccak256 hashes of the event signatures. They are the same on every -chain; only the contract `address` changes for EthFlow. +chain; only the contract `address` changes for EthFlow. Package of record: +`wit/shepherd-cow/cow-events.wit`. | Event | Topic-0 | |-------|---------| diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index 45b6519a..47cc57c0 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -26,7 +26,9 @@ allow = [] # CoWSwapEthFlow.OrderPlacement on Sepolia. topic-0 = keccak256( # "OrderPlacement(address,(address,address,address,uint256,uint256,uint32, -# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"). +# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in +# strategy.rs. # `address` is the Sepolia ETH_FLOW_PRODUCTION deployment from # `cowprotocol/ethflowcontract/networks.prod.json`. Unlike # ComposableCoW's CREATE2 address, EthFlow has had multiple per-network diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 99e25ba1..4d24db00 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -42,7 +42,7 @@ use cowprotocol::{ use nexum_sdk::events::Log; use nexum_sdk::host::Fault; use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, gpv2_to_order_data}; +use shepherd_sdk::cow::{CowApiError, CowHost, events, gpv2_to_order_data}; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays @@ -89,13 +89,16 @@ pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Resul /// `ETH_FLOW_STAGING` (defensive - the host's `[[subscription]]` /// filter already pins the address, but a misconfigured engine could /// still leak through); -/// - topic0 does not match the event signature; or +/// - topic-0 does not match the `shepherd:cow/cow-events` pin; or /// - the ABI body fails to decode. pub(crate) fn decode_order_placement(log: &Log) -> Option { let contract = log.address(); if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { return None; } + if log.topics().first() != Some(&events::ORDER_PLACEMENT.topic0) { + return None; + } let decoded = OrderPlacement::decode_log(&log.inner).ok()?; Some(DecodedPlacement { contract, @@ -178,7 +181,7 @@ fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, b256, hex}; + use alloy_primitives::{U256, address, hex}; use alloy_sol_types::SolValue; use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; use nexum_sdk::Level; @@ -495,29 +498,29 @@ mod tests { ); } - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `OrderPlacement` signature. - /// A typo or ABI drift would silently miss every EthFlow event. + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every EthFlow event. #[test] fn topic0_matches_order_placement_canonical_signature() { assert_eq!( OrderPlacement::SIGNATURE_HASH, - b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", + events::ORDER_PLACEMENT.topic0, + "sol! topic-0 must match the shepherd:cow/cow-events pin", ); } /// Stronger guard than the constant check above: read the shipped /// `module.toml` and assert its pinned `event_signature` actually - /// equals `OrderPlacement::SIGNATURE_HASH` - catches a manifest/code - /// drift the ABI-hash assertion cannot see. (Ported from #164.) + /// equals the package-of-record topic-0 - catches a manifest/code + /// drift the decoder assertion cannot see. #[test] fn manifest_topic0_matches_order_placement_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", OrderPlacement::SIGNATURE_HASH); + let expected = format!("{:#x}", events::ORDER_PLACEMENT.topic0); assert!( manifest.contains(&expected), - "module.toml event_signature must equal OrderPlacement::SIGNATURE_HASH ({expected})", + "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", ); } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index e49b8dec..3ef1883d 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -26,9 +26,10 @@ allow = [] # --- subscriptions ------------------------------------------------------ # ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = -# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"). -# Both `address` and `event_signature` are pinned so the supervisor -# does not deliver unrelated logs to the module. +# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in +# strategy.rs. Both `address` and `event_signature` are pinned so the +# supervisor does not deliver unrelated logs to the module. [[subscription]] kind = "chain-log" chain_id = 11155111 diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 0223f5b8..a9bcee04 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -24,7 +24,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, run}; +use shepherd_sdk::cow::{CowHost, events, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -84,7 +84,12 @@ pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { // ---- indexing path ---- +/// Topic-0 resolves from the `shepherd:cow/cow-events` package of +/// record before the ABI decode. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { + if log.topics().first() != Some(&events::CONDITIONAL_ORDER_CREATED.topic0) { + return None; + } let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; Some((decoded.data.owner, decoded.data.params)) } @@ -800,28 +805,29 @@ mod tests { }); } - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `ConditionalOrderCreated` signature. - /// A typo or ABI drift would silently miss every registration event. + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every registration event. #[test] fn topic0_matches_conditional_order_created_canonical_signature() { assert_eq!( ConditionalOrderCreated::SIGNATURE_HASH, - b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", + events::CONDITIONAL_ORDER_CREATED.topic0, + "sol! topic-0 must match the shepherd:cow/cow-events pin", ); } /// Stronger guard than the constant check above: read the shipped /// `module.toml` and assert its pinned `event_signature` actually - /// equals `ConditionalOrderCreated::SIGNATURE_HASH`. (Ported from #164.) + /// equals the package-of-record topic-0 - catches a manifest/code + /// drift the decoder assertion cannot see. #[test] fn manifest_topic0_matches_conditional_order_created_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", ConditionalOrderCreated::SIGNATURE_HASH); + let expected = format!("{:#x}", events::CONDITIONAL_ORDER_CREATED.topic0); assert!( manifest.contains(&expected), - "module.toml event_signature must equal ConditionalOrderCreated::SIGNATURE_HASH ({expected})", + "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", ); } } diff --git a/wit/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit index 4d0df3ae..0dbaa940 100644 --- a/wit/shepherd-cow/cow-api.wit +++ b/wit/shepherd-cow/cow-api.wit @@ -1,5 +1,7 @@ package shepherd:cow@0.1.0; +/// Legacy host-extension surface: retiring. Submission moves to the +/// generic videre pool seam; deleted at the fork-gated poll wire-swap. interface cow-api { use nexum:host/types@0.1.0.{chain-id, fault}; diff --git a/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit new file mode 100644 index 00000000..b5f54bc1 --- /dev/null +++ b/wit/shepherd-cow/cow-events.wit @@ -0,0 +1,20 @@ +package shepherd:cow@0.1.0; + +/// CoW on-chain event surfaces the keepers decode. Package of record +/// for the canonical event signatures and their keccak256 topic-0 +/// hashes; guest constants and module manifests are parity-tested +/// against this file. +interface cow-events { + /// A decoded CoW on-chain event surface. Each variant doc pins the + /// canonical Solidity signature and its topic-0. + enum cow-event { + /// ComposableCoW registration. + /// signature: ConditionalOrderCreated(address,(address,bytes32,bytes)) + /// topic0: 0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361 + conditional-order-created, + /// CoWSwapOnchainOrders (EthFlow) placement. + /// signature: OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes) + /// topic0: 0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9 + order-placement, + } +} diff --git a/wit/shepherd-cow/cow-ext.wit b/wit/shepherd-cow/cow-ext.wit index d78889c8..2a81b5f7 100644 --- a/wit/shepherd-cow/cow-ext.wit +++ b/wit/shepherd-cow/cow-ext.wit @@ -3,6 +3,7 @@ package shepherd:cow@0.1.0; /// Extension world: the cow-api interface alone, wired into a module /// linker by the cow extension. Kept separate from `shepherd` so the /// extension contributes only its own import, never the core interfaces. +/// Retiring with `cow-api`. world cow-ext { import cow-api; } From ef62240846b233c01dadc888f1c4edd513439ec5 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 11:10:21 +0000 Subject: [PATCH 41/53] cow: ratify the retry classification table against the upstream errorType enum Prune the phantom PriceExceedsMarketPrice row, record the table (not cowprotocol RetryHint) as the classification source of truth in the data header, and pin the reconciliation with parity tests: every row must name a real upstream errorType, and the divergence from retry_hint() must be exactly the ratified set. --- Cargo.lock | 1 + crates/cow-venue/Cargo.toml | 3 ++ crates/cow-venue/data/classification.toml | 35 +++++++------ crates/cow-venue/src/classification.rs | 60 +++++++++++++++++++++++ crates/shepherd-sdk/src/cow/error.rs | 13 ++--- 5 files changed, 89 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a53d316d..858e830c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1570,6 +1570,7 @@ name = "cow-venue" version = "0.1.0" dependencies = [ "borsh", + "cowprotocol", "nexum-sdk", "serde", "thiserror 2.0.18", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 8e303765..3a780947 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -41,6 +41,9 @@ toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. videre-test = { path = "../videre-test" } +# Parity tests only: the upstream errorType enum and `retry_hint()` the +# shipped table is reconciled against. Never a runtime dependency. +cowprotocol = { version = "0.2.0", default-features = false } [features] # The body-type + codec slice ships by default; the `client` slice layers diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index 46507d48..f7910590 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -33,17 +33,26 @@ # # Relationship to `cowprotocol::ApiError::retry_hint()`. The upstream # `cowprotocol` crate (a shepherd-sdk dependency) also classifies -# orderbook `errorType`s, via `RetryHint`. This table is deliberately -# NOT delegated to it: it is shepherd's own, more conservative retry -# policy, kept as data of record here so a non-Rust author owns it and -# so the guest `client` slice stays free of the upstream error module. -# The two intentionally diverge on several types - e.g. this table drops -# `InvalidEip1271Signature`, `InsufficientBalance`, `InsufficientAllowance` -# and `InvalidAppData` where upstream retries or backs off, and backs off -# `TooManyLimitOrders` for 30s rather than an hour. These are ratified -# shepherd decisions (a permanent-looking contract rejection is dropped -# rather than retried every block); revisit them here, not by switching -# the source of truth to `RetryHint`. +# orderbook `errorType`s, via `RetryHint`. Ratified: this table, not +# `RetryHint`, is shepherd's classification source of truth. It is +# shepherd's own, more conservative retry policy, kept as data of record +# here so a non-Rust author owns it and so the guest `client` slice +# stays free of the upstream error module. The ratified divergences +# (a permanent-looking contract rejection is dropped rather than +# retried, and the limit-order backoff is shorter) are exactly: +# +# InvalidEip1271Signature drop upstream: retry next block +# InsufficientBalance drop upstream: backoff 10 min +# InsufficientAllowance drop upstream: backoff 10 min +# InvalidAppData drop upstream: backoff 60 s +# TooManyLimitOrders backoff 30 s upstream: backoff 1 h +# +# Every `error-type` below must name a member of the upstream orderbook +# errorType enum (`cowprotocol::OrderbookApiErrorType`). Parity tests +# reject phantom types and pin the divergence set to the list above, so +# both a data edit and an upstream policy change force re-ratification. +# Revisit policy here, not by switching the source of truth to +# `RetryHint`. # --- Transient: retry on the next block ------------------------------ @@ -51,10 +60,6 @@ error-type = "InsufficientFee" action = "try-next-block" -[[entry]] -error-type = "PriceExceedsMarketPrice" -action = "try-next-block" - # --- Throttle: wait, then retry -------------------------------------- # The account already holds the maximum number of open limit orders. A diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index 73d0e71f..aabda91f 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -235,6 +235,66 @@ mod tests { ); } + /// Every listed `error-type` names a member of the upstream + /// orderbook errorType enum, in its exact wire spelling: no phantom + /// rows. + #[test] + fn every_row_names_a_real_error_type() { + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + for entry in &entries { + let kind = cowprotocol::OrderbookApiErrorType::from(entry.error_type.as_str()); + assert!( + !matches!(kind, cowprotocol::OrderbookApiErrorType::Unknown(_)), + "phantom errorType {}", + entry.error_type, + ); + assert_eq!(kind.as_str(), entry.error_type, "wire spelling"); + } + } + + /// The table's divergence from upstream `retry_hint()` is exactly + /// the ratified set in the data header. A data edit or an upstream + /// policy change lands here and forces re-ratification. + #[test] + fn divergence_from_upstream_is_exactly_the_ratified_set() { + const RATIFIED: [&str; 5] = [ + "InsufficientAllowance", + "InsufficientBalance", + "InvalidAppData", + "InvalidEip1271Signature", + "TooManyLimitOrders", + ]; + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + let mut divergent: Vec<&str> = Vec::new(); + for entry in &entries { + let api = cowprotocol::ApiError { + error_type: entry.error_type.clone(), + description: String::new(), + data: None, + }; + // Project the upstream hint into the table's model; a hint + // variant this projection does not know is a divergence. + let upstream = match api.retry_hint() { + cowprotocol::RetryHint::Retry => Some((RetryAction::TryNextBlock, false)), + cowprotocol::RetryHint::Backoff { seconds } => { + Some((RetryAction::Backoff { seconds }, false)) + } + cowprotocol::RetryHint::Drop => Some((RetryAction::Drop, false)), + cowprotocol::RetryHint::AlreadySubmitted => Some((RetryAction::TryNextBlock, true)), + _ => None, + }; + let shepherd = ( + classify(&entry.error_type), + is_already_submitted(&entry.error_type), + ); + if upstream != Some(shepherd) { + divergent.push(&entry.error_type); + } + } + divergent.sort_unstable(); + assert_eq!(divergent, RATIFIED); + } + /// A non-Rust reader sees the same file as plain data: parsing it /// with the untyped TOML value model (no Rust schema) exposes the /// entries and their fields, proving any TOML library reads it. diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index 3f676861..a5e0de03 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -170,14 +170,11 @@ mod tests { } #[test] - fn retriable_kinds_yield_try_next_block() { - for kind in ["InsufficientFee", "PriceExceedsMarketPrice"] { - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::TryNextBlock, - "{kind}", - ); - } + fn retriable_kind_yields_try_next_block() { + assert_eq!( + classify_api_error(&rejection("InsufficientFee")), + RetryAction::TryNextBlock, + ); } /// A throttle errorType backs off rather than retrying next block, From 09ad5b8666aa2341db53c8626cc871b3fbf5f9d2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 11:05:38 +0000 Subject: [PATCH 42/53] docs: call out the grant-M2 contract-mods divergence in milestone reporting --- docs/00-overview.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/00-overview.md b/docs/00-overview.md index 4deeecd5..4e5d12ce 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -342,11 +342,13 @@ The mobile/wallet host story - including the experimental `query-module` world's | # | Milestone | Effort | Key Deliverables | |---|-----------|--------|------------------| | 1 | Core Runtime & Event System | 120h | wasmtime Component Model host, WIT interfaces, event sources, redb local store, CLI | -| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods | +| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods\* | | 3 | SDK & Developer Experience | 60h | `shepherd-sdk` + `shepherd-sdk-test` crates (host-trait seam per ADR-0009), example modules, tutorial, docs | | 4 | Production Hardening | 60h | Resource limits, restart policy, logging, metrics, health checks | | 5 | Multi-Chain & Deployment | 40h | Multi-chain config, Docker image, deployment docs | +\* **M2 divergence.** The "ComposableCoW contract mods" deliverable (enhanced polling interfaces, optimized getters, monitoring events) was intentionally met off-chain: no Solidity was modified. The TWAP module polls `getTradeableOrderWithSignature` via raw `eth_call` with SDK helpers. Contract-side interfaces would fix one concrete TWAP implementation behind the boundary and block competing strategies (ADR-0006). The functional goal stands; the literal deliverable does not. + ## Repository Structure ``` From 497ccd9c67af376409dcf33e03bc724d283457a0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 12:38:18 +0000 Subject: [PATCH 43/53] cow: settle the idempotency seam on the venue-and-body intent-id The submitted: journal now keys on the deterministic intent-id (the generic sweep's venue-and-body submission key over the encoded CowIntentBody), derived pre-submit without assembling OrderCreation. SubmitOutcome's accepted receipt is fixed as the canonical 56-byte order UID (OrderUid), and the CowIntent schema gains the Signed kind a conditional-order keeper emits. A regression test covers resubmit after restart with a single orderbook POST. --- crates/cow-venue/src/body.rs | 19 +++- crates/cow-venue/src/client.rs | 43 ++++++++- crates/cow-venue/src/lib.rs | 8 +- crates/cow-venue/src/order.rs | 98 ++++++++++++++++++++ crates/shepherd-sdk-test/tests/mock_venue.rs | 25 ++++- crates/shepherd-sdk/src/cow/mod.rs | 4 +- crates/shepherd-sdk/src/cow/order.rs | 55 +++++++++++ crates/shepherd-sdk/src/cow/run.rs | 81 ++++++++-------- crates/shepherd-sdk/tests/run.rs | 81 ++++++++++++---- crates/videre-sdk/src/keeper.rs | 8 +- modules/twap-monitor/src/strategy.rs | 53 ++++++----- 11 files changed, 374 insertions(+), 101 deletions(-) diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index feb7d3c8..6bb39165 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -1,6 +1,6 @@ //! The CoW intent body and its versioned `IntentBody` codec. //! -//! A CoW intent is a direct order for the orderbook; [`CowIntent`] is +//! A CoW intent is an order for the orderbook; [`CowIntent`] is //! that sum, open for future intent kinds. [`CowIntentBody`] is the //! outer per-venue version enum the venue publishes, and //! `#[derive(IntentBody)]` gives it the borsh codec: a one-byte version @@ -13,13 +13,16 @@ use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::IntentBody; -use crate::order::OrderBody; +use crate::order::{OrderBody, SignedOrder}; -/// What the CoW venue accepts: a direct order for the orderbook. +/// What the CoW venue accepts: an order for the orderbook. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub enum CowIntent { /// A direct `GPv2Order` to place on the orderbook. Order(OrderBody), + /// An owner-signed order with its EIP-1271 signature: what a + /// conditional-order keeper emits after a poll. + Signed(SignedOrder), } /// The outer per-venue version enum: the schema the CoW venue publishes. @@ -56,6 +59,16 @@ mod tests { &CowIntentBody::V1(CowIntent::Order(order_body())), ) .expect("order body encodes"); + vectors + .push_round_trip( + "v1-signed", + &CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + })), + ) + .expect("signed order encodes"); let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); let mut unknown = bytes(CowIntent::Order(order_body())); diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 892e4f4e..7d13353c 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -8,12 +8,17 @@ //! slice so the client that submits an order and the table that //! classifies its rejection version together. +use alloc::string::String; + use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId}; +use videre_sdk::keeper::submission_key; +use videre_sdk::{BodyError, IntentBody as _}; use crate::body::CowIntentBody; /// The CoW venue marker: every [`CowClient`] call routes to -/// [`Venue::ID`] and encodes a [`CowIntentBody`]. +/// [`Venue::ID`] and encodes a [`CowIntentBody`]. An accepted submit's +/// receipt is the canonical [`OrderUid`](crate::OrderUid) in wire form. #[derive(Clone, Copy, Debug)] pub struct CowVenue; @@ -26,6 +31,14 @@ impl Venue for CowVenue { /// or submit a foreign body. pub type CowClient = VenueClient; +/// Deterministic intent-id for `body`: the sweep's +/// [`submission_key`] bound to [`CowVenue::ID`]. Derivable before any +/// network work, so a keeper journals the same key whether it submits +/// through the sweep or directly. +pub fn intent_id(body: &CowIntentBody) -> Result { + Ok(submission_key(&CowVenue::ID, &body.to_bytes()?)) +} + #[cfg(test)] mod tests { use std::cell::RefCell; @@ -91,6 +104,34 @@ mod tests { )) } + #[test] + fn intent_id_is_deterministic_and_body_scoped() { + use videre_sdk::IntentBody; + + use crate::body::CowIntent; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + let body = sample_body(); + let id = intent_id(&body).expect("body encodes"); + assert_eq!(id, intent_id(&body.clone()).expect("body encodes")); + assert_eq!( + id, + submission_key(&CowVenue::ID, &body.to_bytes().expect("body encodes")), + "the id must be exactly the key the generic sweep journals", + ); + assert!(id.starts_with("cow:0x")); + + let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .build(), + owner: [0x55; 20], + signature: vec![0xC0], + })); + assert_ne!(id, intent_id(&other).expect("body encodes")); + } + #[test] fn submit_routes_to_the_cow_venue_with_encoded_body() { use videre_sdk::IntentBody; diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index fc4392b5..4e0879ba 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -19,7 +19,8 @@ //! without pulling the codec transitively. //! //! The `client` slice layers on top: a typed [`CowClient`] bound to the -//! CoW venue plus the table-driven retry [`classification`] generated at +//! CoW venue, the deterministic [`intent_id`] journal key, and the +//! table-driven retry [`classification`] generated at //! build time from the shipped `data/classification.toml` (the TOML //! parser stays a build-time dependency, off the guest). It links the //! strategy keeper (for the retry action type) and is off by default, @@ -56,10 +57,11 @@ pub mod client; pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] pub use order::{ - BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource, + BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, OrderUid, SellToken, + SellTokenSource, SignedOrder, }; #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; #[cfg(feature = "client")] -pub use client::{CowClient, CowVenue}; +pub use client::{CowClient, CowVenue, intent_id}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index a8ac4a57..8bb1d725 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -9,6 +9,8 @@ //! marker enums are canonical wire forms, not on-chain keccak markers, //! so the adapter, not this type, owns the projection to and from chain. +use alloc::vec::Vec; +use core::fmt; use core::marker::PhantomData; use borsh::{BorshDeserialize, BorshSerialize}; @@ -259,6 +261,70 @@ impl OrderBuilder { } } +/// An owner-signed order ready for the orderbook: what a +/// conditional-order keeper emits after a poll. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct SignedOrder { + /// The order to place. + pub order: OrderBody, + /// Order owner: the EIP-1271 verifier and the `from` of the + /// orderbook submission. + pub owner: Address, + /// Raw EIP-1271 signature bytes; the settlement verifies them + /// against `owner`. + pub signature: Vec, +} + +/// Canonical 56-byte orderbook order UID (order digest, owner, +/// `valid_to`) in wire form: the receipt bytes an accepted CoW submit +/// carries. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OrderUid(pub [u8; 56]); + +impl OrderUid { + /// The raw 56 bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 56] { + &self.0 + } +} + +impl From<[u8; 56]> for OrderUid { + fn from(bytes: [u8; 56]) -> Self { + Self(bytes) + } +} + +impl TryFrom<&[u8]> for OrderUid { + type Error = core::array::TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self(<[u8; 56]>::try_from(bytes)?)) + } +} + +impl From for Vec { + fn from(uid: OrderUid) -> Self { + uid.0.to_vec() + } +} + +impl fmt::Display for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("0x")?; + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + #[cfg(test)] mod tests { use super::*; @@ -367,4 +433,36 @@ mod tests { } } } + + #[test] + fn signed_order_borsh_round_trips() { + let signed = SignedOrder { + order: sample(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + }; + let bytes = borsh::to_vec(&signed).expect("encode"); + assert_eq!(SignedOrder::try_from_slice(&bytes).expect("decode"), signed); + } + + #[test] + fn order_uid_converts_only_from_56_bytes() { + let uid = OrderUid([0xAB; 56]); + assert_eq!(OrderUid::try_from(&uid.0[..]).expect("56 bytes"), uid); + assert!(OrderUid::try_from(&uid.0[..55]).is_err()); + assert_eq!(Vec::from(uid), vec![0xAB; 56]); + } + + #[test] + fn order_uid_displays_as_prefixed_hex() { + let mut bytes = [0u8; 56]; + bytes[0] = 0x01; + bytes[55] = 0xFF; + let uid = OrderUid(bytes); + let hex = uid.to_string(); + assert_eq!(hex.len(), 2 + 56 * 2); + assert!(hex.starts_with("0x01")); + assert!(hex.ends_with("ff")); + assert_eq!(format!("{uid:?}"), hex); + } } diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index 6867ecdd..d8af0612 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -8,7 +8,10 @@ use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowHost, CowIntent, CowIntentBody, OrderRejection, SignedOrder, + gpv2_to_order_data, order_data_to_body, order_uid_hex, run, +}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; @@ -107,6 +110,18 @@ fn client_uid(order: &GPv2OrderData) -> String { order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") } +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") +} + fn rejection(error_type: &str) -> CowApiError { CowApiError::Rejected(OrderRejection { status: 400, @@ -136,7 +151,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { assert!(host.store.snapshot().contains_key(&key), "watch survives"); assert!( !Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); @@ -144,7 +159,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); assert_eq!( @@ -190,7 +205,7 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } @@ -220,7 +235,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 84a97643..e733da5d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -25,7 +25,7 @@ pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, }; -pub use order::{gpv2_to_order_data, order_uid_hex}; +pub use order::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use run::run; /// The venue-neutral intent body types and their borsh `IntentBody` @@ -33,7 +33,7 @@ pub use run::run; /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ BuyToken, BuyTokenDestination, CowIntent, CowIntentBody, OrderBody, OrderBuilder, OrderKind, - SellToken, SellTokenSource, + OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index d0a79bfb..79431954 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -92,6 +92,37 @@ pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Op Some(format!("{}", order_data.uid(&domain, owner))) } +/// Project a typed [`OrderData`] into the venue wire +/// [`OrderBody`](cow_venue::OrderBody) a keeper emits. Total: every +/// typed field has exactly one wire form. +#[must_use] +pub fn order_data_to_body(order: &OrderData) -> cow_venue::OrderBody { + cow_venue::OrderBody { + sell_token: order.sell_token.into_array(), + buy_token: order.buy_token.into_array(), + receiver: order.receiver.map(Address::into_array), + sell_amount: order.sell_amount.to_be_bytes(), + buy_amount: order.buy_amount.to_be_bytes(), + valid_to: order.valid_to, + app_data: order.app_data.0, + fee_amount: order.fee_amount.to_be_bytes(), + kind: match order.kind { + OrderKind::Sell => cow_venue::OrderKind::Sell, + OrderKind::Buy => cow_venue::OrderKind::Buy, + }, + partially_fillable: order.partially_fillable, + sell_token_balance: match order.sell_token_balance { + SellTokenSource::Erc20 => cow_venue::SellTokenSource::Erc20, + SellTokenSource::External => cow_venue::SellTokenSource::External, + SellTokenSource::Internal => cow_venue::SellTokenSource::Internal, + }, + buy_token_balance: match order.buy_token_balance { + BuyTokenDestination::Erc20 => cow_venue::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => cow_venue::BuyTokenDestination::Internal, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -159,6 +190,30 @@ mod tests { assert!(gpv2_to_order_data(&g).is_none()); } + // ---- order_data_to_body ---- + + #[test] + fn order_data_to_body_projects_every_field() { + let g = submittable_gpv2(); + let order = gpv2_to_order_data(&g).expect("known markers"); + let body = order_data_to_body(&order); + assert_eq!(body.sell_token, g.sellToken.into_array()); + assert_eq!(body.buy_token, g.buyToken.into_array()); + assert_eq!(body.receiver, Some(g.receiver.into_array())); + assert_eq!(body.sell_amount, g.sellAmount.to_be_bytes::<32>()); + assert_eq!(body.buy_amount, g.buyAmount.to_be_bytes::<32>()); + assert_eq!(body.valid_to, g.validTo); + assert_eq!(body.app_data, g.appData.0); + assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); + assert_eq!(body.kind, cow_venue::OrderKind::Sell); + assert!(!body.partially_fillable); + assert_eq!(body.sell_token_balance, cow_venue::SellTokenSource::Erc20); + assert_eq!( + body.buy_token_balance, + cow_venue::BuyTokenDestination::Erc20 + ); + } + // ---- order_uid_hex ---- const SEPOLIA: u64 = 11_155_111; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index 910090e1..05761760 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -6,7 +6,8 @@ //! [`Verdict`]'s effect: lifecycle outcomes update the gate and //! watch stores, `Post` drives one submission through the //! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` -//! journal as the idempotency guard and the keeper [`Retrier`] +//! journal as the idempotency guard - keyed on the venue-and-body +//! [`intent_id`](super::intent_id) - and the keeper [`Retrier`] //! as the failure dispatch. //! //! Store faults abort the sweep (the next tick replays it); @@ -25,8 +26,8 @@ use nexum_sdk::keeper::{ }; use super::{ - CowApiError, CowHost, classify_submit_error, gpv2_to_order_data, is_already_submitted, - order_uid_hex, + CowApiError, CowHost, CowIntent, CowIntentBody, SignedOrder, classify_submit_error, + gpv2_to_order_data, intent_id, is_already_submitted, order_data_to_body, }; /// Poll every gate-ready watch once at `tick` and run each outcome's @@ -76,9 +77,12 @@ where /// `submitted:` journal and dispatching any failure through the retry /// ledger. /// -/// The UID is deterministic from on-chain inputs, so the idempotency -/// check runs before any network work; the same value keys the journal -/// marker after, so the read and write paths agree. +/// The journal keys on the deterministic venue-and-body +/// [`intent_id`], derived before any network work from the same body +/// bytes a venue submit carries - never from the assembled +/// `OrderCreation` - so the guard survives assembly moving into the +/// venue adapter. The orderbook's UID is the receipt; it rides the +/// log only. fn submit_ready( host: &H, watch: WatchRef<'_>, @@ -95,15 +99,6 @@ fn submit_ready( return Ok(()); }; - let journal = Journal::submitted(host); - let client_uid = order_uid_hex(tick.chain_id, order, owner); - if let Some(uid) = client_uid.as_deref() - && journal.contains(uid)? - { - tracing::info!("{label} {uid} already submitted; skipping re-submit"); - return Ok(()); - } - let Some(order_data) = gpv2_to_order_data(order) else { // An unknown enum marker means the SDK cannot express this // payload yet; skip rather than drop so an SDK upgrade can @@ -113,6 +108,24 @@ fn submit_ready( ); return Ok(()); }; + + let intent = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + })); + let intent_id = match intent_id(&intent) { + Ok(id) => id, + Err(err) => { + tracing::error!("intent body encode failed: {err}"); + return Ok(()); + } + }; + let journal = Journal::submitted(host); + if journal.contains(&intent_id)? { + tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); + return Ok(()); + } let creation = match build_order_creation(&order_data, signature, owner) { Ok(creation) => creation, Err(err) => { @@ -137,41 +150,29 @@ fn submit_ready( }; match host.submit_order(tick.chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID so the guard above reads - // what this writes; a divergence would be a protocol bug - // worth a warning, never a silently split keyspace. - let marker = client_uid.as_deref().unwrap_or(server_uid.as_str()); + Ok(receipt) => { // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the // next tick's re-post idempotent. - if let Err(fault) = journal.record(marker) { - tracing::error!("submitted {marker} but journal write failed: {fault}"); + if let Err(fault) = journal.record(&intent_id) { + tracing::error!("submitted {intent_id} but journal write failed: {fault}"); } - if let Some(client) = client_uid.as_deref() - && client != server_uid - { - tracing::warn!( - "{label} UID divergence: client={client} server={server_uid} \ - (marker keyed on the client UID)" - ); - } - tracing::info!("submitted {marker}"); + tracing::info!("submitted {intent_id} (receipt {receipt})"); } Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { // Success wearing an error status: the orderbook already - // holds this order. Record the receipt and keep the watch - // so the next tick short-circuits instead of re-posting. - // As above, a journal fault post-submit only forfeits the - // short-circuit; it must not abort the sweep. - if let Some(uid) = client_uid.as_deref() - && let Err(fault) = journal.record(uid) - { - tracing::error!("orderbook already holds {uid} but journal write failed: {fault}"); + // holds this order. Journal the intent-id and keep the + // watch so the next tick short-circuits instead of + // re-posting. As above, a journal fault post-submit only + // forfeits the short-circuit; it must not abort the sweep. + if let Err(fault) = journal.record(&intent_id) { + tracing::error!( + "orderbook already holds {intent_id} but journal write failed: {fault}" + ); } tracing::info!( - "orderbook already holds this order ({}); receipt recorded", + "orderbook already holds this order ({}); intent-id journalled", rejection.error_type, ); } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index 76677e0a..bff0f238 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -12,7 +12,10 @@ use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{CowApiError, OrderRejection, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowIntent, CowIntentBody, OrderRejection, SignedOrder, gpv2_to_order_data, + order_data_to_body, run, +}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -99,8 +102,16 @@ fn seed_watch(host: &MockHost) -> String { .unwrap() } -fn client_uid(order: &GPv2OrderData) -> String { - order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") } // ---- lifecycle outcomes ---- @@ -229,11 +240,11 @@ fn malformed_watch_rows_are_skipped() { // ---- ready -> submission ---- #[test] -fn ready_submits_once_and_journals_the_client_uid() { +fn ready_submits_once_and_journals_the_intent_id() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok(client_uid(&order))); + host.cow_api.respond(Ok("0xserveruid".to_string())); let source = { let order = order.clone(); @@ -244,15 +255,15 @@ fn ready_submits_once_and_journals_the_client_uid() { assert_eq!(host.cow_api.call_count(), 1); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "submitted:{{client_uid}} receipt must be recorded", + "submitted:{{intent_id}} marker must be recorded", ); assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); } #[test] -fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { +fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); @@ -262,25 +273,23 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); - result.unwrap(); + run(&host, &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); assert!( !snapshot.contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID", + "marker must key on the pre-submit intent-id, not the server receipt", ); - assert!(logs.any(|e| e.message.contains("UID divergence"))); } #[test] -fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { +fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); Journal::submitted(&host) - .record(&client_uid(&order)) + .record(&intent_id(&order)) .unwrap(); let polls = Cell::new(0_u32); @@ -422,9 +431,9 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { assert!(host.store.snapshot().contains_key(&key)); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "already-submitted must record the receipt", + "already-submitted must journal the intent-id", ); // The next tick must not touch the orderbook again. @@ -432,6 +441,44 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { assert_eq!(host.cow_api.call_count(), 1); } +/// Restart regression: a keeper that posted, journalled, and then +/// restarted over the same persistent local store must not post the +/// same order again - one orderbook POST across both lives. +#[test] +fn restart_with_a_journalled_intent_does_not_repost() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xserveruid".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + + // A restarted keeper: fresh instance, the local store carried over. + let restarted = MockHost::new(); + for (key, value) in host.store.snapshot() { + restarted.store.set(&key, &value).unwrap(); + } + restarted.cow_api.respond(Ok("0xserveruid".to_string())); + + run(&restarted, &source, &sample_tick()).unwrap(); + + assert_eq!( + host.cow_api.call_count() + restarted.cow_api.call_count(), + 1, + "resubmit after restart must make no second orderbook POST", + ); + assert!( + Journal::submitted(&restarted) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + /// A rate-limit fault with server guidance backs the watch off on the /// epoch clock - `RetryAction::Backoff` reached through the ledger. #[test] diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index f94a88f8..7255efd9 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -63,7 +63,7 @@ impl Keeper { /// Sweep the watch set once at `tick`: poll every ready watch, /// submit [`Sweep::Submit`] bodies through the venue seam, and /// run every other outcome and every venue refusal through the - /// [`Retrier`]. A venue-and-body key is checked against the + /// [`Retrier`]. The [`submission_key`] is checked against the /// `submitted:` [`Journal`] before every submit and recorded on /// acceptance, so an accepted body never reaches the venue twice; /// a `requires-signing` answer journals nothing and is surfaced @@ -156,8 +156,10 @@ pub struct SweepReport { /// Deterministic pre-submit journal key: the venue id and the /// keccak-256 of the body. The hash is a fixed-length suffix, so the -/// key is unambiguous whatever the venue id contains. -fn submission_key(venue: &VenueId, body: &[u8]) -> String { +/// key is unambiguous whatever the venue id contains. Public so a +/// keeper journalling outside [`Keeper::sweep`] writes the key the +/// sweep checks. +pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index a9bcee04..540a6c7e 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -236,8 +236,15 @@ fn parse_watch_key(key: &str) -> Option<(&str, &str)> { } #[cfg(test)] -fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + use shepherd_sdk::cow::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = shepherd_sdk::cow::gpv2_to_order_data(order)?; + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: shepherd_sdk::cow::order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + }))) + .ok() } #[cfg(test)] @@ -493,7 +500,7 @@ mod tests { } #[test] - fn poll_ready_submits_order_and_persists_submitted_uid() { + fn poll_ready_submits_order_and_persists_the_intent_id() { let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); @@ -509,30 +516,22 @@ mod tests { ); host.cow_api.respond(Ok("0xfeedface".to_string())); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); + on_block(&host, sample_block(1_000)).unwrap(); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert_eq!(host.chain.call_count(), 1); assert_eq!(host.cow_api.call_count(), 1); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "expected submitted:{{client_uid}} marker" + .contains_key(&format!("submitted:{expected_id}")), + "expected submitted:{{intent_id}} marker" ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID" + "marker must key on the pre-submit intent-id, not the server receipt" ); - // The MockHost orderbook stub returns `0xfeedface` instead of - // the canonical UID; the strategy logs a Warn about the - // divergence (real orderbooks would not diverge). - let ev = logs - .expect_one(|e| e.level == Level::WARN && e.message.contains("twap UID divergence")); - assert!(ev.message.contains(&format!("client={expected_uid}"))); - assert!(ev.message.contains("server=0xfeedface")); } /// Regression guard: when `getTradeableOrderWithSignature` @@ -541,10 +540,10 @@ mod tests { /// POSTed it), the second tick must NOT call `submit_order` /// again. Without the guard the orderbook responds with /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{uid}` + /// correct, finished work. The guard is the `submitted:{intent_id}` /// short-circuit at the top of `submit_ready`. #[test] - fn poll_ready_skips_submit_when_submitted_uid_already_in_store() { + fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); @@ -562,10 +561,10 @@ mod tests { // Seed the marker that a previous successful poll-tick would // have written. The poll path must read this and skip; the // orderbook submit must not be attempted. - let already_submitted_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let already_submitted = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); host.store - .set(&format!("submitted:{already_submitted_uid}"), b"") + .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); on_block(&host, sample_block(1_000)).unwrap(); @@ -578,7 +577,7 @@ mod tests { assert_eq!( host.cow_api.call_count(), 0, - "submit_order must NOT be called when submitted:{{uid}} already exists", + "submit_order must NOT be called when submitted:{{intent_id}} already exists", ); assert_eq!( host.cow_api.request_calls().len(), @@ -636,13 +635,13 @@ mod tests { body.get("appDataHash").is_none(), "hash-only body must omit appDataHash, got: {body}" ); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful submit" + .contains_key(&format!("submitted:{expected_id}")), + "submitted:{{intent_id}} marker must be written after a successful submit" ); } From 39f178a1fb442ae54af22f36707c78d883590935 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 13:43:08 +0000 Subject: [PATCH 44/53] cow: build the cow venue adapter component with timeout transport middleware cow-venue gains the adapter slice: CowAdapter under #[videre_sdk::venue] decodes CowIntentBody, derives the value-flow header, and speaks the orderbook REST API over scoped wasi:http. A signed order posts EIP-1271 and its receipt is the canonical 56-byte UID; an unsigned order posts pre-sign and returns requires-signing carrying the setPreSignature call. An already-held rejection is success with the client-derived UID, and errorType rejections project onto venue-error through the shipped classification table, so the retry hint survives the collapse. The chain-edge order assembly (gpv2_to_order_data, order_data_to_body, order_uid_hex, build_order_creation) moves into the new assembly slice the adapter owns; shepherd-sdk re-exports it for the legacy keeper run. videre-sdk gains TimedFetch, the per-request timeout middleware every adapter request rides. Codec vectors and header goldens are published under tests/vectors with a conformance suite, and the built component bundles into the shepherd distribution via the engine adapters stanza (example and docker configs, justfile, Dockerfile, CI wasm build). --- .github/workflows/ci.yml | 8 +- Cargo.lock | 7 + Dockerfile | 17 +- crates/cow-venue/Cargo.toml | 45 +- crates/cow-venue/module.toml | 29 + crates/cow-venue/src/adapter.rs | 928 ++++++++++++++++++ .../order.rs => cow-venue/src/assembly.rs} | 198 +++- crates/cow-venue/src/lib.rs | 29 +- crates/cow-venue/tests/conformance.rs | 29 + .../tests/vectors/cow-header-goldens.json | 60 ++ .../tests/vectors/cow-intent-body.json | 48 + crates/nexum-sdk/src/http.rs | 11 + crates/shepherd-sdk/Cargo.toml | 5 +- crates/shepherd-sdk/src/cow/mod.rs | 13 +- crates/shepherd-sdk/src/cow/run.rs | 24 +- crates/shepherd-sdk/src/proptests.rs | 2 +- crates/videre-host/tests/platform.rs | 41 + crates/videre-sdk/Cargo.toml | 3 + crates/videre-sdk/src/lib.rs | 8 +- crates/videre-sdk/src/transport.rs | 111 ++- engine.docker.toml | 11 + engine.example.toml | 13 + justfile | 5 + 23 files changed, 1567 insertions(+), 78 deletions(-) create mode 100644 crates/cow-venue/module.toml create mode 100644 crates/cow-venue/src/adapter.rs rename crates/{shepherd-sdk/src/cow/order.rs => cow-venue/src/assembly.rs} (52%) create mode 100644 crates/cow-venue/tests/conformance.rs create mode 100644 crates/cow-venue/tests/vectors/cow-header-goldens.json create mode 100644 crates/cow-venue/tests/vectors/cow-intent-body.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74809b37..9e0f3b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,8 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 17 guest module wasms ONCE (release/wasm32-wasip2): the single + # Build all 18 guest wasms ONCE (17 modules + the cow adapter, + # release/wasm32-wasip2): the single # source of truth for guest buildability and the artifacts the integration # tests load. Replaces the deleted 9-way build-module matrix, which recompiled # the shared wasm dependency graph ~9x cold. Per-module size report folded in; @@ -90,6 +91,11 @@ jobs: -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ -p echo-client -p echo-keeper -p clock-reader -p flaky-bomb -p flaky-venue \ -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host + # Separate invocation on purpose: unifying `cow-venue/adapter` + # into the module build would link the adapter's component + # export glue into every keeper module wasm. + cargo build --release --target wasm32-wasip2 --locked \ + -p cow-venue --features cow-venue/adapter { echo "### module .wasm sizes" echo "| module | bytes |" diff --git a/Cargo.lock b/Cargo.lock index 858e830c..cb1328ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1569,14 +1569,20 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "cow-venue" version = "0.1.0" dependencies = [ + "alloy-primitives", + "alloy-sol-types", "borsh", "cowprotocol", + "http", "nexum-sdk", "serde", + "serde_json", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "url", "videre-sdk", "videre-test", + "wit-bindgen 0.59.0", ] [[package]] @@ -6086,6 +6092,7 @@ name = "videre-sdk" version = "0.1.0" dependencies = [ "borsh", + "http", "nexum-sdk", "nexum-sdk-test", "strum", diff --git a/Dockerfile b/Dockerfile index d7465ff5..1fbaba16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # syntax=docker/dockerfile:1.6 # # Multi-stage build for `shepherd` - the cow composition-root engine -# binary plus the five production WASM modules baked into a single image. +# binary plus the five production WASM modules and the bundled cow +# venue adapter baked into a single image. # # Stage 1 (`build`): full Rust toolchain + wasm32-wasip2 target, builds # the engine in release mode + each module to a Component Model wasm @@ -68,7 +69,9 @@ COPY --from=planner /src/recipe.json recipe.json RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss --recipe-path recipe.json + -p balance-tracker -p stop-loss --recipe-path recipe.json \ + && cargo chef cook --release --target wasm32-wasip2 \ + -p cow-venue --features cow-venue/adapter --recipe-path recipe.json # Now the workspace sources. `.dockerignore` keeps the context lean # (no `target/`, no `data/`, no large baseline / backtest fixtures). @@ -79,13 +82,15 @@ COPY . . # is used verbatim so builds are reproducible. RUN cargo build -p shepherd --release --locked -# Five production modules. The wasm artefacts land under +# Five production modules plus the bundled cow venue adapter. The wasm +# artefacts land under # `target/wasm32-wasip2/release/.wasm`. RUN cargo build -p twap-monitor --target wasm32-wasip2 --release --locked \ && cargo build -p ethflow-watcher --target wasm32-wasip2 --release --locked \ && cargo build -p price-alert --target wasm32-wasip2 --release --locked \ && cargo build -p balance-tracker --target wasm32-wasip2 --release --locked \ - && cargo build -p stop-loss --target wasm32-wasip2 --release --locked + && cargo build -p stop-loss --target wasm32-wasip2 --release --locked \ + && cargo build -p cow-venue --target wasm32-wasip2 --release --locked --features adapter # ----------------------------------------------------------------- runtime @@ -123,6 +128,10 @@ COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepher COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml +# The bundled cow venue adapter's manifest; installed via the +# engine.toml [[adapters]] stanza, never compiled into the engine. +COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml + # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and # binds 127.0.0.1:9100 inside the container. diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 3a780947..d718ad35 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -7,9 +7,11 @@ repository.workspace = true description = "CoW venue slices, orderbook-only. The default `body` slice carries the venue-neutral order intent body types and their borsh IntentBody codec, linkable by adapters and modules." [lib] -# Plain library. The default `body` slice is dependency-light so a venue -# adapter component or a strategy module can link the intent body types -# and codec without the host-side CoW machinery. +# The default `body` slice is dependency-light so a venue adapter +# component or a strategy module can link the intent body types and +# codec without the host-side CoW machinery. The cdylib is the +# `adapter` slice's component build (wasm32-wasip2). +crate-type = ["lib", "cdylib"] [lints] workspace = true @@ -27,6 +29,19 @@ videre-sdk = { path = "../videre-sdk", optional = true } # `build.rs`, so serde/toml/thiserror are build- and dev-only and never # reach a guest that links this slice. nexum-sdk = { path = "../nexum-sdk", optional = true } +# `assembly` slice: the chain-edge order projections and orderbook +# submission bodies. Express-declared (not workspace-inherited) so the +# guest build never inherits the native `http-client` feature. +cowprotocol = { version = "0.2.0", default-features = false, optional = true } +alloy-primitives = { workspace = true, optional = true } +alloy-sol-types = { workspace = true, optional = true } +# `adapter` slice: the orderbook REST speaker over the scoped +# wasi:http transport. +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +http = { workspace = true, optional = true } +url = { workspace = true, optional = true } +wit-bindgen = { workspace = true, optional = true } # `build.rs` parses `data/classification.toml` and emits the static # lookup table; the same parse is shared with the parity tests below. @@ -48,9 +63,27 @@ cowprotocol = { version = "0.2.0", default-features = false } [features] # The body-type + codec slice ships by default; the `client` slice layers # the typed client and the table-driven retry classification on top. -# `--no-default-features` drops both to an empty crate so downstream can -# depend on a future `adapter` slice without pulling the codec or the -# keeper transitively. +# `--no-default-features` drops everything so downstream can depend on a +# single slice without pulling the codec or the keeper transitively. default = ["body"] body = ["dep:borsh", "dep:videre-sdk"] client = ["body", "dep:nexum-sdk"] +# Chain-edge order assembly, shared by the adapter's submit and the +# keeper's legacy submit path. Carries no component glue, so a keeper +# module can link it without exporting the adapter face. +assembly = ["body", "dep:cowprotocol", "dep:alloy-primitives", "dep:alloy-sol-types"] +# The venue-adapter component slice: the `#[videre_sdk::venue]` export +# and the orderbook transport. Only the cdylib wasm build enables it. +adapter = [ + "assembly", + "client", + "dep:serde", + "dep:serde_json", + "dep:http", + "dep:url", + "dep:wit-bindgen", +] + +[[test]] +name = "conformance" +required-features = ["adapter"] diff --git a/crates/cow-venue/module.toml b/crates/cow-venue/module.toml new file mode 100644 index 00000000..b660014b --- /dev/null +++ b/crates/cow-venue/module.toml @@ -0,0 +1,29 @@ +# cow adapter manifest - the CoW venue's `#[videre_sdk::venue]` +# component. The manifest name is the venue id the registry installs +# the adapter under. Outbound orderbook HTTP is the only transport; +# the operator's `[[adapters]].http_allow` grant scopes it at install. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +# One adapter instance speaks one chain's orderbook. `orderbook-url`, +# `owner` (enables the pre-sign path), and `http-timeout-ms` are +# optional overrides. +[config] +chain = "1" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs new file mode 100644 index 00000000..7a7bb633 --- /dev/null +++ b/crates/cow-venue/src/adapter.rs @@ -0,0 +1,928 @@ +//! The CoW venue adapter: the `venue-adapter` component slice. +//! +//! [`CowAdapter`] decodes [`CowIntentBody`], assembles the orderbook +//! wire bodies through [`crate::assembly`], and speaks the +//! orderbook REST API over the scoped wasi:http transport, every +//! request bounded by the configured per-request timeout +//! ([`TimedFetch`](videre_sdk::transport::TimedFetch)). Orderbook +//! `errorType` rejections project onto +//! `venue-error` through the shipped classification table so the retry +//! hint survives the collapse: transient rows are `unavailable`, +//! throttles are `rate-limited`, permanent rows are `denied` (never +//! retried). An unsigned order submits as pre-sign: success is +//! `requires-signing` carrying the `setPreSignature` call the host +//! signs and sends. +//! +//! `[config]` keys: `chain` (required, decimal chain id), and optional +//! `orderbook-url` (base URL override), `owner` (hex address enabling +//! the pre-sign path), `http-timeout-ms` (per-request bound, default +//! 30000). + +use core::time::Duration; +use std::sync::{PoisonError, RwLock}; + +use alloy_primitives::{Address, U256}; +use cowprotocol::{ + ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, QuoteAppData, QuoteRequest, +}; +use nexum_sdk::keeper::RetryAction; +use serde::Deserialize; +use url::Url; +use videre_sdk::transport::http::Fetch; +use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use videre_sdk::{ + AuthScheme, IntentBody as _, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, + SubmitOutcome, UnsignedTx, VenueError, +}; + +use crate::assembly; +use crate::body::{CowIntent, CowIntentBody}; +use crate::classification; +use crate::order::OrderUid; + +/// The CoW venue's protocol speaker: the `venue-adapter` export type. +/// The component face is supplied by `#[videre_sdk::venue]` on the +/// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. +pub struct CowAdapter; + +/// Default per-request timeout bound in milliseconds. +const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +/// Parsed `[config]`: one adapter instance speaks one chain's orderbook. +#[derive(Clone, Debug)] +pub(crate) struct AdapterConfig { + pub(crate) chain: Chain, + pub(crate) base: Url, + pub(crate) owner: Option
, + pub(crate) timeout: Duration, +} + +impl AdapterConfig { + /// Parse the wire config table. Unknown keys are ignored; a + /// malformed value fails init typedly. + pub(crate) fn parse(config: &[(String, String)]) -> Result { + let invalid = |key: &str, value: &str| { + videre_sdk::Fault::InvalidInput(format!("config {key} is invalid: {value}")) + }; + let mut chain = None; + let mut base = None; + let mut owner = None; + let mut timeout = Duration::from_millis(DEFAULT_TIMEOUT_MS); + for (key, value) in config { + match key.as_str() { + "chain" => { + let id: u64 = value.parse().map_err(|_| invalid(key, value))?; + chain = Some(Chain::try_from(id).map_err(|_| invalid(key, value))?); + } + "orderbook-url" => { + let mut url: Url = value.parse().map_err(|_| invalid(key, value))?; + // Path joining relies on a trailing slash. + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + base = Some(url); + } + "owner" => { + owner = Some(value.parse::
().map_err(|_| invalid(key, value))?); + } + "http-timeout-ms" => { + let ms: u64 = value.parse().map_err(|_| invalid(key, value))?; + timeout = Duration::from_millis(ms.max(1)); + } + _ => {} + } + } + let chain = chain.ok_or_else(|| { + videre_sdk::Fault::InvalidInput("config requires a chain id".to_owned()) + })?; + Ok(Self { + chain, + base: base.unwrap_or_else(|| chain.orderbook_base_url()), + owner, + timeout, + }) + } +} + +/// The configured adapter state; `init` replaces it whole, so a +/// supervisor restart re-configures cleanly. +static CONFIG: RwLock> = RwLock::new(None); + +pub(crate) fn store_config(config: AdapterConfig) { + *CONFIG.write().unwrap_or_else(PoisonError::into_inner) = Some(config); +} + +/// The stored config, or the typed refusal for an uninitialised call. +/// The world contract calls `init` before any submission, so this is +/// only reachable on a host driving the exports out of order. +pub(crate) fn config() -> Result { + CONFIG + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + .ok_or_else(|| VenueError::Unavailable("adapter not initialised".to_owned())) +} + +// ── intent functions, transport-injected for host-free tests ───────── + +/// Decode the versioned wire body into its single published intent sum. +fn decode(body: &[u8]) -> Result { + let CowIntentBody::V1(intent) = CowIntentBody::from_bytes(body)?; + Ok(intent) +} + +/// Pure header derivation for `chain`: gives the sell side, wants the +/// buy side, authorisation by intent kind (a signed order carries its +/// EIP-1271 signature; an unsigned order is authorised by host-held +/// keys through the pre-sign flow). +pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result { + let intent = decode(body)?; + let (order, authorisation) = match &intent { + CowIntent::Order(order) => (order, AuthScheme::Eip712), + CowIntent::Signed(signed) => (&signed.order, AuthScheme::Eip1271), + }; + Ok(IntentHeader { + gives: erc20(order.sell_token, minimal_be(&order.sell_amount)), + wants: erc20(order.buy_token, minimal_be(&order.buy_amount)), + settlement: Settlement { chain }, + authorisation, + }) +} + +/// Submit one intent to the orderbook. A signed order posts EIP-1271 +/// and its receipt is the canonical 56-byte UID; an unsigned order +/// posts pre-sign and success is `requires-signing` carrying the +/// `setPreSignature` call. Either way an already-held rejection is +/// success wearing an error status: the UID is derived client-side, so +/// the outcome is identical to a fresh accept. +pub(crate) fn submit_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + match decode(body)? { + CowIntent::Signed(signed) => { + let order = assembly::body_to_order_data(&signed.order); + let owner = Address::from(signed.owner); + let creation = assembly::build_order_creation(&order, &signed.signature, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => uid, + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::Accepted(uid.as_slice().to_vec())) + } + CowIntent::Order(wire) => { + // Pre-sign needs an owner for `from` and the on-chain call; + // an unconfigured deployment refuses rather than guesses. + let owner = config.owner.ok_or(VenueError::Unsupported)?; + let order = assembly::body_to_order_data(&wire); + let creation = assembly::build_presign_creation(&order, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => uid, + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: config.chain.id(), + to: config.chain.settlement().as_slice().to_vec(), + value: Vec::new(), + data: assembly::set_pre_signature_calldata(&uid), + })) + } + } +} + +/// Poll one receipt's orderbook lifecycle state. +pub(crate) fn status_with( + fetch: &impl Fetch, + config: &AdapterConfig, + receipt: &[u8], +) -> Result { + let uid = OrderUid::try_from(receipt) + .map_err(|_| VenueError::InvalidBody("receipt is not a 56-byte order uid".to_owned()))?; + let url = join(config, &format!("api/v1/orders/{uid}"))?; + let response = call(fetch, http::Method::GET, url, None)?; + if response.status() == http::StatusCode::NOT_FOUND { + // A just-accepted order can lag the read path; not-found stays + // retryable rather than killing the watch. + return Err(VenueError::Unavailable("order not found".to_owned())); + } + if !response.status().is_success() { + return Err(refusal(&response).into_error()); + } + /// The one server field the lifecycle projection reads. + #[derive(Deserialize)] + struct OrderStatusView { + status: OrderStatus, + } + let view: OrderStatusView = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("order decode failed: {e}")))?; + Ok(match view.status { + OrderStatus::PresignaturePending => IntentStatus::Pending, + OrderStatus::Open => IntentStatus::Open, + OrderStatus::Fulfilled => IntentStatus::Fulfilled, + OrderStatus::Cancelled => IntentStatus::Cancelled, + OrderStatus::Expired => IntentStatus::Expired, + }) +} + +/// Price one intent body: an indicative orderbook quote. +pub(crate) fn quote_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + let intent = decode(body)?; + let (wire, from) = match &intent { + CowIntent::Order(order) => (order, config.owner.ok_or(VenueError::Unsupported)?), + CowIntent::Signed(signed) => (&signed.order, Address::from(signed.owner)), + }; + let order = assembly::body_to_order_data(wire); + let request = serde_json::to_vec("e_request(&order, from)) + .map_err(|e| VenueError::Unavailable(format!("quote encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/quote")?, + Some(request), + )?; + if !response.status().is_success() { + return Err(refusal(&response).into_error()); + } + let quoted: cowprotocol::OrderQuoteResponse = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("quote decode failed: {e}")))?; + Ok(Quotation { + gives: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.sell_amount), + ), + wants: erc20( + order.buy_token.into_array(), + minimal_be_u256(quoted.quote.buy_amount), + ), + fee: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.fee_amount), + ), + valid_until_ms: u64::from(quoted.quote.valid_to).saturating_mul(1000), + }) +} + +/// The quote request pinned to the body's own terms, so the indicative +/// price answers for exactly the order the keeper would place. +fn quote_request(order: &OrderData, from: Address) -> QuoteRequest { + let mut request = match order.kind { + OrderKind::Sell => QuoteRequest::sell_before_fee( + order.sell_token, + order.buy_token, + from, + order.sell_amount, + ), + OrderKind::Buy => { + QuoteRequest::buy_after_fee(order.sell_token, order.buy_token, from, order.buy_amount) + } + }; + request.receiver = order.receiver; + request.valid_to = Some(order.valid_to); + request.app_data = Some(QuoteAppData::Hash(order.app_data)); + request.partially_fillable = Some(order.partially_fillable); + request.sell_token_balance = Some(order.sell_token_balance); + request.buy_token_balance = Some(order.buy_token_balance); + request +} + +// ── orderbook wire plumbing ────────────────────────────────────────── + +/// What a `POST /api/v1/orders` produced. +enum Posted { + /// The orderbook accepted and assigned this UID. + Accepted(cowprotocol::OrderUid), + /// The orderbook already holds this exact order. + AlreadyHeld, +} + +fn post_order( + fetch: &impl Fetch, + config: &AdapterConfig, + creation: &OrderCreation, +) -> Result { + let body = serde_json::to_vec(creation) + .map_err(|e| VenueError::Unavailable(format!("order encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/orders")?, + Some(body), + )?; + if response.status().is_success() { + let uid: cowprotocol::OrderUid = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("uid decode failed: {e}")))?; + return Ok(Posted::Accepted(uid)); + } + match refusal(&response) { + Refusal::AlreadyHeld => Ok(Posted::AlreadyHeld), + Refusal::Error(err) => Err(err), + } +} + +fn join(config: &AdapterConfig, path: &str) -> Result { + config + .base + .join(path) + .map_err(|e| VenueError::Unavailable(format!("orderbook url: {e}"))) +} + +/// One bounded request; every transport failure arrives as a typed +/// [`VenueError`] through the fetch conversion. +fn call( + fetch: &impl Fetch, + method: http::Method, + url: Url, + json: Option>, +) -> Result>, VenueError> { + let mut builder = http::Request::builder().method(method).uri(url.as_str()); + if json.is_some() { + builder = builder.header(http::header::CONTENT_TYPE, "application/json"); + } + let request = builder + .body(json.unwrap_or_default()) + .map_err(|e| VenueError::Unavailable(format!("request build failed: {e}")))?; + Ok(fetch.fetch(request)?) +} + +/// A non-2xx orderbook reply, projected for the wire. +enum Refusal { + /// The structured already-held rejection: success wearing an error + /// status. + AlreadyHeld, + /// Everything else, as the `venue-error` the caller reports. + Error(VenueError), +} + +impl Refusal { + /// Collapse for call sites where already-held is not a success + /// shape (reads); the orderbook only emits it on submission. + fn into_error(self) -> VenueError { + match self { + Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), + Refusal::Error(err) => err, + } + } +} + +/// Project a non-2xx reply: throttles first, server failures stay +/// retryable, and only a structured 4xx envelope reaches the +/// classification table. +fn refusal(response: &http::Response>) -> Refusal { + let status = response.status(); + if status == http::StatusCode::TOO_MANY_REQUESTS { + return Refusal::Error(VenueError::RateLimited(RateLimit { + retry_after_ms: retry_after_ms(response), + })); + } + if status.is_server_error() { + return Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))); + } + match serde_json::from_slice::(response.body()) { + Ok(api) if classification::is_already_submitted(&api.error_type) => Refusal::AlreadyHeld, + Ok(api) => Refusal::Error(classified(&api)), + Err(_) => Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))), + } +} + +/// `Retry-After` in milliseconds, when the reply carries the +/// delta-seconds form. +fn retry_after_ms(response: &http::Response>) -> Option { + response + .headers() + .get(http::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .map(|seconds| seconds.saturating_mul(1000)) +} + +/// Fold a structured rejection through the shipped table: transient +/// rows retry as `unavailable`, throttle rows carry their backoff as +/// `rate-limited`, permanent rows (and any future action) are `denied`. +fn classified(api: &ApiError) -> VenueError { + let detail = format!("{}: {}", api.error_type, api.description); + match classification::classify(&api.error_type) { + RetryAction::TryNextBlock => VenueError::Unavailable(detail), + RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit { + retry_after_ms: Some(seconds.saturating_mul(1000)), + }), + RetryAction::Drop => VenueError::Denied(detail), + _ => VenueError::Denied(detail), + } +} + +// ── value projections ──────────────────────────────────────────────── + +/// Big-endian bytes with leading zeros trimmed: the minimal `uint` +/// spelling, where an empty list is zero. +fn minimal_be(bytes: &[u8; 32]) -> Vec { + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + +fn minimal_be_u256(value: U256) -> Vec { + minimal_be(&value.to_be_bytes::<32>()) +} + +fn erc20(token: [u8; 20], amount: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Erc20(Erc20 { + token: token.to_vec(), + }), + amount, + } +} + +// The component-ABI export glue only exists on the wasm build; the +// native build keeps the same trait impl (for conformance suites) +// without export symbols no native linker accepts. +#[cfg(not(target_arch = "wasm32"))] +use wit_bindgen as _; + +/// The component face: `#[videre_sdk::venue]` derives the world from +/// `module.toml` and wires the trait through it. The real transport is +/// wasi:http behind the configured +/// [`TimedFetch`](videre_sdk::transport::TimedFetch) bound. +mod export { + use videre_sdk::VenueAdapter; + use videre_sdk::transport::TimedFetch; + use videre_sdk::transport::http::WasiFetch; + #[cfg(not(target_arch = "wasm32"))] + use videre_sdk::{Config, Fault}; + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; + + use super::{AdapterConfig, CowAdapter}; + + #[cfg_attr(target_arch = "wasm32", videre_sdk::venue)] + impl VenueAdapter for CowAdapter { + fn init(config: Config) -> Result<(), Fault> { + AdapterConfig::parse(&config).map(super::store_config) + } + + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + + fn derive_header(body: Vec) -> Result { + super::derive_header_with(super::config()?.chain.id(), &body) + } + + fn quote(body: Vec) -> Result { + let config = super::config()?; + super::quote_with(&TimedFetch::new(WasiFetch, config.timeout), &config, &body) + } + + fn submit(body: Vec) -> Result { + let config = super::config()?; + super::submit_with(&TimedFetch::new(WasiFetch, config.timeout), &config, &body) + } + + fn status(receipt: Vec) -> Result { + let config = super::config()?; + super::status_with( + &TimedFetch::new(WasiFetch, config.timeout), + &config, + &receipt, + ) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + // Off-chain cancellation is an owner-signed request; the + // adapter structurally holds no keys. + Err(VenueError::Unsupported) + } + } +} + +#[cfg(test)] +mod tests { + use videre_sdk::IntentBody as _; + use videre_sdk::transport::TimedFetch; + use videre_sdk::transport::http::FetchError; + use videre_test::MockFetch; + + use super::*; + use crate::body::{CowIntent, CowIntentBody}; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + const SEPOLIA: u64 = 11_155_111; + const ORDERS: &str = "https://orderbook.test/api/v1/orders"; + const QUOTE: &str = "https://orderbook.test/api/v1/quote"; + + fn config() -> AdapterConfig { + AdapterConfig { + chain: Chain::try_from(SEPOLIA).expect("sepolia is supported"), + base: Url::parse("https://orderbook.test/").expect("test url parses"), + owner: None, + timeout: Duration::from_secs(5), + } + } + + fn with_owner(owner: Address) -> AdapterConfig { + AdapterConfig { + owner: Some(owner), + ..config() + } + } + + fn owner() -> Address { + Address::repeat_byte(0x55) + } + + fn order_body() -> OrderBody { + OrderBody::sell(SellToken([0x11; 20]), amount(42)) + .for_at_least(BuyToken([0x22; 20]), amount(41)) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .build() + } + + fn amount(value: u8) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[31] = value; + bytes + } + + fn signed_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: owner().into_array(), + signature: vec![0xC0, 0xFF, 0xEE], + })) + .to_bytes() + .expect("body encodes") + } + + fn order_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .expect("body encodes") + } + + fn expected_uid(config: &AdapterConfig) -> cowprotocol::OrderUid { + let order = assembly::body_to_order_data(&order_body()); + assembly::order_uid(config.chain, &order, owner()) + } + + fn reject(fetch: &MockFetch, error_type: &str) { + fetch.respond_to( + http::Method::POST, + ORDERS, + 400, + format!(r#"{{"errorType":"{error_type}","description":"d"}}"#), + ); + } + + // ── config ─────────────────────────────────────────────────────── + + #[test] + fn config_defaults_resolve_from_the_chain() { + let pairs = [("chain".to_owned(), "1".to_owned())]; + let parsed = AdapterConfig::parse(&pairs).expect("chain alone suffices"); + assert_eq!(parsed.chain.id(), 1); + assert_eq!(parsed.base.as_str(), "https://api.cow.fi/mainnet/"); + assert_eq!(parsed.owner, None); + assert_eq!(parsed.timeout, Duration::from_millis(DEFAULT_TIMEOUT_MS)); + } + + #[test] + fn config_overrides_parse_and_the_base_gains_its_slash() { + let pairs = [ + ("chain".to_owned(), SEPOLIA.to_string()), + ( + "orderbook-url".to_owned(), + "https://barn.test/sepolia".to_owned(), + ), + ("owner".to_owned(), format!("{:#x}", owner())), + ("http-timeout-ms".to_owned(), "1500".to_owned()), + ("name".to_owned(), "cow".to_owned()), + ]; + let parsed = AdapterConfig::parse(&pairs).expect("overrides parse"); + assert_eq!(parsed.base.as_str(), "https://barn.test/sepolia/"); + assert_eq!(parsed.owner, Some(owner())); + assert_eq!(parsed.timeout, Duration::from_millis(1500)); + } + + #[test] + fn config_refuses_a_missing_or_malformed_chain() { + assert!(matches!( + AdapterConfig::parse(&[]), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + for bad in ["x", "0"] { + let pairs = [("chain".to_owned(), bad.to_owned())]; + assert!(matches!( + AdapterConfig::parse(&pairs), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + } + } + + // ── derive-header ──────────────────────────────────────────────── + + #[test] + fn header_projects_sides_minimally_and_auth_by_kind() { + let header = derive_header_with(SEPOLIA, &signed_bytes()).expect("valid body"); + assert_eq!( + header.gives.asset, + Asset::Erc20(Erc20 { + token: vec![0x11; 20] + }) + ); + assert_eq!(header.gives.amount, vec![42]); + assert_eq!( + header.wants.asset, + Asset::Erc20(Erc20 { + token: vec![0x22; 20] + }) + ); + assert_eq!(header.wants.amount, vec![41]); + assert_eq!(header.settlement.chain, SEPOLIA); + assert!(matches!(header.authorisation, AuthScheme::Eip1271)); + + let presign = derive_header_with(SEPOLIA, &order_bytes()).expect("valid body"); + assert!(matches!(presign.authorisation, AuthScheme::Eip712)); + } + + #[test] + fn header_refuses_a_malformed_body() { + assert!(matches!( + derive_header_with(SEPOLIA, &[9, 9, 9]), + Err(VenueError::InvalidBody(_)) + )); + } + + // ── submit ─────────────────────────────────────────────────────── + + #[test] + fn signed_submit_posts_eip1271_and_returns_the_uid_receipt() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("accepted"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("signed submit must accept"); + }; + assert_eq!(receipt, uid.as_slice()); + + let request = fetch.last_request().expect("one request"); + assert_eq!(request.uri, ORDERS); + let posted: serde_json::Value = serde_json::from_slice(&request.body).expect("posted JSON"); + assert_eq!(posted["signingScheme"], "eip1271"); + assert_eq!( + posted["from"].as_str().map(str::to_lowercase), + Some(format!("{:#x}", owner())), + ); + assert_eq!(posted["appData"], format!("0x{}", "44".repeat(32))); + } + + #[test] + fn already_held_is_success_with_the_derived_uid() { + let config = config(); + let fetch = MockFetch::default(); + reject(&fetch, "DuplicatedOrder"); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("held is success"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("already-held must accept"); + }; + assert_eq!(receipt, expected_uid(&config).as_slice()); + } + + #[test] + fn unsigned_submit_requires_signing_the_presign_call() { + let config = with_owner(owner()); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &order_bytes()).expect("accepted"); + let SubmitOutcome::RequiresSigning(tx) = outcome else { + panic!("unsigned submit must require signing"); + }; + assert_eq!(tx.chain, SEPOLIA); + assert_eq!(tx.to, config.chain.settlement().as_slice()); + assert!(tx.value.is_empty()); + assert_eq!(tx.data, assembly::set_pre_signature_calldata(&uid)); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["signingScheme"], "presign"); + } + + #[test] + fn unsigned_submit_without_an_owner_is_unsupported() { + let fetch = MockFetch::default(); + assert!(matches!( + submit_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } + + #[test] + fn rejections_project_through_the_classification_table() { + let config = config(); + let fetch = MockFetch::default(); + + reject(&fetch, "InvalidSignature"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(detail)) if detail.contains("InvalidSignature") + )); + + reject(&fetch, "TooManyLimitOrders"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms == Some(30_000) + )); + + reject(&fetch, "InsufficientFee"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(detail)) if detail.contains("InsufficientFee") + )); + } + + #[test] + fn transport_shapes_stay_typed() { + let config = config(); + let fetch = MockFetch::default(); + + fetch.respond_to(http::Method::POST, ORDERS, 429, "slow down"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms.is_none() + )); + + fetch.respond_to(http::Method::POST, ORDERS, 503, "maintenance"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(_)) + )); + + fetch.fail_with( + http::Method::POST, + ORDERS, + FetchError::Timeout("first byte".to_owned()), + ); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Timeout) + )); + + fetch.fail_with(http::Method::POST, ORDERS, FetchError::Denied); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(_)) + )); + } + + #[test] + fn requests_ride_the_configured_timeout_bound() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let timed = TimedFetch::new(&fetch, config.timeout); + submit_with(&timed, &config, &signed_bytes()).expect("accepted"); + let options = fetch.last_request().expect("one request").options; + assert_eq!(options.connect_timeout, config.timeout); + assert_eq!(options.first_byte_timeout, config.timeout); + assert_eq!(options.between_bytes_timeout, config.timeout); + } + + #[test] + fn retry_after_header_survives_as_milliseconds() { + let response = http::Response::builder() + .status(429) + .header(http::header::RETRY_AFTER, "7") + .body(Vec::new()) + .expect("test response builds"); + let Refusal::Error(VenueError::RateLimited(rl)) = refusal(&response) else { + panic!("429 must rate-limit"); + }; + assert_eq!(rl.retry_after_ms, Some(7_000)); + } + + // ── status ─────────────────────────────────────────────────────── + + fn status_url(uid: &OrderUid) -> String { + format!("https://orderbook.test/api/v1/orders/{uid}") + } + + #[test] + fn status_maps_the_orderbook_lifecycle() { + let config = config(); + let uid = OrderUid([0xAB; 56]); + let fetch = MockFetch::default(); + for (wire, status) in [ + ("presignaturePending", IntentStatus::Pending), + ("open", IntentStatus::Open), + ("fulfilled", IntentStatus::Fulfilled), + ("cancelled", IntentStatus::Cancelled), + ("expired", IntentStatus::Expired), + ] { + fetch.respond_to( + http::Method::GET, + status_url(&uid), + 200, + format!(r#"{{"status":"{wire}","uid":"{uid}"}}"#), + ); + assert_eq!( + status_with(&fetch, &config, uid.as_bytes()).expect("known status"), + status, + ); + } + } + + #[test] + fn status_refuses_a_short_receipt_and_retries_not_found() { + let config = config(); + let fetch = MockFetch::default(); + assert!(matches!( + status_with(&fetch, &config, &[0xAB; 3]), + Err(VenueError::InvalidBody(_)) + )); + + let uid = OrderUid([0xAB; 56]); + fetch.respond_to(http::Method::GET, status_url(&uid), 404, "not found"); + assert!(matches!( + status_with(&fetch, &config, uid.as_bytes()), + Err(VenueError::Unavailable(_)) + )); + } + + // ── quote ──────────────────────────────────────────────────────── + + #[test] + fn quote_prices_the_body_and_pins_its_terms() { + let config = config(); + let fetch = MockFetch::default(); + let quote = serde_json::json!({ + "quote": { + "sellToken": format!("0x{}", "11".repeat(20)), + "buyToken": format!("0x{}", "22".repeat(20)), + "receiver": null, + "sellAmount": "42", + "buyAmount": "40", + "validTo": 1_700_000_000u32, + "appData": format!("0x{}", "44".repeat(32)), + "feeAmount": "2", + "kind": "sell", + "partiallyFillable": false, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "signingScheme": "eip1271", + }, + "from": format!("{:#x}", owner()), + "expiration": "2026-01-01T00:00:00Z", + "id": 7, + "verified": true, + }); + fetch.respond_to(http::Method::POST, QUOTE, 200, quote.to_string()); + + let quotation = quote_with(&fetch, &config, &signed_bytes()).expect("quoted"); + assert_eq!(quotation.gives.amount, vec![42]); + assert_eq!(quotation.wants.amount, vec![40]); + assert_eq!(quotation.fee.amount, vec![2]); + assert_eq!(quotation.valid_until_ms, 1_700_000_000_000); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["kind"], "sell"); + assert_eq!(posted["sellAmountBeforeFee"], "42"); + assert_eq!(posted["validTo"], 1_700_000_000u32); + } + + #[test] + fn quote_for_an_unsigned_body_needs_the_configured_owner() { + let fetch = MockFetch::default(); + assert!(matches!( + quote_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } +} diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/cow-venue/src/assembly.rs similarity index 52% rename from crates/shepherd-sdk/src/cow/order.rs rename to crates/cow-venue/src/assembly.rs index 79431954..6e04db1c 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/cow-venue/src/assembly.rs @@ -1,16 +1,26 @@ -//! `GPv2OrderData` -> `OrderData` bridging. +//! Chain-edge order assembly: the projections between the on-chain +//! `GPv2OrderData` tuple, the typed `OrderData`, and the venue wire +//! [`OrderBody`], plus the orderbook submission bodies built from them. //! -//! ComposableCoW and CoWSwapEthFlow both emit / return the 12-field -//! `GPv2OrderData` Solidity tuple, with `kind` / `sellTokenBalance` / -//! `buyTokenBalance` as 32-byte keccak markers. The orderbook signs -//! against the typed `OrderData` shape, with those markers projected -//! into Rust enums. [`gpv2_to_order_data`] is the bridge. +//! This is the venue's protocol knowledge: `kind` / +//! `sellTokenBalance` / `buyTokenBalance` ride the chain as 32-byte +//! keccak markers and the orderbook signs against the typed shape, so +//! the adapter, not the keeper, owns every projection across that +//! edge. -use alloy_primitives::Address; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolCall; use cowprotocol::{ - BuyTokenDestination, Chain, GPv2OrderData, OrderData, OrderKind, SellTokenSource, + BuyTokenDestination, Chain, GPv2OrderData, GPv2Settlement, OrderCreation, OrderData, OrderKind, + SellTokenSource, Signature, }; +use crate::order::OrderBody; + /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the /// typed [`OrderData`] shape `OrderCreation::new` expects. /// @@ -29,11 +39,11 @@ use cowprotocol::{ /// # Example /// /// ``` +/// use cow_venue::assembly::gpv2_to_order_data; /// use cowprotocol::{ /// BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource, /// }; -/// use shepherd_sdk::cow::gpv2_to_order_data; -/// use nexum_sdk::prelude::{Address, U256}; +/// use alloy_primitives::{Address, U256}; /// /// let gpv2 = GPv2OrderData { /// sellToken: Address::repeat_byte(1), @@ -87,17 +97,22 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { #[must_use] pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); let order_data = gpv2_to_order_data(order)?; - Some(format!("{}", order_data.uid(&domain, owner))) + Some(format!("{}", order_uid(chain, &order_data, owner))) +} + +/// The canonical 56-byte orderbook UID for `order` under `chain`'s +/// settlement domain: what an accepted submit's receipt carries. +#[must_use] +pub fn order_uid(chain: Chain, order: &OrderData, owner: Address) -> cowprotocol::OrderUid { + order.uid(&chain.settlement_domain(), owner) } -/// Project a typed [`OrderData`] into the venue wire -/// [`OrderBody`](cow_venue::OrderBody) a keeper emits. Total: every -/// typed field has exactly one wire form. +/// Project a typed [`OrderData`] into the venue wire [`OrderBody`] a +/// keeper emits. Total: every typed field has exactly one wire form. #[must_use] -pub fn order_data_to_body(order: &OrderData) -> cow_venue::OrderBody { - cow_venue::OrderBody { +pub fn order_data_to_body(order: &OrderData) -> OrderBody { + OrderBody { sell_token: order.sell_token.into_array(), buy_token: order.buy_token.into_array(), receiver: order.receiver.map(Address::into_array), @@ -107,26 +122,97 @@ pub fn order_data_to_body(order: &OrderData) -> cow_venue::OrderBody { app_data: order.app_data.0, fee_amount: order.fee_amount.to_be_bytes(), kind: match order.kind { - OrderKind::Sell => cow_venue::OrderKind::Sell, - OrderKind::Buy => cow_venue::OrderKind::Buy, + OrderKind::Sell => crate::order::OrderKind::Sell, + OrderKind::Buy => crate::order::OrderKind::Buy, }, partially_fillable: order.partially_fillable, sell_token_balance: match order.sell_token_balance { - SellTokenSource::Erc20 => cow_venue::SellTokenSource::Erc20, - SellTokenSource::External => cow_venue::SellTokenSource::External, - SellTokenSource::Internal => cow_venue::SellTokenSource::Internal, + SellTokenSource::Erc20 => crate::order::SellTokenSource::Erc20, + SellTokenSource::External => crate::order::SellTokenSource::External, + SellTokenSource::Internal => crate::order::SellTokenSource::Internal, }, buy_token_balance: match order.buy_token_balance { - BuyTokenDestination::Erc20 => cow_venue::BuyTokenDestination::Erc20, - BuyTokenDestination::Internal => cow_venue::BuyTokenDestination::Internal, + BuyTokenDestination::Erc20 => crate::order::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => crate::order::BuyTokenDestination::Internal, + }, + } +} + +/// Project a venue wire [`OrderBody`] back onto the typed [`OrderData`] +/// the orderbook signs against: [`order_data_to_body`]'s total inverse. +#[must_use] +pub fn body_to_order_data(body: &OrderBody) -> OrderData { + OrderData { + sell_token: Address::from(body.sell_token), + buy_token: Address::from(body.buy_token), + receiver: body.receiver.map(Address::from), + sell_amount: alloy_primitives::U256::from_be_bytes(body.sell_amount), + buy_amount: alloy_primitives::U256::from_be_bytes(body.buy_amount), + valid_to: body.valid_to, + app_data: body.app_data.into(), + fee_amount: alloy_primitives::U256::from_be_bytes(body.fee_amount), + kind: match body.kind { + crate::order::OrderKind::Sell => OrderKind::Sell, + crate::order::OrderKind::Buy => OrderKind::Buy, + }, + partially_fillable: body.partially_fillable, + sell_token_balance: match body.sell_token_balance { + crate::order::SellTokenSource::Erc20 => SellTokenSource::Erc20, + crate::order::SellTokenSource::External => SellTokenSource::External, + crate::order::SellTokenSource::Internal => SellTokenSource::Internal, + }, + buy_token_balance: match body.buy_token_balance { + crate::order::BuyTokenDestination::Erc20 => BuyTokenDestination::Erc20, + crate::order::BuyTokenDestination::Internal => BuyTokenDestination::Internal, }, } } +/// Assemble the `OrderCreation` body the orderbook expects from a +/// polled conditional order. The signed `appData` digest goes out +/// verbatim in the hash-only wire shape (watch-tower parity), and the +/// signature is EIP-1271 - the conditional-order contract is the +/// verifier. +/// +/// An `Err` is a client-side precondition failure that would recur on +/// every retry of the same payload; the caller drops the watch. +pub fn build_order_creation( + order_data: &OrderData, + signature: &[u8], + from: Address, +) -> Result { + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(order_data, signature, from, None) +} + +/// Assemble the pre-sign `OrderCreation` for an unsigned order: the +/// orderbook holds it as signature-pending until `from` settles the +/// authorisation on chain via [`set_pre_signature_calldata`]. +pub fn build_presign_creation( + order_data: &OrderData, + from: Address, +) -> Result { + OrderCreation::new_app_data_hash_only(order_data, Signature::PreSign, from, None) +} + +/// ABI-encoded `GPv2Settlement::setPreSignature(uid, true)` calldata: +/// the call the order's owner must sign and send to activate a +/// pre-sign order. +#[must_use] +pub fn set_pre_signature_calldata(uid: &cowprotocol::OrderUid) -> Vec { + GPv2Settlement::setPreSignatureCall { + orderUid: Bytes::copy_from_slice(uid.as_slice()), + signed: true, + } + .abi_encode() +} + #[cfg(test)] mod tests { + use alloy_primitives::{B256, U256, address, keccak256}; + use cowprotocol::GPV2_SETTLEMENT; + use super::*; - use alloy_primitives::{B256, U256, address}; fn submittable_gpv2() -> GPv2OrderData { GPv2OrderData { @@ -190,7 +276,7 @@ mod tests { assert!(gpv2_to_order_data(&g).is_none()); } - // ---- order_data_to_body ---- + // ---- order_data_to_body / body_to_order_data ---- #[test] fn order_data_to_body_projects_every_field() { @@ -205,15 +291,44 @@ mod tests { assert_eq!(body.valid_to, g.validTo); assert_eq!(body.app_data, g.appData.0); assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); - assert_eq!(body.kind, cow_venue::OrderKind::Sell); + assert_eq!(body.kind, crate::order::OrderKind::Sell); assert!(!body.partially_fillable); - assert_eq!(body.sell_token_balance, cow_venue::SellTokenSource::Erc20); + assert_eq!( + body.sell_token_balance, + crate::order::SellTokenSource::Erc20 + ); assert_eq!( body.buy_token_balance, - cow_venue::BuyTokenDestination::Erc20 + crate::order::BuyTokenDestination::Erc20 ); } + #[test] + fn body_round_trips_back_to_order_data() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + assert_eq!(body_to_order_data(&order_data_to_body(&order)), order); + + for (kind, sell, buy) in [ + ( + OrderKind::Buy, + SellTokenSource::External, + BuyTokenDestination::Internal, + ), + ( + OrderKind::Sell, + SellTokenSource::Internal, + BuyTokenDestination::Erc20, + ), + ] { + let mut varied = order; + varied.kind = kind; + varied.sell_token_balance = sell; + varied.buy_token_balance = buy; + varied.receiver = None; + assert_eq!(body_to_order_data(&order_data_to_body(&varied)), varied); + } + } + // ---- order_uid_hex ---- const SEPOLIA: u64 = 11_155_111; @@ -243,4 +358,29 @@ mod tests { bad.kind = B256::repeat_byte(0x42); assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); } + + // ---- submission bodies ---- + + #[test] + fn presign_creation_carries_the_presign_scheme() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let creation = build_presign_creation(&order, owner).expect("valid order"); + assert_eq!(creation.signature, Signature::PreSign); + assert_eq!(creation.from, owner); + + assert!(build_presign_creation(&order, Address::ZERO).is_err()); + } + + #[test] + fn set_pre_signature_calldata_encodes_the_selector_and_uid() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid(Chain::Mainnet, &order, owner); + let data = set_pre_signature_calldata(&uid); + assert_eq!(&data[..4], &keccak256("setPreSignature(bytes,bool)")[..4]); + assert!(data.windows(56).any(|w| w == uid.as_slice())); + // The call targets the deterministic settlement deployment. + assert_ne!(GPV2_SETTLEMENT, Address::ZERO); + } } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 4e0879ba..82ff5aaa 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -10,12 +10,12 @@ //! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) //! and borsh, so a venue adapter component or a strategy module can carry //! the body types and codec without dragging in the host-side CoW -//! machinery. The crate is `#![no_std]` (tests aside): the derive's -//! generated code reaches `alloc` through the venue SDK re-export, never -//! `::std`. +//! machinery. The crate is `#![no_std]` (tests and the `adapter` slice +//! aside): the derive's generated code reaches `alloc` through the +//! venue SDK re-export, never `::std`. //! //! With `--no-default-features` the slice drops out entirely and the -//! crate compiles empty, so a consumer can depend on a future slice +//! crate compiles empty, so a consumer can depend on a single slice //! without pulling the codec transitively. //! //! The `client` slice layers on top: a typed [`CowClient`] bound to the @@ -26,10 +26,20 @@ //! strategy keeper (for the retry action type) and is off by default, //! so an adapter or a module that wants only the body types stays //! dependency-light. +//! +//! The `assembly` slice carries the chain-edge order projections and +//! orderbook submission bodies; the `adapter` slice on top of it is +//! the venue-adapter component itself ([`CowAdapter`] under +//! `#[videre_sdk::venue]`), built as the cdylib for wasm32-wasip2 and +//! never linked by a keeper module (linking it would export the +//! adapter face). -#![cfg_attr(not(test), no_std)] +#![cfg_attr(not(any(test, feature = "adapter")), no_std)] #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] +// wit_bindgen::generate! expands to host-import shims whose arity can +// exceed clippy's too-many-arguments threshold. +#![cfg_attr(feature = "adapter", allow(clippy::too_many_arguments))] #[cfg(feature = "body")] extern crate alloc; @@ -40,6 +50,12 @@ pub mod body; #[cfg(feature = "body")] pub mod order; +#[cfg(feature = "adapter")] +pub mod adapter; + +#[cfg(feature = "assembly")] +pub mod assembly; + #[cfg(feature = "client")] pub mod classification; @@ -61,6 +77,9 @@ pub use order::{ SellTokenSource, SignedOrder, }; +#[cfg(feature = "adapter")] +pub use adapter::CowAdapter; + #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; #[cfg(feature = "client")] diff --git a/crates/cow-venue/tests/conformance.rs b/crates/cow-venue/tests/conformance.rs new file mode 100644 index 00000000..4c176300 --- /dev/null +++ b/crates/cow-venue/tests/conformance.rs @@ -0,0 +1,29 @@ +//! Published-fixture conformance: the codec vectors and header goldens +//! under `tests/vectors/` replayed through the shipped body codec and +//! the adapter's own derivation. The files are the contract a non-Rust +//! adapter author reads. + +use cow_venue::{CowAdapter, CowIntentBody}; +use videre_sdk::VenueAdapter; +use videre_test::{CodecVectors, HeaderGoldens}; + +fn fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/vectors") + .join(name) +} + +#[test] +fn codec_conforms_to_the_published_vectors() { + let vectors = CodecVectors::load(fixture("cow-intent-body.json")).expect("vectors parse"); + vectors.assert_conforms::(); +} + +#[test] +fn derive_header_conforms_to_the_published_goldens() { + // The goldens pin mainnet; init drives the same configured path + // the host boots the component through. + CowAdapter::init(vec![("chain".to_owned(), "1".to_owned())]).expect("config parses"); + let goldens = HeaderGoldens::load(fixture("cow-header-goldens.json")).expect("goldens parse"); + goldens.assert_conforms(CowAdapter::derive_header); +} diff --git a/crates/cow-venue/tests/vectors/cow-header-goldens.json b/crates/cow-venue/tests/vectors/cow-header-goldens.json new file mode 100644 index 00000000..d482f6a2 --- /dev/null +++ b/crates/cow-venue/tests/vectors/cow-header-goldens.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "venue": "cow", + "goldens": [ + { + "name": "v1-order-presign", + "body": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "unsigned order: authorised by host-held keys (pre-sign)" + }, + { + "name": "v1-signed", + "body": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip1271" + }, + "notes": "owner-signed order: EIP-1271" + } + ] +} diff --git a/crates/cow-venue/tests/vectors/cow-intent-body.json b/crates/cow-venue/tests/vectors/cow-intent-body.json new file mode 100644 index 00000000..651231e7 --- /dev/null +++ b/crates/cow-venue/tests/vectors/cow-intent-body.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "schema": "cow-venue/cow-intent-body", + "vectors": [ + { + "name": "v1-order", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": "round-trip" + }, + { + "name": "v1-signed", + "bytes": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "expect": "round-trip" + }, + { + "name": "unknown-version", + "bytes": "09001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "unknown-version": { + "version": 9 + } + } + }, + { + "name": "empty", + "bytes": "", + "expect": "empty" + }, + { + "name": "truncated-payload", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f1536544444444444444444444444444444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + }, + { + "name": "trailing-bytes", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f15365444444444444444444444444444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + } + ] +} diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index e4a6dec0..4e35b146 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -96,6 +96,17 @@ pub trait Fetch { } } +/// A shared reference forwards, so middleware can borrow its transport. +impl Fetch for &F { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + (**self).fetch_with(request, options) + } +} + /// [`Fetch`] adapter over the host's wasi:http outgoing handler. /// /// Guest-only glue: the type exists on every target so module diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index a6cc1049..b73fae63 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -16,8 +16,9 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or # cow-venue default slice; this crate re-exports them under `cow` until # the module ports move off the legacy path. The `client` slice carries # the table-driven retry classification the cow error surface delegates -# to. -cow-venue = { path = "../cow-venue", features = ["client"] } +# to; the `assembly` slice carries the chain-edge order projections the +# keeper run still drives. +cow-venue = { path = "../cow-venue", features = ["client", "assembly"] } # The structured poll seam the keeper run dispatches on. composable-cow = { path = "../composable-cow" } nexum-sdk = { path = "../nexum-sdk" } diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index e733da5d..86ecdf03 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -1,9 +1,9 @@ //! CoW Protocol bridging. //! -//! Type conversions and ABI decoding helpers that translate between -//! the on-chain shape (`GPv2OrderData`, orderbook JSON) and the typed -//! Rust surface (`OrderData`, `RetryAction`), plus [`run()`] - the -//! poll/submit composition over the keeper stores. +//! ABI decoding helpers, the orderbook error surface, and [`run()`] - +//! the poll/submit composition over the keeper stores. The chain-edge +//! order projections live in the `cow-venue` `assembly` slice (the +//! venue adapter owns them) and are re-exported here. //! //! The poll seam is the structured //! [`Verdict`](composable_cow::Verdict), carried by the @@ -18,14 +18,15 @@ pub mod error; pub mod events; -pub mod order; pub mod run; +/// Chain-edge order assembly, re-exported from the `cow-venue` +/// `assembly` slice the venue adapter owns. +pub use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, }; -pub use order::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use run::run; /// The venue-neutral intent body types and their borsh `IntentBody` diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index 05761760..c9e39a00 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -7,7 +7,7 @@ //! watch stores, `Post` drives one submission through the //! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` //! journal as the idempotency guard - keyed on the venue-and-body -//! [`intent_id`](super::intent_id) - and the keeper [`Retrier`] +//! [`intent_id`] - and the keeper [`Retrier`] //! as the failure dispatch. //! //! Store faults abort the sweep (the next tick replays it); @@ -19,7 +19,8 @@ use alloy_primitives::{Address, Bytes}; use composable_cow::Verdict; -use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; +use cow_venue::assembly::build_order_creation; +use cowprotocol::GPv2OrderData; use nexum_sdk::host::Fault; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, @@ -126,7 +127,7 @@ fn submit_ready( tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); return Ok(()); } - let creation = match build_order_creation(&order_data, signature, owner) { + let creation = match build_order_creation(&order_data, &signature, owner) { Ok(creation) => creation, Err(err) => { // A constructor rejection (zero `from`, `validTo` beyond @@ -196,20 +197,3 @@ fn submit_ready( } Ok(()) } - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// polled conditional order. The signed `appData` digest goes out -/// verbatim in the hash-only wire shape (watch-tower parity), and the -/// signature is EIP-1271 - the conditional-order contract is the -/// verifier. -/// -/// An `Err` is a client-side precondition failure that would recur on -/// every retry of the same payload; the caller drops the watch. -fn build_order_creation( - order_data: &OrderData, - signature: Bytes, - from: Address, -) -> Result { - let signature = Signature::Eip1271(signature.to_vec()); - OrderCreation::new_app_data_hash_only(order_data, signature, from, None) -} diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index a9b4d5a2..c300c65f 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -32,7 +32,7 @@ proptest! { // We do not call `gpv2_to_order_data` here because building // a `GPv2OrderData` requires a full alloy-sol-encoded struct // and the generators for that are extensive. The property - // test for the marker dispatch lives in `cow::order::tests` + // test for the marker dispatch lives in `cow_venue::assembly` // example-based; this proptest stands in as a no-panic // guard for the inputs the strategy ABI can produce. } diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 9c44c425..ecc3c79e 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -152,6 +152,47 @@ fn e2e_echo_venue_component_imports_equal_declared_capabilities() { ); } +/// The shipped cow adapter honours the same contract: outbound HTTP is +/// its only capability, so the component structurally cannot reach +/// chain, messaging, host key material, or persistence. +#[test] +fn e2e_cow_venue_component_imports_equal_declared_capabilities() { + let wasm = workspace_path("target/wasm32-wasip2/release/cow_venue.wasm"); + if !wasm.exists() { + eprintln!( + "SKIP: {} not found - build with `just build-cow-venue`", + wasm.display() + ); + return; + } + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["http"]), + "imports were: {imports:?}" + ); + assert!( + imports.iter().all(|name| !name.contains("nexum:host/chain") + && !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + /// The venue-adapter provider linker binds only the scoped transport /// (chain, messaging, wasi base, allowlisted http) and withholds the /// core-only interfaces. Assembling it proves the scope wires without a diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 54701fd4..bbf43928 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -28,6 +28,9 @@ videre-macros = { path = "../videre-macros" } # chain wrapper implements, the shared `Fault` vocabulary, and the # wasi:http `fetch` surface re-exported as `transport::http`. nexum-sdk = { path = "../nexum-sdk" } +# The standard request/response types the `Fetch` seam (and the +# `TimedFetch` middleware over it) is expressed in. +http.workspace = true strum.workspace = true thiserror.workspace = true wit-bindgen.workspace = true diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 29dcd772..8173f7e2 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -28,7 +28,7 @@ //! import and drives async handlers; [`rt`] completes their futures //! on the synchronous guest boundary. //! -//! - [`keeper`] - the generic sweep assembler: [`Keeper::sweep`] runs +//! - [`keeper`](mod@keeper) - the generic sweep assembler: [`Keeper::sweep`] runs //! the world-neutral `nexum_sdk::keeper` stores over a //! [`ConditionalSource`](nexum_sdk::keeper::ConditionalSource) //! producing the shared [`Sweep`] outcome, submitting through the @@ -37,8 +37,10 @@ //! - [`transport`] - typed wrappers over the world's scoped imports: //! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] //! seam (plus batch), [`HostMessaging`](transport::HostMessaging) -//! behind [`MessagingHost`](transport::MessagingHost), and the -//! wasi:http surface re-exported as [`transport::http`]. +//! behind [`MessagingHost`](transport::MessagingHost), the +//! wasi:http surface re-exported as [`transport::http`], and +//! [`TimedFetch`](transport::TimedFetch), the per-request timeout +//! middleware every adapter request should ride. //! //! - [`faults`] - the conversions that make `?` work across the wire //! fault, the SDK-neutral fault, and [`VenueError`]; plus diff --git a/crates/videre-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs index a5d62bd1..f1971e92 100644 --- a/crates/videre-sdk/src/transport.rs +++ b/crates/videre-sdk/src/transport.rs @@ -10,7 +10,10 @@ //! `messaging_topics`, and HTTP to its `http_allow` list, each refusal //! surfacing as a typed `denied`. +use core::time::Duration; + use nexum_sdk::host::{ChainError, ChainHost, Fault, RpcError}; +use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; use crate::bindings::nexum::host::{chain, messaging}; use crate::faults::fault_into_sdk; @@ -18,10 +21,45 @@ use crate::faults::fault_into_sdk; /// Outbound HTTP for adapters: the SDK's wasi:http surface re-exported. /// [`fetch`](nexum_sdk::http::Fetch::fetch) speaks the standard `http` /// crate's request/response types; an off-allowlist request fails as -/// [`FetchError::Denied`](nexum_sdk::http::FetchError::Denied), which +/// [`FetchError::Denied`], which /// converts into [`VenueError`](crate::VenueError) via `?`. pub use nexum_sdk::http; +/// Per-request timeout middleware over any [`Fetch`]: every request +/// through it, including plain [`Fetch::fetch`], carries phase timeouts +/// clamped to the configured bound, so a hung endpoint errors within it +/// rather than stalling the adapter's export call. +#[derive(Clone, Copy, Debug)] +pub struct TimedFetch { + inner: F, + bound: Duration, +} + +impl TimedFetch { + /// Bound every phase (connect, first byte, between bytes) of every + /// request to at most `bound`. + pub const fn new(inner: F, bound: Duration) -> Self { + Self { inner, bound } + } +} + +impl Fetch for TimedFetch { + fn fetch_with( + &self, + request: ::http::Request>, + options: FetchOptions, + ) -> Result<::http::Response>, FetchError> { + self.inner.fetch_with( + request, + FetchOptions { + connect_timeout: options.connect_timeout.min(self.bound), + first_byte_timeout: options.first_byte_timeout.min(self.bound), + between_bytes_timeout: options.between_bytes_timeout.min(self.bound), + }, + ) + } +} + /// The adapter's `nexum:host/chain` import behind the SDK's /// [`ChainHost`] seam. Unit-struct handle: hold it where strategy logic /// takes `&impl ChainHost` and slot a mock in host-side tests. @@ -124,3 +162,74 @@ impl From for Message { } } } + +#[cfg(test)] +mod tests { + use core::cell::Cell; + use core::time::Duration; + + use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; + + use super::TimedFetch; + + struct Spy { + seen: Cell>, + } + + impl Fetch for Spy { + fn fetch_with( + &self, + _request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + self.seen.set(Some(options)); + Ok(http::Response::new(Vec::new())) + } + } + + fn request() -> http::Request> { + http::Request::get("https://api.cow.fi/") + .body(Vec::new()) + .expect("test request builds") + } + + #[test] + fn plain_fetch_is_bounded() { + let bound = Duration::from_secs(5); + let timed = TimedFetch::new( + Spy { + seen: Cell::new(None), + }, + bound, + ); + timed.fetch(request()).expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, bound); + assert_eq!(seen.first_byte_timeout, bound); + assert_eq!(seen.between_bytes_timeout, bound); + } + + #[test] + fn caller_options_clamp_to_the_bound_but_tighter_ones_pass() { + let timed = TimedFetch::new( + Spy { + seen: Cell::new(None), + }, + Duration::from_secs(5), + ); + timed + .fetch_with( + request(), + FetchOptions { + connect_timeout: Duration::from_secs(60), + first_byte_timeout: Duration::from_secs(1), + between_bytes_timeout: Duration::from_secs(60), + }, + ) + .expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, Duration::from_secs(5)); + assert_eq!(seen.first_byte_timeout, Duration::from_secs(1)); + assert_eq!(seen.between_bytes_timeout, Duration::from_secs(5)); + } +} diff --git a/engine.docker.toml b/engine.docker.toml index 72141a15..151f3de8 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -77,3 +77,14 @@ manifest = "/opt/shepherd/manifests/balance-tracker.toml" [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" + +# ---- adapters ---- +# +# The bundled cow venue adapter: the pool router resolves the `cow` +# venue id through it. The operator grant scopes its outbound HTTP to +# the production orderbook. + +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index a3f883bd..77dc3145 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -100,3 +100,16 @@ rpc_url = "${ARBITRUM_RPC_URL}" [chains.8453] # Base rpc_url = "${BASE_RPC_URL}" + +# ---- adapters ---- +# +# Venue-adapter components the pool router resolves venue ids through. +# The operator, not the adapter author, grants the transport scope: +# `http_allow` is the outbound wasi:http host allowlist. The bundled +# cow adapter builds with `just build-cow-venue`; its venue id is the +# manifest name (`cow`). + +# [[adapters]] +# path = "target/wasm32-wasip2/release/cow_venue.wasm" +# manifest = "crates/cow-venue/module.toml" +# http_allow = ["api.cow.fi"] diff --git a/justfile b/justfile index 9c89a2f9..6f410367 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,11 @@ build-module: build-venue: cargo build --target wasm32-wasip2 --release -p echo-venue +# Build the bundled cow venue adapter component. Install via the +# engine.toml [[adapters]] stanza; the venue id is its manifest name. +build-cow-venue: + cargo build --target wasm32-wasip2 --release -p cow-venue --features adapter + # Build everything build: build-engine build-module From fbe2ecaa229c29dc78c72cd3f4b99e3b264b2def Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 14:19:57 +0000 Subject: [PATCH 45/53] cow: flip the keeper run onto the typed venue client --- Cargo.lock | 1 + crates/shepherd-sdk-test/tests/mock_venue.rs | 27 ++- crates/shepherd-sdk/Cargo.toml | 3 + crates/shepherd-sdk/src/cow/mod.rs | 28 ++- crates/shepherd-sdk/src/cow/run.rs | 133 +++++------ crates/shepherd-sdk/src/cow/transport.rs | 226 +++++++++++++++++++ crates/shepherd-sdk/src/lib.rs | 20 +- crates/shepherd-sdk/tests/run.rs | 132 ++++++++++- crates/videre-sdk/src/keeper.rs | 8 +- crates/videre-sdk/src/lib.rs | 2 +- modules/twap-monitor/src/strategy.rs | 10 +- 11 files changed, 472 insertions(+), 118 deletions(-) create mode 100644 crates/shepherd-sdk/src/cow/transport.rs diff --git a/Cargo.lock b/Cargo.lock index cb1328ce..b2b98b85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5249,6 +5249,7 @@ dependencies = [ "strum", "thiserror 2.0.18", "tracing", + "videre-sdk", ] [[package]] diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index d8af0612..ec371ca2 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -9,8 +9,8 @@ use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; use shepherd_sdk::cow::{ - CowApiError, CowHost, CowIntent, CowIntentBody, OrderRejection, SignedOrder, - gpv2_to_order_data, order_data_to_body, order_uid_hex, run, + CowApiError, CowApiTransport, CowClient, CowHost, CowIntent, CowIntentBody, OrderRejection, + SignedOrder, gpv2_to_order_data, order_data_to_body, order_uid_hex, run, }; use shepherd_sdk_test::{MockHost, MockVenue}; @@ -18,6 +18,12 @@ const SEPOLIA: u64 = 11_155_111; type VenueHost = MockHost; +/// The typed client every test drives `run` with: the transitional +/// cow-api bridge over the scripted venue host. +fn venue(host: &VenueHost) -> CowClient> { + CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +} + /// Closure-backed source so each test scripts its own outcome. struct FnSource(F); @@ -146,7 +152,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { host.cow_api.enqueue_submit(Ok(client_uid(&order))); let source = ready_source(&order); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); assert!(host.store.snapshot().contains_key(&key), "watch survives"); assert!( @@ -155,7 +161,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { .unwrap() ); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) @@ -185,7 +191,7 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { let t0 = sample_tick(); let source = ready_source(&order); - run(&host, &source, &t0).unwrap(); + run(&host, &venue(&host), &source, &t0).unwrap(); assert_eq!(host.cow_api.call_count(), 1); // 2500ms rounds up to a 3s epoch gate: a tick inside it never @@ -194,14 +200,14 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { epoch_s: t0.epoch_s + 2, ..t0 }; - run(&host, &source, &gated).unwrap(); + run(&host, &venue(&host), &source, &gated).unwrap(); assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); let clear = Tick { epoch_s: t0.epoch_s + 3, ..t0 }; - run(&host, &source, &clear).unwrap(); + run(&host, &venue(&host), &source, &clear).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) @@ -222,7 +228,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); let source = ready_source(&order); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&key)); assert!(!snapshot.contains_key(&watch_key.next_block_key())); @@ -231,7 +237,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { host.cow_api.clear_fault(); host.cow_api.enqueue_submit(Ok(client_uid(&order))); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) @@ -249,7 +255,7 @@ fn keeper_drops_the_watch_on_a_scripted_permanent_rejection() { host.cow_api .enqueue_submit(Err(rejection("InvalidSignature"))); - run(&host, &ready_source(&order), &sample_tick()).unwrap(); + run(&host, &venue(&host), &ready_source(&order), &sample_tick()).unwrap(); assert!(host.store.is_empty(), "watch and gates must go"); assert_eq!(host.cow_api.call_count(), 1); @@ -276,6 +282,7 @@ fn keeper_sweep_ignores_sibling_namespace_watches() { let polls = std::cell::Cell::new(0_u32); run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index b73fae63..a2c7f216 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -22,6 +22,9 @@ cow-venue = { path = "../cow-venue", features = ["client", "assembly"] } # The structured poll seam the keeper run dispatches on. composable-cow = { path = "../composable-cow" } nexum-sdk = { path = "../nexum-sdk" } +# The typed client seam the keeper run submits through; also the +# `VenueTransport` contract the legacy cow-api bridge implements. +videre-sdk = { path = "../videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true serde_json.workspace = true diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 86ecdf03..8909134c 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -1,9 +1,10 @@ //! CoW Protocol bridging. //! //! ABI decoding helpers, the orderbook error surface, and [`run()`] - -//! the poll/submit composition over the keeper stores. The chain-edge -//! order projections live in the `cow-venue` `assembly` slice (the -//! venue adapter owns them) and are re-exported here. +//! the poll/submit composition over the keeper stores, submitting +//! through the typed [`CowClient`] on the `videre:venue/client` seam. +//! The chain-edge order projections live in the `cow-venue` `assembly` +//! slice (the venue adapter owns them) and are re-exported here. //! //! The poll seam is the structured //! [`Verdict`](composable_cow::Verdict), carried by the @@ -14,11 +15,14 @@ //! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can //! be unit-tested without wit-bindgen scaffolding and re-used //! unchanged by TWAP, EthFlow, and future strategy modules. The -//! keeper run is generic over the host traits alone. +//! keeper run is generic over the host traits and the venue transport +//! alone; [`CowApiTransport`] carries it over the legacy +//! `shepherd:cow/cow-api` import until the module worlds flip. pub mod error; pub mod events; pub mod run; +pub mod transport; /// Chain-edge order assembly, re-exported from the `cow-venue` /// `assembly` slice the venue adapter owns. @@ -28,19 +32,23 @@ pub use error::{ classify_submit_error, is_already_submitted, }; pub use run::run; +pub use transport::CowApiTransport; /// The venue-neutral intent body types and their borsh `IntentBody` -/// codec, re-exported from the `cow-venue` default slice. The shim keeps -/// this path stable while the module ports move off the legacy surface. +/// codec, re-exported from the `cow-venue` default slice, plus the +/// typed CoW venue client. The shim keeps this path stable while the +/// module ports move off the legacy surface. pub use cow_venue::{ - BuyToken, BuyTokenDestination, CowIntent, CowIntentBody, OrderBody, OrderBuilder, OrderKind, - OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, + BuyToken, BuyTokenDestination, CowClient, CowIntent, CowIntentBody, CowVenue, OrderBody, + OrderBuilder, OrderKind, OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, }; use nexum_sdk::host::Host; -/// `shepherd:cow/cow-api` - orderbook submission path. The CoW-domain -/// sibling of the core host traits in [`nexum_sdk::host`]. +/// `shepherd:cow/cow-api` - the legacy orderbook submission path, +/// retiring. The keeper [`run()`] submits through the typed +/// [`CowClient`]; [`CowApiTransport`] bridges it onto this seam until +/// the module worlds flip to `videre:venue/client`. pub trait CowApiHost { /// Submit an `OrderCreation` JSON body. The host returns the /// canonical order UID on success. A rejection surfaces as a typed diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index c9e39a00..0a19bc8e 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -4,40 +4,43 @@ //! [`run`] walks the keeper watch set, polls each gate-ready //! watch through a [`ConditionalSource`], and runs the //! [`Verdict`]'s effect: lifecycle outcomes update the gate and -//! watch stores, `Post` drives one submission through the -//! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` -//! journal as the idempotency guard - keyed on the venue-and-body -//! [`intent_id`] - and the keeper [`Retrier`] +//! watch stores, `Post` drives one submission through the typed +//! [`CowClient`] onto the `videre:venue/client` seam with the +//! `submitted:` journal as the idempotency guard - keyed on the +//! venue-and-body [`intent_id`] - and the keeper [`Retrier`] //! as the failure dispatch. //! //! Store faults abort the sweep (the next tick replays it); -//! submission failures never do - they classify into a -//! [`RetryAction`], the ledger applies the effect, and the sweep -//! moves on. Diagnostics go through the guest `tracing` facade - +//! submission failures never do - they fold into a +//! [`RetryAction`] through the videre +//! [`retry_action`] table, the ledger applies the effect, and the +//! sweep moves on. Diagnostics go through the guest `tracing` facade - //! the same channel strategy code logs on - so module tests observe //! the composed behaviour with one capture. -use alloy_primitives::{Address, Bytes}; +use alloy_primitives::{Address, Bytes, hex}; use composable_cow::Verdict; -use cow_venue::assembly::build_order_creation; use cowprotocol::GPv2OrderData; -use nexum_sdk::host::Fault; +use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, }; +use videre_sdk::keeper::retry_action; +use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; use super::{ - CowApiError, CowHost, CowIntent, CowIntentBody, SignedOrder, classify_submit_error, - gpv2_to_order_data, intent_id, is_already_submitted, order_data_to_body, + CowClient, CowIntent, CowIntentBody, SignedOrder, gpv2_to_order_data, intent_id, + order_data_to_body, }; /// Poll every gate-ready watch once at `tick` and run each outcome's /// effect. One source poll per ready watch; a `Post` outcome makes at -/// most one `submit_order` call. -pub fn run(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> +/// most one venue submit through `venue`. +pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> where - H: CowHost, + H: LocalStoreHost, S: ConditionalSource, + T: VenueTransport, { let watches = WatchSet::new(host); let gates = Gates::new(host); @@ -55,7 +58,7 @@ where Verdict::Post { order, signature, .. } => { - submit_ready(host, watch, &order, signature, tick, source.label())?; + submit_ready(host, venue, watch, &order, signature, tick, source.label())?; } Verdict::TryNextBlock { .. } => {} Verdict::WaitBlock { wait_until, .. } => gates.set_next_block(watch, wait_until)?, @@ -74,24 +77,27 @@ where Ok(()) } -/// Submit one freshly-polled `Ready` order, guarding on the -/// `submitted:` journal and dispatching any failure through the retry -/// ledger. +/// Submit one freshly-polled `Ready` order through the typed client, +/// guarding on the `submitted:` journal and dispatching any venue +/// refusal through the retry ledger. /// -/// The journal keys on the deterministic venue-and-body -/// [`intent_id`], derived before any network work from the same body -/// bytes a venue submit carries - never from the assembled -/// `OrderCreation` - so the guard survives assembly moving into the -/// venue adapter. The orderbook's UID is the receipt; it rides the -/// log only. -fn submit_ready( +/// The journal keys on the deterministic venue-and-body [`intent_id`], +/// derived before any network work from the same body bytes the venue +/// submit carries, so the guard is independent of where assembly +/// happens. The venue's receipt rides the log only. +fn submit_ready( host: &H, + venue: &CowClient, watch: WatchRef<'_>, order: &GPv2OrderData, signature: Bytes, tick: &Tick, label: &str, -) -> Result<(), Fault> { +) -> Result<(), Fault> +where + H: LocalStoreHost, + T: VenueTransport, +{ let Ok(owner) = watch.owner_hex().parse::
() else { tracing::warn!( "watch {} carries an unparseable owner; skipping submit", @@ -127,31 +133,16 @@ fn submit_ready( tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); return Ok(()); } - let creation = match build_order_creation(&order_data, &signature, owner) { - Ok(creation) => creation, - Err(err) => { - // A constructor rejection (zero `from`, `validTo` beyond - // the client-side max horizon) is deterministic for this - // polled payload: keeping the watch would re-poll and - // re-warn on every block forever. Drop through the ledger - // - the same net effect as the pre-keeper flow, where - // the orderbook rejected the shipped body and the - // classifier dropped the watch. - tracing::warn!("{label} submit dropped watch for {owner:#x}: {err}"); - Retrier::new(host).apply(watch, RetryAction::Drop, tick.epoch_s)?; - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(body) => body, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); - return Ok(()); - } - }; - match host.submit_order(tick.chain_id, &body) { - Ok(receipt) => { + let Some(outcome) = rt::complete(venue.submit(&intent)) else { + // Guest transports never suspend; a pending future means a + // foreign transport misbehaved. The watch stays for the next + // tick. + tracing::error!("{label} submit future suspended; retrying next tick"); + return Ok(()); + }; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the @@ -159,41 +150,39 @@ fn submit_ready( if let Err(fault) = journal.record(&intent_id) { tracing::error!("submitted {intent_id} but journal write failed: {fault}"); } - tracing::info!("submitted {intent_id} (receipt {receipt})"); - } - Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { - // Success wearing an error status: the orderbook already - // holds this order. Journal the intent-id and keep the - // watch so the next tick short-circuits instead of - // re-posting. As above, a journal fault post-submit only - // forfeits the short-circuit; it must not abort the sweep. - if let Err(fault) = journal.record(&intent_id) { - tracing::error!( - "orderbook already holds {intent_id} but journal write failed: {fault}" - ); - } tracing::info!( - "orderbook already holds this order ({}); intent-id journalled", - rejection.error_type, + "submitted {intent_id} (receipt {})", + hex::encode_prefixed(&receipt), ); } - Err(err) => { - let action = classify_submit_error(&err); + Ok(SubmitOutcome::RequiresSigning(_)) => { + // A sweep cannot sign; nothing is journalled, so the next + // tick surfaces the same ask afresh. + tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); + } + Err(ClientError::Body(err)) => { + tracing::error!("intent body encode failed: {err}"); + } + Err(ClientError::Venue(fault)) => { + let action = retry_action(&fault); Retrier::new(host).apply(watch, action, tick.epoch_s)?; match action { - RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {err}"), + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {fault}"), RetryAction::Backoff { seconds } => { - tracing::warn!("submit backoff {seconds}s: {err}"); + tracing::warn!("submit backoff {seconds}s: {fault}"); } - RetryAction::Drop => tracing::warn!("submit dropped watch: {err}"), + RetryAction::Drop => tracing::warn!("submit dropped watch: {fault}"), // `RetryAction` is non-exhaustive; the ledger already // ran the effect, so the log needs only the name. other => { let action_label: &'static str = other.into(); - tracing::warn!("submit retry action {action_label}: {err}"); + tracing::warn!("submit retry action {action_label}: {fault}"); } } } + // `ClientError` is non-exhaustive; a future case leaves the + // watch for the next tick. + Err(err) => tracing::error!("submit failed: {err}"), } Ok(()) } diff --git a/crates/shepherd-sdk/src/cow/transport.rs b/crates/shepherd-sdk/src/cow/transport.rs new file mode 100644 index 00000000..ece99f70 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/transport.rs @@ -0,0 +1,226 @@ +//! Transitional venue transport over the legacy `shepherd:cow/cow-api` +//! seam. +//! +//! [`CowApiTransport`] implements the videre [`VenueTransport`] +//! contract by assembling the orderbook `OrderCreation` from the +//! decoded [`CowIntentBody`] and driving +//! [`CowApiHost::submit_order`], so the keeper [`run`](super::run()) +//! submits through the typed [`CowClient`](super::CowClient) while +//! module worlds still import the legacy host extension. Deleted when +//! the worlds flip to `videre:venue/client`. + +use alloy_primitives::{Address, hex}; +use cow_venue::assembly; +use cow_venue::body::{CowIntent, CowIntentBody}; +use cowprotocol::Chain; +use nexum_sdk::host::Fault; +use nexum_sdk::keeper::RetryAction; +use videre_sdk::client::sealed::SealedTransport; +use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, VenueFault, VenueId, VenueTransport, +}; + +use super::{CowApiError, CowApiHost, classify_api_error, is_already_submitted}; + +/// The `videre:venue/client` verbs carried over the legacy +/// `shepherd:cow/cow-api` import: submit only, pre-bound to one chain's +/// orderbook. Quote, status, and cancel have no legacy submission-path +/// counterpart and refuse as `unsupported`. +pub struct CowApiTransport<'h, H> { + host: &'h H, + chain_id: u64, +} + +impl<'h, H: CowApiHost> CowApiTransport<'h, H> { + /// Bind the legacy seam to one chain's orderbook. + #[must_use] + pub const fn new(host: &'h H, chain_id: u64) -> Self { + Self { host, chain_id } + } +} + +impl SealedTransport for CowApiTransport<'_, H> {} + +impl VenueTransport for CowApiTransport<'_, H> { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + Err(VenueFault::Unsupported) + } + + async fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + let CowIntentBody::V1(intent) = + CowIntentBody::from_bytes(&body).map_err(|e| VenueFault::InvalidBody(e.to_string()))?; + // The legacy seam posts EIP-1271 only; the pre-sign flow needs + // the adapter. + let CowIntent::Signed(signed) = intent else { + return Err(VenueFault::Unsupported); + }; + let order = assembly::body_to_order_data(&signed.order); + let owner = Address::from(signed.owner); + let creation = assembly::build_order_creation(&order, &signed.signature, owner) + .map_err(|e| VenueFault::InvalidBody(e.to_string()))?; + let json = serde_json::to_vec(&creation) + .map_err(|e| VenueFault::Unavailable(format!("order encode failed: {e}")))?; + match self.host.submit_order(self.chain_id, &json) { + Ok(uid) => Ok(SubmitOutcome::Accepted(receipt_bytes(&uid))), + // Already-held is success wearing an error status; the + // receipt is the client-derived UID (empty on a chain the + // SDK cannot derive for). + Err(CowApiError::Rejected(r)) if is_already_submitted(&r) => { + let receipt = Chain::try_from(self.chain_id) + .map(|chain| { + assembly::order_uid(chain, &order, owner) + .as_slice() + .to_vec() + }) + .unwrap_or_default(); + Ok(SubmitOutcome::Accepted(receipt)) + } + Err(err) => Err(venue_fault(&err)), + } + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + Err(VenueFault::Unsupported) + } +} + +/// The server UID at its wire spelling; a non-hex receipt rides through +/// as raw bytes rather than failing an accepted submit. +fn receipt_bytes(uid: &str) -> Vec { + hex::decode(uid).unwrap_or_else(|_| uid.as_bytes().to_vec()) +} + +/// Project a legacy submission failure onto the venue fault the typed +/// client reports, mirroring the adapter: throttles keep their hint, +/// host and server failures stay retryable, and only a structured +/// rejection, folded through the shipped classification table, carries +/// a permanent venue verdict. +fn venue_fault(err: &CowApiError) -> VenueFault { + match err { + CowApiError::Fault(Fault::RateLimited(limit)) => VenueFault::RateLimited { + retry_after_ms: limit.retry_after_ms, + }, + CowApiError::Fault(Fault::Timeout) => VenueFault::Timeout, + // Any other host fault is infrastructure, not a venue verdict: + // it stays retryable so an unprovisioned capability or unknown + // chain never drops a still-valid order. + CowApiError::Fault(fault) => VenueFault::Unavailable(fault.to_string()), + CowApiError::Http(http) if http.status == 429 => VenueFault::RateLimited { + retry_after_ms: None, + }, + CowApiError::Http(http) => { + VenueFault::Unavailable(format!("orderbook http {}", http.status)) + } + CowApiError::Rejected(rejection) => { + let detail = format!("{}: {}", rejection.error_type, rejection.description); + match classify_api_error(rejection) { + RetryAction::TryNextBlock => VenueFault::Unavailable(detail), + RetryAction::Backoff { seconds } => VenueFault::RateLimited { + retry_after_ms: Some(seconds.saturating_mul(1000)), + }, + _ => VenueFault::Denied(detail), + } + } + } +} + +#[cfg(test)] +mod tests { + use nexum_sdk::host::RateLimit; + use videre_sdk::keeper::retry_action; + + use super::super::{HttpFailure, OrderRejection}; + use super::*; + + fn rejected(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "d".into(), + data: None, + }) + } + + #[test] + fn legacy_failures_project_onto_the_venue_fault_by_shape() { + assert!(matches!( + venue_fault(&rejected("InsufficientFee")), + VenueFault::Unavailable(detail) if detail.contains("InsufficientFee") + )); + assert!(matches!( + venue_fault(&rejected("TooManyLimitOrders")), + VenueFault::RateLimited { + retry_after_ms: Some(30_000) + } + )); + assert!(matches!( + venue_fault(&rejected("InvalidSignature")), + VenueFault::Denied(detail) if detail.contains("InvalidSignature") + )); + assert!(matches!( + venue_fault(&CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + }))), + VenueFault::RateLimited { + retry_after_ms: Some(2_500) + } + )); + assert!(matches!( + venue_fault(&CowApiError::Fault(Fault::Timeout)), + VenueFault::Timeout + )); + assert!(matches!( + venue_fault(&CowApiError::Http(HttpFailure { + status: 429, + body: None, + })), + VenueFault::RateLimited { + retry_after_ms: None + } + )); + assert!(matches!( + venue_fault(&CowApiError::Http(HttpFailure { + status: 502, + body: None, + })), + VenueFault::Unavailable(_) + )); + } + + #[test] + fn host_faults_stay_retryable_and_never_drop_the_watch() { + for fault in [ + Fault::Unsupported("cow-api not provisioned".into()), + Fault::Denied("allowlist".into()), + Fault::Unavailable("rpc down".into()), + Fault::Internal("host bug".into()), + Fault::InvalidInput("mangled".into()), + ] { + let projected = venue_fault(&CowApiError::Fault(fault)); + assert!(matches!(projected, VenueFault::Unavailable(_))); + assert_eq!(retry_action(&projected), RetryAction::TryNextBlock); + } + assert_eq!( + retry_action(&venue_fault(&CowApiError::Fault(Fault::Timeout))), + RetryAction::TryNextBlock + ); + assert_eq!( + retry_action(&venue_fault(&CowApiError::Fault(Fault::RateLimited( + RateLimit { + retry_after_ms: Some(2_500), + } + )))), + RetryAction::Backoff { seconds: 3 } + ); + } + + #[test] + fn server_uid_decodes_to_wire_bytes_with_a_raw_fallback() { + assert_eq!(receipt_bytes("0xc0ffee"), vec![0xC0, 0xFF, 0xEE]); + assert_eq!(receipt_bytes("not-hex"), b"not-hex".to_vec()); + } +} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 40798f85..2201ad35 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -13,14 +13,16 @@ //! [`OrderData`], [`OrderUid`], [`OrderKind`], [`Signature`], //! [`Chain`], [`GPv2OrderData`], [`EMPTY_APP_DATA_JSON`]). //! -//! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` -//! (and the [`CowHost`] bound over the core [`Host`]), -//! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! the classifiers mapping submit failures into the keeper -//! [`RetryAction`], and [`run`] - the poll -> outcome -> -//! gate/journal/submit composition over the keeper stores, -//! dispatching the structured [`Verdict`] from the `composable-cow` -//! keeper crate. +//! - [`cow`] - [`run`], the poll -> outcome -> gate/journal/submit +//! composition over the keeper stores, dispatching the structured +//! [`Verdict`] from the `composable-cow` keeper crate and submitting +//! through the typed [`CowClient`] on the `videre:venue/client` +//! seam; `GPv2OrderData` -> `OrderData` bridging +//! ([`gpv2_to_order_data`]) and the classifiers mapping submit +//! failures into the keeper [`RetryAction`]. The legacy +//! [`CowApiHost`] trait for `shepherd:cow/cow-api` (and the +//! [`CowHost`] bound over the core [`Host`]) stays for the read +//! paths and the transitional [`CowApiTransport`] bridge. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -47,6 +49,8 @@ //! [`EMPTY_APP_DATA_JSON`]: cowprotocol::EMPTY_APP_DATA_JSON //! [`CowApiHost`]: cow::CowApiHost //! [`CowHost`]: cow::CowHost +//! [`CowClient`]: cow::CowClient +//! [`CowApiTransport`]: cow::CowApiTransport //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data //! [`Verdict`]: composable_cow::Verdict diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index bff0f238..f4e32081 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -13,13 +13,19 @@ use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; use shepherd_sdk::cow::{ - CowApiError, CowIntent, CowIntentBody, OrderRejection, SignedOrder, gpv2_to_order_data, - order_data_to_body, run, + CowApiError, CowApiTransport, CowClient, CowIntent, CowIntentBody, CowVenue, OrderRejection, + SignedOrder, gpv2_to_order_data, order_data_to_body, run, }; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; +/// The typed client every test drives `run` with: the transitional +/// cow-api bridge over the composed mock host. +fn venue(host: &MockHost) -> CowClient> { + CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +} + /// Closure-backed source so each test scripts its own outcome and /// observes its own poll calls. struct FnSource(F); @@ -124,6 +130,7 @@ fn try_next_block_leaves_the_store_untouched() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), &sample_tick(), ) @@ -141,6 +148,7 @@ fn try_on_block_sets_the_block_gate() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::WaitBlock { wait_until: 2_000, reason: [0; 4], @@ -163,6 +171,7 @@ fn try_at_epoch_sets_the_epoch_gate() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::WaitTimestamp { wait_until: 1_800_000_000, reason: [0; 4], @@ -186,6 +195,7 @@ fn invalid_removes_the_watch_and_its_gates() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), &sample_tick(), ) @@ -207,6 +217,7 @@ fn gated_watch_is_not_polled() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -226,6 +237,7 @@ fn malformed_watch_rows_are_skipped() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -250,7 +262,7 @@ fn ready_submits_once_and_journals_the_intent_id() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); assert!( @@ -273,7 +285,7 @@ fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); @@ -295,6 +307,7 @@ fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) @@ -320,6 +333,7 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -343,7 +357,7 @@ fn ready_beyond_the_valid_to_horizon_drops_the_watch() { order.validTo = valid_to_in(2 * 365 * 24 * 3_600); let source = src(move |_, _, _, _| ready_outcome(&order)); - let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + let (result, logs) = capture_tracing(|| run(&host, &venue(&host), &source, &sample_tick())); result.unwrap(); assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); @@ -377,6 +391,7 @@ fn transient_rejection_keeps_the_watch_ungated() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -401,6 +416,7 @@ fn permanent_rejection_drops_the_watch_through_the_ledger() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -426,7 +442,7 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert!(host.store.snapshot().contains_key(&key)); assert!( @@ -437,7 +453,7 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { ); // The next tick must not touch the orderbook again. - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); } @@ -455,7 +471,7 @@ fn restart_with_a_journalled_intent_does_not_repost() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); // A restarted keeper: fresh instance, the local store carried over. @@ -465,7 +481,7 @@ fn restart_with_a_journalled_intent_does_not_repost() { } restarted.cow_api.respond(Ok("0xserveruid".to_string())); - run(&restarted, &source, &sample_tick()).unwrap(); + run(&restarted, &venue(&restarted), &source, &sample_tick()).unwrap(); assert_eq!( host.cow_api.call_count() + restarted.cow_api.call_count(), @@ -493,7 +509,13 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { })))); let tick = sample_tick(); - run(&host, &src(move |_, _, _, _| ready_outcome(&order)), &tick).unwrap(); + run( + &host, + &venue(&host), + &src(move |_, _, _, _| ready_outcome(&order)), + &tick, + ) + .unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&key), "backoff must keep the watch"); @@ -504,3 +526,93 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { ); assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } + +// ---- the generic seam ---- + +/// The seam proof: a `Post` verdict reaches the venue transport as the +/// encoded `CowIntentBody` under the CoW venue id, the journal keys on +/// the generic submission key, and the legacy cow-api seam is never +/// touched. +#[test] +fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { + use std::cell::RefCell; + + use videre_sdk::keeper::submission_key; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + VenueTransport, + }; + + /// Records the venue and wire bytes of every submit. + struct SpyTransport { + calls: RefCell)>>, + } + + impl videre_sdk::client::sealed::SealedTransport for &SpyTransport {} + + impl VenueTransport for &SpyTransport { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.calls.borrow_mut().push((venue.to_string(), body)); + Ok(SubmitOutcome::Accepted(vec![0xAA])) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let spy = SpyTransport { + calls: RefCell::new(Vec::new()), + }; + let client = CowClient::with_transport(&spy); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &client, &source, &sample_tick()).unwrap(); + + let order_data = gpv2_to_order_data(&order).expect("known markers"); + let expected = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + })) + .to_bytes() + .expect("body encodes"); + + let calls = spy.calls.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, CowVenue::ID.as_str()); + assert_eq!(calls[0].1, expected, "the wire carries the intent body"); + assert!( + Journal::submitted(&host) + .contains(&submission_key(&CowVenue::ID, &expected)) + .unwrap(), + "the journal keys on the generic submission key", + ); + assert_eq!( + host.cow_api.call_count(), + 0, + "the legacy cow-api seam is never touched", + ); +} diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 7255efd9..8ca91a3e 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -112,7 +112,7 @@ impl Keeper { report.unsigned.push(tx); continue; } - Err(fault) => retry_action(fault), + Err(fault) => retry_action(&fault), } } Sweep::WaitBlock => RetryAction::TryNextBlock, @@ -165,8 +165,10 @@ pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { /// Fold a venue refusal into the retry action the ledger runs: the /// throttle hint becomes an epoch gate, transient failures retry next -/// block, and refusals no retry can cure drop the watch. -fn retry_action(fault: VenueFault) -> RetryAction { +/// block, and refusals no retry can cure drop the watch. Public so a +/// keeper sweeping outside [`Keeper::sweep`] folds refusals the same +/// way. +pub fn retry_action(fault: &VenueFault) -> RetryAction { match fault { VenueFault::RateLimited { retry_after_ms: Some(ms), diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 8173f7e2..dd96575c 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -77,7 +77,7 @@ pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; pub use faults::VenueFault; -pub use keeper::{Keeper, Sweep, SweepReport}; +pub use keeper::{Keeper, Sweep, SweepReport, retry_action}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`videre_macros::IntentBody`]. pub use videre_macros::IntentBody; diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 540a6c7e..d5426d58 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -24,7 +24,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, events, run}; +use shepherd_sdk::cow::{CowApiTransport, CowClient, CowHost, events, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -71,15 +71,17 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { } /// Poll entry: run the keeper over every gate-ready watch through the -/// shared composition. The block timestamp arrives in milliseconds; the -/// tick carries Unix seconds. +/// shared composition, submitting through the typed client on the +/// transitional cow-api bridge. The block timestamp arrives in +/// milliseconds; the tick carries Unix seconds. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { let tick = Tick { chain_id: block.chain_id, block: block.number, epoch_s: block.timestamp / 1000, }; - run(host, &TwapSource, &tick) + let venue = CowClient::with_transport(CowApiTransport::new(host, tick.chain_id)); + run(host, &venue, &TwapSource, &tick) } // ---- indexing path ---- From d58eed316548cd2ab291c6509ba5c81d52bb44c2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 20:29:18 +0000 Subject: [PATCH 46/53] twap: re-point the monitor onto pool submit through the cow adapter The module flips from the shepherd:cow world onto #[videre_sdk::keeper]: the manifest declares the client capability and body version 1, the keeper run submits through the typed CowClient over the module's own videre:venue/client import, and the direct cow-api import and legacy cow client bridge drop out. Status transitions the registry polls back arrive on a cow intent-status subscription. Behaviour identity is proven at the VenueTransport seam: the dispatch tests script submit outcomes against the mock host and assert the same journal, gate, and retry effects as the legacy bridge, including the throttle hint surviving as an epoch backoff and the appData digest riding the body verbatim. The bundle boot proof moves to the videre platform suite (twap against the installed cow adapter); the cow-api boot-order invariant re-pins on ethflow-watcher. Engine configs that boot twap install the bundled adapter. --- Cargo.lock | 3 +- crates/shepherd-cow-host/tests/cow_boot.rs | 44 +-- crates/videre-host/tests/platform.rs | 46 +++ engine.e2e.toml | 9 + engine.load.toml | 10 + engine.m2.toml | 7 + engine.soak.docker.toml | 8 + engine.soak.toml | 11 + justfile | 4 +- modules/twap-monitor/Cargo.toml | 4 +- modules/twap-monitor/module.toml | 20 +- modules/twap-monitor/src/lib.rs | 85 +++--- modules/twap-monitor/src/strategy.rs | 334 ++++++++++++++------- 13 files changed, 388 insertions(+), 197 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2b98b85..dddca7c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5930,13 +5930,14 @@ dependencies = [ "alloy-primitives", "alloy-sol-types", "composable-cow", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 99080dac..3953ab6a 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -144,32 +144,6 @@ async fn boot_production_module( .expect("boot_single") } -/// twap-monitor imports `shepherd:cow/cow-api`; with the cow extension -/// registered it boots, and a block dispatch reaches it and keeps it alive. -#[tokio::test] -async fn e2e_twap_monitor_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - - // twap-monitor subscribes to Sepolia blocks (poll path). A real poll - // would call chain::request, which ProviderPool::empty() does not - // satisfy - the module surfaces a fault and warns; the supervisor - // must keep the module alive because the strategy catches the error - // and returns Ok(()). - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - /// ethflow-watcher imports `shepherd:cow/cow-api` and subscribes to logs; /// it boots with the cow extension and a synthetic log is delivered. #[tokio::test] @@ -221,17 +195,17 @@ async fn e2e_stop_loss_block_dispatch() { } /// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the +/// a module that imports `shepherd:cow/cow-api` (ethflow-watcher) must +/// NOT boot when the cow extension is absent from the linker AND the /// capability registry. The paired linker-hook + capability-namespace /// registration is what makes the same module boot in the tests above; /// drop the pairing and boot fails. #[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { +async fn ethflow_watcher_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { return; }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); + let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); let engine = make_wasmtime_engine(); // Core-only: no cow linker hook, no cow capability namespace. let linker = build_linker::(&engine, &[]).expect("build_linker"); @@ -254,10 +228,10 @@ async fn twap_monitor_without_cow_extension_fails_to_boot() { let err = result .err() .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. + // Pin the failure to its specific cause: ethflow-watcher declares + // the cow-api capability, which a core-only registry does not + // recognise (registering it is exactly what the cow extension does). + // Rules out an unrelated failure masquerading as the invariant. let chain = format!("{err:#}"); assert!( chain.contains(r#"unknown capability "cow-api""#), diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index ecc3c79e..ace107a8 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -709,6 +709,52 @@ async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { } } +/// The shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` +/// worker) boots against the installed cow adapter - the body-version +/// handshake admits the pair - and a Sepolia block dispatch reaches it +/// and keeps it alive. The chainless poll surfaces a fault the strategy +/// absorbs, so no orderbook traffic occurs. +#[tokio::test] +async fn e2e_twap_monitor_boots_against_the_cow_adapter() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("cow-venue"), + module_wasm_or_skip("twap-monitor"), + ) else { + return; + }; + + let components = mock_components(); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("crates/cow-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/twap-monitor/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "cow is routable"); + assert_eq!(supervisor.alive_count(), 1, "twap-monitor is alive"); + + // twap-monitor subscribes to Sepolia blocks (poll path); with no + // watches indexed the sweep is empty and the keeper stays alive. + assert_eq!(supervisor.dispatch_block(block(11_155_111)).await, 1); + assert_eq!(supervisor.alive_count(), 1); +} + /// The body-version handshake refuses a mismatched pair: an adapter /// decoding only v1 against a keeper encoding v2 fails the boot at the /// keeper's install, before instantiation, naming both sides' versions. diff --git a/engine.e2e.toml b/engine.e2e.toml index 330774b7..50ce3063 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -65,3 +65,12 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.load.toml b/engine.load.toml index 7999669d..ee8bc03f 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -41,6 +41,16 @@ rpc_url = "ws://localhost:8545" path = "./target/wasm32-wasip2/release/twap_monitor.wasm" manifest = "./modules/twap-monitor/module.toml" +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Its orderbook target comes from the adapter's own +# `[config]`; re-pointing pool submits at the orderbook mock needs a +# load-variant adapter manifest, so a load run currently exercises the +# submit path against the adapter's configured orderbook. +[[adapters]] +path = "./target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "./crates/cow-venue/module.toml" +http_allow = ["api.cow.fi"] + [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "./modules/ethflow-watcher/module.toml" diff --git a/engine.m2.toml b/engine.m2.toml index cce435c3..eecae31d 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -33,3 +33,10 @@ manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "modules/ethflow-watcher/module.toml" + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.docker.toml b/engine.soak.docker.toml index 3f29ea10..9e121b67 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -46,3 +46,11 @@ manifest = "/opt/shepherd/manifests/balance-tracker.toml" [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" + +# --- adapters ----------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through. +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.toml b/engine.soak.toml index 8f6cabf8..c9501a08 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -9,6 +9,8 @@ # cargo build --target wasm32-wasip2 --release \ # -p twap-monitor -p ethflow-watcher -p price-alert \ # -p balance-tracker -p stop-loss +# cargo build --target wasm32-wasip2 --release \ +# -p cow-venue --features cow-venue/adapter # ./target/release/nexum --engine-config engine.soak.toml # # Swap rpc_url below for your paid endpoint before starting, or set it @@ -66,3 +68,12 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.toml" +http_allow = ["api.cow.fi"] diff --git a/justfile b/justfile index 6f410367..1d22c5c2 100644 --- a/justfile +++ b/justfile @@ -43,7 +43,7 @@ build-m2: # (Sepolia, both M2 modules). See `docs/operations/m2-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m2: build-m2 build-engine +run-m2: build-m2 build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs # Build the M3 example modules (price-alert + balance-tracker + stop-loss) @@ -74,7 +74,7 @@ build-e2e: build-m2 build-m3 # 127.0.0.1:9100/metrics. JSON logs (no --pretty-logs) so a # downstream `jq` filter can mine submitted/dropped/backoff markers # for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. -run-e2e: build-e2e build-engine +run-e2e: build-e2e build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.e2e.toml # Zero-leak gate: host-layer crate graphs, runtime charter-symbol and diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 5533d59a..acacdadb 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -10,8 +10,10 @@ crate-type = ["cdylib"] [dependencies] composable-cow = { path = "../../crates/composable-cow" } +cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -19,6 +21,6 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index 3ef1883d..d7f45ca2 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -1,5 +1,5 @@ # twap-monitor: poll registered ComposableCoW conditional orders and -# submit ready ones via the CoW Protocol orderbook. +# submit ready ones to the CoW venue through the pool. [module] name = "twap-monitor" @@ -14,13 +14,13 @@ component = "sha256:000000000000000000000000000000000000000000000000000000000000 # - local-store -> watch: / next_block: / next_epoch: / submitted: / # backoff: / dropped: persistence # - chain -> eth_call into ComposableCoW.getTradeableOrderWithSignature -# - cow-api -> POST /api/v1/orders submission path -required = ["logging", "local-store", "chain", "cow-api"] +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "local-store", "chain", "client"] optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api` (which routes through the -# host's pinned orderbook URL); no direct `http` calls. +# All outbound HTTP is the cow adapter's; the keeper makes no direct +# `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ @@ -40,3 +40,13 @@ event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2c [[subscription]] kind = "block" chain_id = 11155111 + +# Status transitions the registry polls back for submitted orders. +[[subscription]] +kind = "intent-status" +venue = "cow" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed venue adapter decodes it. +[venue] +body_version = 1 diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index fda3ac82..562a86d8 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -1,21 +1,19 @@ -//! # twap-monitor (Shepherd module) +//! # twap-monitor (Shepherd keeper module) //! //! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to -//! the CoW orderbook as they go live. +//! watched conditional order on every block, submitting tranches to the +//! CoW venue through the pool as they go live. //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates each event variant to `strategy`. -//! -//! Same recipe as `modules/examples/price-alert` and -//! `modules/examples/stop-loss`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -23,52 +21,45 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: [ - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; +use cow_venue::CowClient; use nexum::host::types; -// `WitBindgenHost` and the fault and level `From` impls -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - struct TwapMonitor; -impl Guest for TwapMonitor { +#[videre_sdk::keeper] +impl TwapMonitor { fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); tracing::info!("twap-monitor init"); Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - match event { - types::Event::ChainLogs(batch) => { - let logs: Vec = - batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs)?; - } - types::Event::Block(block) => { - let info = strategy::BlockInfo { - chain_id: block.chain_id, - number: block.number, - timestamp: block.timestamp, - }; - strategy::on_block(&WitBindgenHost, info)?; - } - // Tick / Message are not used by this module. - _ => {} - } + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); + strategy::on_chain_logs(&WitBindgenHost, &logs)?; + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let info = strategy::BlockInfo { + chain_id: block.chain_id, + number: block.number, + timestamp: block.timestamp, + }; + strategy::on_block(&WitBindgenHost, &CowClient::new(), info)?; Ok(()) } -} -export!(TwapMonitor); + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + tracing::info!( + "cow intent status {:?} ({} receipt bytes)", + body.status, + update.receipt.len(), + ); + Ok(()) + } +} diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index d5426d58..15affe60 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -1,30 +1,34 @@ //! Pure strategy logic for the twap-monitor module. //! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under -//! `#[cfg(test)]` hand the same functions a -//! `shepherd_sdk_test::MockHost`. +//! Every interaction with the world flows through a trait seam: the +//! `nexum_sdk::host` traits for chain and store access and the videre +//! [`VenueTransport`] under the typed [`CowClient`] for submission - +//! no direct calls to wit-bindgen-generated free functions live here. +//! The `lib.rs` glue hands [`on_chain_logs`] / [`on_block`] the +//! `WitBindgenHost` adapter and the client over the module's own +//! `videre:venue/client` import; tests under `#[cfg(test)]` hand the +//! same functions a `nexum_sdk_test::MockHost` and a scripted +//! transport. //! //! The module owns decode and evaluate only: log decoding into the //! keeper watch set, and the `getTradeableOrderWithSignature` poll //! behind [`ConditionalSource`]. Gate discipline, the `submitted:` -//! journal, submission, and retry dispatch live in the shared -//! composition (`shepherd_sdk::cow::run`). +//! journal, submission through the pool, and retry dispatch live in +//! the shared composition (`shepherd_sdk::cow::run`). use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use composable_cow::{LegacyRevertAdapter, Verdict}; +use cow_venue::CowClient; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; -use nexum_sdk::host::{ChainError, Fault}; +use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowApiTransport, CowClient, CowHost, events, run}; +use shepherd_sdk::cow::{events, run}; +use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -61,7 +65,7 @@ mod abi { /// Indexer entry: decode every `ComposableCoW.ConditionalOrderCreated` /// chain-log in a dispatch batch and persist its watch. -pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { +pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { for log in logs { if let Some((owner, params)) = decode_conditional_order_created(log) { persist_watch(host, owner, ¶ms)?; @@ -71,17 +75,20 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { } /// Poll entry: run the keeper over every gate-ready watch through the -/// shared composition, submitting through the typed client on the -/// transitional cow-api bridge. The block timestamp arrives in -/// milliseconds; the tick carries Unix seconds. -pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { +/// shared composition, submitting through the typed client onto the +/// pool. The block timestamp arrives in milliseconds; the tick carries +/// Unix seconds. +pub fn on_block(host: &H, venue: &CowClient, block: BlockInfo) -> Result<(), Fault> +where + H: ChainHost + LocalStoreHost, + T: VenueTransport, +{ let tick = Tick { chain_id: block.chain_id, block: block.number, epoch_s: block.timestamp / 1000, }; - let venue = CowClient::with_transport(CowApiTransport::new(host, tick.chain_id)); - run(host, &venue, &TwapSource, &tick) + run(host, venue, &TwapSource, &tick) } // ---- indexing path ---- @@ -99,7 +106,7 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr /// The watch set overwrites in place, so re-indexing the same log /// (re-org replay, overlapping subscription windows) produces no /// observable side effect. -fn persist_watch( +fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, @@ -118,7 +125,7 @@ fn persist_watch( /// down the sweep. struct TwapSource; -impl ConditionalSource for TwapSource { +impl ConditionalSource for TwapSource { type Outcome = Verdict; fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { @@ -143,7 +150,7 @@ impl ConditionalSource for TwapSource { } } -fn poll_one( +fn poll_one( host: &H, chain_id: u64, owner: &Address, @@ -225,7 +232,7 @@ fn outcome_label(o: &Verdict) -> &'static str { // ---- test-only seam mirrors ---- // -// Thin views over the keeper / SDK canon so the dispatch tests can +// Thin views over the keeper / venue canon so the dispatch tests can // seed and inspect the store in the exact shapes production writes. #[cfg(test)] @@ -238,30 +245,108 @@ fn parse_watch_key(key: &str) -> Option<(&str, &str)> { } #[cfg(test)] -fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { - use shepherd_sdk::cow::{CowIntent, CowIntentBody, SignedOrder}; - let order_data = shepherd_sdk::cow::gpv2_to_order_data(order)?; - shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: shepherd_sdk::cow::order_data_to_body(&order_data), +fn signed_intent_body( + order: &GPv2OrderData, + signature: &Bytes, + owner: Address, +) -> Option { + use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; + use cow_venue::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = gpv2_to_order_data(order)?; + Some(CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), owner: owner.into_array(), signature: signature.to_vec(), }))) - .ok() +} + +#[cfg(test)] +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + cow_venue::intent_id(&signed_intent_body(order, signature, owner)?).ok() } #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use alloy_primitives::{B256, U256, address, b256, hex}; + use cow_venue::CowVenue; use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; use nexum_sdk::Level; use nexum_sdk::host::LocalStoreHost as _; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::{CowApiError, OrderRejection}; - use shepherd_sdk_test::MockHost; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + }; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// Scripted [`VenueTransport`]: one submit outcome per queued entry, + /// every submit recorded. Quote, status, and cancel are off the + /// module's poll path. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } + } + + impl SealedTransport for &MockVenue {} + + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + /// Dispatch one block through `on_block` with the typed client over + /// the scripted transport. + fn dispatch(host: &MockHost, venue: &MockVenue, block: BlockInfo) -> Result<(), Fault> { + on_block(host, &CowClient::with_transport(venue), block) + } + /// `validTo` a given number of seconds from now. The constructor's /// client-side max-horizon policy reads the wall clock (not the /// block clock), so test orders must expire relative to it. @@ -384,7 +469,7 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - // ---- MockHost dispatch tests ---- + // ---- MockHost + MockVenue dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed /// `ConditionalOrderCreated`, assembled through the same WIT-edge @@ -478,6 +563,7 @@ mod tests { #[test] fn poll_skips_when_next_block_gate_is_in_future() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); let key = seed_watch(&host, owner, ¶ms); @@ -491,19 +577,20 @@ mod tests { ) .unwrap(); - on_block(&host, sample_block(100)).unwrap(); + dispatch(&host, &venue, sample_block(100)).unwrap(); assert_eq!( host.chain.call_count(), 0, "gated watch must not issue eth_call" ); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); } #[test] - fn poll_ready_submits_order_and_persists_the_intent_id() { + fn poll_ready_submits_the_intent_body_through_the_pool() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -516,14 +603,28 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + let expected_body = signed_intent_body(&ready_order, &signature, owner) + .expect("canonical markers") + .to_bytes() + .expect("body encodes"); let expected_id = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!( + submits[0].0, + CowVenue::ID.as_str(), + "routed to the cow venue" + ); + assert_eq!( + submits[0].1, expected_body, + "the wire carries the intent body" + ); assert!( host.store .snapshot() @@ -532,21 +633,22 @@ mod tests { ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the pre-submit intent-id, not the server receipt" + "marker must key on the pre-submit intent-id, not the venue receipt" ); } /// Regression guard: when `getTradeableOrderWithSignature` /// returns the same Ready tuple in consecutive poll-ticks (the /// on-chain conditional order does not know shepherd already - /// POSTed it), the second tick must NOT call `submit_order` - /// again. Without the guard the orderbook responds with - /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{intent_id}` - /// short-circuit at the top of `submit_ready`. + /// posted it), the second tick must NOT submit again. Without the + /// guard the venue refuses the duplicate and a Warn fires for what + /// is in fact correct, finished work. The guard is the + /// `submitted:{intent_id}` short-circuit at the top of + /// `submit_ready`. #[test] fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -562,14 +664,14 @@ mod tests { // Seed the marker that a previous successful poll-tick would // have written. The poll path must read this and skip; the - // orderbook submit must not be attempted. + // venue submit must not be attempted. let already_submitted = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); host.store .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), @@ -577,28 +679,20 @@ mod tests { "poll still consults the chain to see Ready", ); assert_eq!( - host.cow_api.call_count(), + venue.submit_count(), 0, - "submit_order must NOT be called when submitted:{{intent_id}} already exists", - ); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "the REST passthrough must NOT be touched - the guard short-circuits early", + "the pool must NOT be touched when submitted:{{intent_id}} already exists", ); } - /// A Ready order with a non-empty `appData` digest submits the - /// digest verbatim as the hash-only wire shape: `appData` carries - /// the `0x`-hex digest, `appDataHash` is absent, and no orderbook - /// GET runs first - watch-tower parity. The absence of - /// `appDataHash` is load-bearing: with both fields present the - /// orderbook reads the body as the full-document shape and rejects - /// it for a digest mismatch. + /// A Ready order with a non-empty `appData` digest rides the intent + /// body verbatim: assembly into the orderbook wire shape is the + /// adapter's, so the keeper ships exactly the digest the chain + /// returned - watch-tower parity. #[test] - fn poll_ready_submits_non_empty_app_data_hash_only() { - use alloy_primitives::keccak256; + fn poll_ready_carries_a_non_empty_app_data_digest_in_the_body() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -614,28 +708,25 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), 1, "exactly one eth_call to poll Ready" ); - assert_eq!(host.cow_api.call_count(), 1, "exactly one orderbook submit"); - assert!( - host.cow_api.request_calls().is_empty(), - "no appData GET before submit - the digest goes out verbatim", - ); - let body = host.cow_api.last_body_as_json().expect("body is JSON"); + let submits = venue.submits(); + assert_eq!(submits.len(), 1, "exactly one pool submit"); + let cow_venue::CowIntentBody::V1(cow_venue::CowIntent::Signed(signed)) = + cow_venue::CowIntentBody::from_bytes(&submits[0].1).expect("body decodes") + else { + panic!("expected a signed V1 intent"); + }; assert_eq!( - body["appData"], - format!("0x{}", alloy_primitives::hex::encode(app_data_hash)), - ); - assert!( - body.get("appDataHash").is_none(), - "hash-only body must omit appDataHash, got: {body}" + signed.order.app_data, app_data_hash.0, + "the digest goes out verbatim in the body", ); let expected_id = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); @@ -648,8 +739,9 @@ mod tests { } #[test] - fn submit_transient_error_leaves_state_unchanged_for_next_block() { + fn submit_transient_fault_leaves_state_unchanged_for_next_block() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -663,17 +755,13 @@ mod tests { Ok(quoted_hex(&wire)), ); - // InsufficientFee classifies as TryNextBlock per the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + // The adapter projects a retriable rejection onto `unavailable`, + // which the retry table folds to TryNextBlock. + venue.enqueue_submit(Err(VenueFault::Unavailable( + "InsufficientFee: fee too low".into(), + ))); + + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); // Watch still present, no gate written, no submitted marker. @@ -697,9 +785,13 @@ mod tests { }); } + /// The venue's throttle hint survives the pool seam: a rate-limited + /// refusal backs the watch off on the epoch clock instead of + /// hot-looping the submit every block. #[test] - fn submit_permanent_error_drops_watch() { + fn submit_rate_limited_backs_off_on_the_epoch_gate() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -712,22 +804,55 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); - // InvalidSignature classifies as Drop. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); - on_block(&host, sample_block(1_000)).unwrap(); + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&watch_key_str), + "backoff must keep the watch" + ); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + assert_eq!( + snapshot + .get(&format!("next_epoch:{owner_hex}:{hash_hex}")) + .unwrap(), + &(1_700_000_000_u64 + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + } + + #[test] + fn submit_denied_drops_watch() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order, signature).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + + // The adapter projects a permanent rejection onto `denied`, + // which the retry table folds to Drop. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); let store = host.store.snapshot(); assert!( !store.contains_key(&watch_key_str), - "permanent error must drop the watch" + "permanent refusal must drop the watch" ); let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); @@ -746,6 +871,7 @@ mod tests { use nexum_sdk::host::RpcError; let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -771,7 +897,7 @@ mod tests { })), ); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); @@ -781,11 +907,7 @@ mod tests { .snapshot() .contains_key(&format!("next_block:{owner_hex}:{hash_hex}")), ); - assert_eq!( - host.cow_api.call_count(), - 0, - "revert-to-drop path never submits" - ); + assert_eq!(venue.submit_count(), 0, "revert-to-drop path never submits"); // The destructive drop carries its cause: the revert selector // and the node's message ride the Warn, and the keeper logs // the removal itself. From fdbe663b1daf8b0cde94043f255c0314e7d773a0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 20:56:52 +0000 Subject: [PATCH 47/53] cow: match the adapter manifest chain to each engine config's run The adapter fixes its orderbook at init from its manifest chain, so a Sepolia run wired to the mainnet manifest submits to the wrong orderbook. Add per-chain manifest variants (sepolia, load-mock) and point every twap-wired engine config at the one matching the chain it indexes. --- Dockerfile | 5 ++++- crates/cow-venue/module.load.toml | 24 ++++++++++++++++++++++++ crates/cow-venue/module.sepolia.toml | 24 ++++++++++++++++++++++++ crates/cow-venue/module.toml | 3 ++- engine.docker.toml | 6 ++++-- engine.e2e.toml | 5 +++-- engine.example.toml | 4 +++- engine.load.toml | 11 +++++------ engine.m2.toml | 5 +++-- engine.soak.docker.toml | 5 +++-- engine.soak.toml | 5 +++-- 11 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 crates/cow-venue/module.load.toml create mode 100644 crates/cow-venue/module.sepolia.toml diff --git a/Dockerfile b/Dockerfile index 1fbaba16..bc53f0fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -128,9 +128,12 @@ COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepher COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml -# The bundled cow venue adapter's manifest; installed via the +# The bundled cow venue adapter's manifests; installed via the # engine.toml [[adapters]] stanza, never compiled into the engine. +# One manifest per chain: mainnet (cow-venue.toml) and Sepolia +# (cow-venue.sepolia.toml); pick the one matching the run's chain. COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and diff --git a/crates/cow-venue/module.load.toml b/crates/cow-venue/module.load.toml new file mode 100644 index 00000000..b28b4a4a --- /dev/null +++ b/crates/cow-venue/module.load.toml @@ -0,0 +1,24 @@ +# Load-test variant of the cow adapter manifest: Sepolia chain id with +# the orderbook re-pointed at tools/orderbook-mock (no live cow.fi). + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["localhost"] + +[config] +chain = "11155111" +orderbook-url = "http://localhost:9999" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.sepolia.toml b/crates/cow-venue/module.sepolia.toml new file mode 100644 index 00000000..35635f09 --- /dev/null +++ b/crates/cow-venue/module.sepolia.toml @@ -0,0 +1,24 @@ +# Sepolia variant of the cow adapter manifest: same component, chain +# 11155111, so submits land on the Sepolia orderbook. Wire this from +# any engine config whose watchers index Sepolia. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +[config] +chain = "11155111" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.toml b/crates/cow-venue/module.toml index b660014b..0e5b555c 100644 --- a/crates/cow-venue/module.toml +++ b/crates/cow-venue/module.toml @@ -19,7 +19,8 @@ allow = ["api.cow.fi"] # One adapter instance speaks one chain's orderbook. `orderbook-url`, # `owner` (enables the pre-sign path), and `http-timeout-ms` are -# optional overrides. +# optional overrides. Sepolia and load-mock variants sit alongside +# (`module.sepolia.toml`, `module.load.toml`). [config] chain = "1" diff --git a/engine.docker.toml b/engine.docker.toml index 151f3de8..e2e697a3 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -82,9 +82,11 @@ manifest = "/opt/shepherd/manifests/stop-loss.toml" # # The bundled cow venue adapter: the pool router resolves the `cow` # venue id through it. The operator grant scopes its outbound HTTP to -# the production orderbook. +# the production orderbook host. The Sepolia manifest matches +# twap-monitor's Sepolia subscriptions; a mainnet run swaps in +# /opt/shepherd/manifests/cow-venue.toml. [[adapters]] path = "/opt/shepherd/modules/cow_venue.wasm" -manifest = "/opt/shepherd/manifests/cow-venue.toml" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.e2e.toml b/engine.e2e.toml index 50ce3063..31960778 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -69,8 +69,9 @@ manifest = "modules/examples/stop-loss/module.toml" # --- adapters --------------------------------------------------------- # The cow venue adapter twap-monitor submits through (`just -# build-cow-venue`). +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.toml" +manifest = "crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index 77dc3145..be346456 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -107,7 +107,9 @@ rpc_url = "${BASE_RPC_URL}" # The operator, not the adapter author, grants the transport scope: # `http_allow` is the outbound wasi:http host allowlist. The bundled # cow adapter builds with `just build-cow-venue`; its venue id is the -# manifest name (`cow`). +# manifest name (`cow`). One manifest per chain: `module.toml` is +# mainnet, `module.sepolia.toml` Sepolia - wire the one matching the +# chain the submitting modules index. # [[adapters]] # path = "target/wasm32-wasip2/release/cow_venue.wasm" diff --git a/engine.load.toml b/engine.load.toml index ee8bc03f..44924b49 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -42,14 +42,13 @@ path = "./target/wasm32-wasip2/release/twap_monitor.wasm" manifest = "./modules/twap-monitor/module.toml" # The cow venue adapter twap-monitor submits through (`just -# build-cow-venue`). Its orderbook target comes from the adapter's own -# `[config]`; re-pointing pool submits at the orderbook mock needs a -# load-variant adapter manifest, so a load run currently exercises the -# submit path against the adapter's configured orderbook. +# build-cow-venue`). Load-variant manifest: Sepolia chain id with the +# orderbook re-pointed at tools/orderbook-mock, matching +# [extensions.cow.orderbook_urls] above. [[adapters]] path = "./target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "./crates/cow-venue/module.toml" -http_allow = ["api.cow.fi"] +manifest = "./crates/cow-venue/module.load.toml" +http_allow = ["localhost"] [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" diff --git a/engine.m2.toml b/engine.m2.toml index eecae31d..11f29389 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -35,8 +35,9 @@ path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "modules/ethflow-watcher/module.toml" # The cow venue adapter twap-monitor submits through (`just -# build-cow-venue`). +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.toml" +manifest = "crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.soak.docker.toml b/engine.soak.docker.toml index 9e121b67..7efd05cc 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -49,8 +49,9 @@ manifest = "/opt/shepherd/manifests/stop-loss.toml" # --- adapters ----------------------------------------------------------- -# The cow venue adapter twap-monitor submits through. +# The cow venue adapter twap-monitor submits through. Sepolia +# manifest: the adapter's orderbook must match the chain twap indexes. [[adapters]] path = "/opt/shepherd/modules/cow_venue.wasm" -manifest = "/opt/shepherd/manifests/cow-venue.toml" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.soak.toml b/engine.soak.toml index c9501a08..ba3e78d7 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -72,8 +72,9 @@ manifest = "modules/examples/stop-loss/module.toml" # --- adapters --------------------------------------------------------- # The cow venue adapter twap-monitor submits through (`just -# build-cow-venue`). +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.toml" +manifest = "crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] From e46ae5c7d6b0f5da38fc69f49e259aa14daa9548 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 20:38:05 +0000 Subject: [PATCH 48/53] modules: re-point ethflow-watcher onto the pool observe path The videre client face gains observe: put an externally-obtained receipt (an on-chain placement the pool never submitted) under the registry's status watch. The registry resolves the venue and admits the watch without touching the adapter; refusal at the watch cap is a typed unavailable. HostVenues and the typed VenueClient carry the new verb; the transport trait defaults it to unsupported so existing transports opt in. ethflow-watcher flips onto that seam: the module is now a the legacy cow-api extension. on_chain_logs computes each placement's orderbook UID and observes it at the cow venue; the registry polls the adapter and fans intent-status transitions back, and the module journals observed:{uid} on the first one. Observe-only is preserved: no call path reaches quote, submit, status, or cancel. The shepherd-backtest rlib replay drives the same strategy pair over a recording pool transport and delivers the open transition the registry would poll, keeping the Observed classification exact-UID strict. The ethflow boot e2e moves from the cow-api extension suite to the venue platform suite. --- Cargo.lock | 9 +- crates/shepherd-backtest/Cargo.toml | 4 +- crates/shepherd-backtest/src/main.rs | 4 +- crates/shepherd-backtest/src/replay.rs | 242 +++++--- crates/shepherd-backtest/src/report.rs | 11 +- crates/shepherd-cow-host/Cargo.toml | 1 - crates/shepherd-cow-host/tests/cow_boot.rs | 43 +- crates/videre-host/src/bindings.rs | 7 +- crates/videre-host/src/client.rs | 4 + crates/videre-host/src/registry.rs | 110 +++- crates/videre-host/tests/platform.rs | 38 ++ crates/videre-sdk/src/client.rs | 23 + modules/ethflow-watcher/Cargo.toml | 4 +- modules/ethflow-watcher/module.toml | 25 +- modules/ethflow-watcher/src/lib.rs | 97 ++-- modules/ethflow-watcher/src/strategy.rs | 610 +++++++++++---------- wit/videre-venue/venue.wit | 4 + 17 files changed, 766 insertions(+), 470 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dddca7c6..2e31d334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2300,12 +2300,12 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", - "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -5198,12 +5198,14 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "cow-venue", "ethflow-watcher", "hex", "nexum-sdk", + "nexum-sdk-test", "serde", "serde_json", - "shepherd-sdk-test", + "videre-sdk", ] [[package]] @@ -5212,7 +5214,6 @@ version = "0.2.0" dependencies = [ "alloy-chains", "alloy-primitives", - "alloy-rpc-types-eth", "anyhow", "cowprotocol", "http", diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml index 2ec14aa6..e424d50c 100644 --- a/crates/shepherd-backtest/Cargo.toml +++ b/crates/shepherd-backtest/Cargo.toml @@ -15,8 +15,10 @@ path = "src/main.rs" # (alongside its wasm cdylib) specifically so this crate can drive # `strategy::on_chain_logs` directly without an embedded runtime. ethflow-watcher = { path = "../../modules/ethflow-watcher" } +cow-venue = { path = "../cow-venue", features = ["client"] } nexum-sdk = { path = "../nexum-sdk" } -shepherd-sdk-test = { path = "../shepherd-sdk-test" } +nexum-sdk-test = { path = "../nexum-sdk-test" } +videre-sdk = { path = "../videre-sdk" } anyhow.workspace = true clap.workspace = true diff --git a/crates/shepherd-backtest/src/main.rs b/crates/shepherd-backtest/src/main.rs index 45d446b6..6d0ff80d 100644 --- a/crates/shepherd-backtest/src/main.rs +++ b/crates/shepherd-backtest/src/main.rs @@ -3,8 +3,8 @@ //! Offline replay harness for Shepherd modules. Loads a fixtures //! JSON produced by `tools/backtest-collect/backtest_collect.py`, //! drives each on-chain event through the production strategy code -//! via `shepherd_sdk_test::MockHost`, classifies the result, and -//! emits a Markdown report at +//! over `nexum_sdk_test::MockHost` and a recording pool transport, +//! classifies the result, and emits a Markdown report at //! `docs/operations/backtest-reports/backtest-7d-YYYY-MM-DD.md`. //! //! ## Scope diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs index 71a0aef1..36649f7b 100644 --- a/crates/shepherd-backtest/src/replay.rs +++ b/crates/shepherd-backtest/src/replay.rs @@ -1,32 +1,37 @@ -//! Per-event replay against `ethflow_watcher::strategy::on_chain_logs`. +//! Per-event replay against the ethflow-watcher strategy pair. //! -//! Each [`EthFlowFixture`] is driven through the production strategy -//! exactly the way the live engine does it: a fresh [`MockHost`] is -//! constructed, a catch-all 200 response is programmed for any -//! `cow_api_request` call (the observe+verify strategy GETs -//! `/api/v1/orders/{uid}` to confirm the orderbook has indexed the -//! order), and `strategy::on_chain_logs` is invoked with an alloy -//! `Log` reconstructed from the raw `eth_getLogs` payload. +//! Each [`EthFlowFixture`] is driven the way the live engine does it, +//! minus the wasm boundary: `strategy::on_chain_logs` runs over a +//! recording venue transport (the pool seam), then the status +//! transition the registry would poll for the watched receipt is +//! delivered through `strategy::on_intent_status`. In backtest context +//! all fixtures are confirmed real orders, so the simulated transition +//! is `open`. //! -//! The classification falls into one of the four buckets defined in -//! the issue: +//! The classification falls into one of four buckets: //! -//! - `Observed`: the strategy verified the order with exactly one -//! `GET /api/v1/orders/{uid}` and wrote `observed:{uid}` to the -//! local store. This is the success case under the observe+verify -//! strategy. -//! - `RejectedExpected`: the strategy returned without observing in a -//! documented case (reserved for fixtures where the mock returns 404 -//! — not applicable when all fixtures program 200). +//! - `Observed`: the strategy registered exactly one `cow` watch whose +//! receipt is the fixture's UID and wrote `observed:{uid}` on the +//! delivered transition. The success case. +//! - `RejectedExpected`: reserved for a documented skip path; not +//! emitted in the current batch. //! - `RejectedUnexpected`: the strategy returned Ok but the observe -//! contract was violated (no `observed:{uid}` marker, an unexpected -//! orderbook call shape, or a `submit_order` attempt); a follow-up -//! should be filed before the report closes. -//! - `StrategyError`: `on_chain_logs` returned `Err(fault)`. A test +//! contract was violated (a wrong watch shape, a non-observe venue +//! call, or a missing `observed:{uid}` marker); a follow-up should +//! be filed before the report closes. +//! - `StrategyError`: a strategy call returned `Err(fault)`. A test //! bug or an `unreachable!` we want to investigate. +use std::cell::RefCell; +use std::rc::Rc; + +use cow_venue::client::CowClient; use ethflow_watcher::strategy; -use shepherd_sdk_test::MockHost; +use nexum_sdk::status_body::{IntentStatus, StatusBody}; +use nexum_sdk_test::{CapturedEvents, MockHost, capture_tracing}; +use videre_sdk::client::{VenueId, VenueTransport}; +use videre_sdk::rt::complete; +use videre_sdk::{Quotation, SubmitOutcome, VenueFault}; use crate::fixtures::{EthFlowFixture, parse_address}; @@ -45,9 +50,8 @@ pub struct ReplayOutcome { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Classification { Observed, - /// Reserved for any documented skip path (e.g. a fixture where the mock - /// returns 404 for an un-indexed order). Not emitted in the current batch; - /// retained so the acceptance-ratio formula is complete. + /// Reserved for any documented skip path. Not emitted in the current + /// batch; retained so the acceptance-ratio formula is complete. #[allow(dead_code)] RejectedExpected(String), RejectedUnexpected(String), @@ -74,16 +78,94 @@ impl Classification { } } -/// Replay one EthFlow fixture through the production strategy. +/// One recorded transport call: the verb, and for `observe` the routed +/// venue and receipt. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Call { + Quote, + Submit, + Observe(String, Vec), + Status, + Cancel, +} + +impl Call { + fn label(&self) -> &'static str { + match self { + Call::Quote => "quote", + Call::Submit => "submit", + Call::Observe(..) => "observe", + Call::Status => "status", + Call::Cancel => "cancel", + } + } +} + +/// Records every pool call; `observe` accepts, the other verbs refuse. +/// Cloneable over shared state so the replay keeps a handle after one +/// moves into the client. +#[derive(Clone, Default)] +struct RecordingVenues { + calls: Rc>>, +} + +impl RecordingVenues { + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } +} + +impl videre_sdk::client::sealed::SealedTransport for RecordingVenues {} + +impl VenueTransport for RecordingVenues { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Quote); + Err(VenueFault::Unsupported) + } + + async fn submit(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Submit); + Err(VenueFault::Unsupported) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + self.calls + .borrow_mut() + .push(Call::Observe(venue.to_string(), receipt.to_vec())); + Ok(()) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + self.calls.borrow_mut().push(Call::Status); + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + self.calls.borrow_mut().push(Call::Cancel); + Err(VenueFault::Unsupported) + } +} + +/// The `open` transition the registry would report for an indexed order. +fn open_status() -> Result, String> { + StatusBody { + status: IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .map_err(|e| format!("status body encode: {e}")) +} + +/// Replay one EthFlow fixture through the production strategy pair. pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { let host = MockHost::new(); - - // Program a catch-all 200 response for any cow_api_request. In - // the observe+verify strategy the module GETs - // `/api/v1/orders/{uid}` to confirm the orderbook has indexed the - // order. In backtest context all fixtures are confirmed real orders, - // so the mock orderbook always returns 200 (indexed). - host.cow_api.respond_to_request(Ok("{}".to_string())); + let venues = RecordingVenues::default(); + let client = CowClient::with_transport(venues.clone()); // Reconstruct the log fields. Topics + data come straight from the // collector's `raw_log`; the contract address is the EthFlow @@ -120,18 +202,32 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { } .into(); - // Drive the strategy. - let result = strategy::on_chain_logs(&host, chain_id, &[log]); - let log_lines: Vec = host - .logging - .lines() - .into_iter() - .map(|l| format!("[{:?}] {}", l.level, l.message)) - .collect(); + // Drive the strategy: register the watch, then deliver the status + // transition the registry would poll for the watched receipt. + let (result, logs) = capture_tracing(|| { + complete(strategy::on_chain_logs(&host, &client, chain_id, &[log])) + .ok_or_else(|| "strategy future suspended".to_owned()) + .and_then(|r| r.map_err(|e| e.to_string()))?; + let calls = venues.calls(); + let [Call::Observe(venue, receipt)] = calls.as_slice() else { + let shapes: Vec<&str> = calls.iter().map(Call::label).collect(); + return Err(format!( + "expected exactly one observe; saw [{}]", + shapes.join(", ") + )); + }; + if venue != "cow" { + return Err(format!("watch routed to venue `{venue}`, not `cow`")); + } + strategy::on_intent_status(&host, venue, receipt, &open_status()?) + .map_err(|e| e.to_string())?; + Ok(receipt.clone()) + }); + let log_lines = log_lines(&logs); let class = match result { - Err(e) => Classification::StrategyError(e.to_string()), - Ok(()) => classify_ok(&host, &fx.uid, &log_lines), + Err(detail) => classify_err(&venues, detail), + Ok(receipt) => classify_ok(&host, &receipt, &fx.uid, &log_lines), }; ReplayOutcome { @@ -143,6 +239,13 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { } } +fn log_lines(logs: &CapturedEvents) -> Vec { + logs.events() + .into_iter() + .map(|e| format!("[{:?}] {}", e.level, e.message)) + .collect() +} + fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { ReplayOutcome { uid: fx.uid.clone(), @@ -153,31 +256,40 @@ fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { } } -/// Classify an `Ok(())` replay by inspecting the mock's recorded -/// side effects, independent of the strategy's own logging. +/// A failed replay is an observe-contract violation when the strategy +/// itself returned Ok but touched the pool wrongly; otherwise a +/// strategy error. +fn classify_err(venues: &RecordingVenues, detail: String) -> Classification { + if venues + .calls() + .iter() + .any(|c| !matches!(c, Call::Observe(..))) + { + return Classification::RejectedUnexpected(format!( + "strategy called a non-observe venue verb; observe-only must never submit ({detail})" + )); + } + if detail.starts_with("expected exactly one observe") + || detail.starts_with("watch routed to venue") + { + return Classification::RejectedUnexpected(detail); + } + Classification::StrategyError(detail) +} + +/// Classify an `Ok` replay by inspecting the recorded side effects, +/// independent of the strategy's own logging. /// /// `Observed` demands the full observe contract, not just the marker: -/// exactly one orderbook call, shaped `GET /api/v1/orders/{uid}`, -/// zero `submit_order` attempts, and the `observed:{uid}` store key. -/// The exact-UID match (never an `observed:` prefix scan) means a -/// compute_uid divergence between module and collector cannot produce -/// a false `Observed`. -fn classify_ok(host: &MockHost, uid: &str, log_lines: &[String]) -> Classification { - if host.cow_api.call_count() > 0 { - return Classification::RejectedUnexpected( - "strategy called submit_order; observe+verify must never submit".into(), - ); - } - let requests = host.cow_api.request_calls(); - let expected_path = format!("/api/v1/orders/{uid}"); - if requests.len() != 1 || requests[0].method != "GET" || requests[0].path != expected_path { - let shapes: Vec = requests - .iter() - .map(|r| format!("{} {}", r.method, r.path)) - .collect(); +/// the one watch's receipt renders to exactly the fixture UID (never a +/// prefix scan, so a compute_uid divergence between module and +/// collector cannot produce a false `Observed`), and the delivered +/// transition wrote the `observed:{uid}` store key. +fn classify_ok(host: &MockHost, receipt: &[u8], uid: &str, log_lines: &[String]) -> Classification { + let receipt_hex = format!("0x{}", hex::encode(receipt)); + if receipt_hex != uid { return Classification::RejectedUnexpected(format!( - "expected exactly one GET {expected_path}; saw [{}]", - shapes.join(", ") + "watched receipt {receipt_hex} does not match the collector UID" )); } if host diff --git a/crates/shepherd-backtest/src/report.rs b/crates/shepherd-backtest/src/report.rs index 9e64b9fd..a27b28ae 100644 --- a/crates/shepherd-backtest/src/report.rs +++ b/crates/shepherd-backtest/src/report.rs @@ -37,11 +37,12 @@ pub fn render(fx: &Fixtures, outcomes: &[ReplayOutcome], threshold: f64) -> Stri )); out.push_str( "Replays every collected EthFlow `OrderPlacement` event through the production \ - `ethflow_watcher::strategy::on_chain_logs` code path via `shepherd_sdk_test::MockHost`. \ - The orderbook is **never hit**: the MockHost programs a catch-all 200 for all \ - `cow_api_request` calls so the observe+verify strategy sees every fixture as \ - already indexed. Success is measured by whether the strategy wrote the exact \ - `observed:{uid}` marker to the local store after the 200 confirmation.\n\n", + `ethflow_watcher::strategy` pair (`on_chain_logs` then `on_intent_status`) over \ + `nexum_sdk_test::MockHost` and a recording pool transport. The orderbook is \ + **never hit**: the transport accepts every watch and the replay delivers the \ + `open` transition the registry would poll for it, so every fixture reads as \ + indexed. Success is measured by whether the strategy watched exactly the \ + fixture UID and wrote the exact `observed:{uid}` marker on the transition.\n\n", ); out.push_str("## Run metadata\n\n"); out.push_str("| Field | Value |\n|---|---|\n"); diff --git a/crates/shepherd-cow-host/Cargo.toml b/crates/shepherd-cow-host/Cargo.toml index a5113911..47ba5e1e 100644 --- a/crates/shepherd-cow-host/Cargo.toml +++ b/crates/shepherd-cow-host/Cargo.toml @@ -50,4 +50,3 @@ toml.workspace = true tokio.workspace = true wiremock.workspace = true tempfile.workspace = true -alloy-rpc-types-eth.workspace = true diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 3953ab6a..b195b537 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -8,7 +8,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use alloy_chains::Chain; use nexum_runtime::bindings::nexum; use nexum_runtime::engine_config::{EngineConfig, ModuleLimits}; use nexum_runtime::host::component::{Components, RuntimeTypes}; @@ -144,38 +143,6 @@ async fn boot_production_module( .expect("boot_single") } -/// ethflow-watcher imports `shepherd:cow/cow-api` and subscribes to logs; -/// it boots with the cow extension and a synthetic log is delivered. -#[tokio::test] -async fn e2e_ethflow_watcher_log_dispatch() { - let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { - return; - }; - let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.alive_count(), 1); - - // A log with an unrecognised topic is silently skipped by the module's - // decoder (returns `None` from `decode_order_placement`), so the test - // only proves: supervisor delivered, module did not trap, module stayed - // alive. - let synthetic_log = alloy_rpc_types_eth::Log::default(); - let dispatched = supervisor - .dispatch_chain_log( - "ethflow-watcher", - Chain::from_id(SEPOLIA), - synthetic_log, - None, - ) - .await; - assert!(dispatched); - assert_eq!(supervisor.alive_count(), 1); -} - /// stop-loss imports `shepherd:cow/cow-api`; it boots with the cow /// extension and a block dispatch reaches it. #[tokio::test] @@ -195,17 +162,17 @@ async fn e2e_stop_loss_block_dispatch() { } /// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (ethflow-watcher) must -/// NOT boot when the cow extension is absent from the linker AND the +/// a module that imports `shepherd:cow/cow-api` (stop-loss) must NOT +/// boot when the cow extension is absent from the linker AND the /// capability registry. The paired linker-hook + capability-namespace /// registration is what makes the same module boot in the tests above; /// drop the pairing and boot fails. #[tokio::test] -async fn ethflow_watcher_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { +async fn stop_loss_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("stop-loss") else { return; }; - let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); + let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); let engine = make_wasmtime_engine(); // Core-only: no cow linker hook, no cow capability namespace. let linker = build_linker::(&engine, &[]).expect("build_linker"); diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs index c07618a9..06e2b887 100644 --- a/crates/videre-host/src/bindings.rs +++ b/crates/videre-host/src/bindings.rs @@ -157,7 +157,7 @@ mod value_flow_smoke { /// client interface and, transitively, the types interface and its /// value-flow dependency. The test names every generated type, case, and /// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the four function signatures, so a keyword collision or an accidental +/// the five function signatures, so a keyword collision or an accidental /// signature change fails this build rather than a downstream binding. #[cfg(test)] mod client_smoke { @@ -193,6 +193,10 @@ mod client_smoke { Err(VenueError::UnknownVenue) } + fn observe(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + fn status( &mut self, _venue: String, @@ -264,6 +268,7 @@ mod client_smoke { let mut client = DummyClient; assert!(client.quote(String::new(), Vec::new()).is_err()); assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.observe(String::new(), Vec::new()).is_err()); assert!(client.status(String::new(), Vec::new()).is_err()); assert!(client.cancel(String::new(), Vec::new()).is_err()); } diff --git a/crates/videre-host/src/client.rs b/crates/videre-host/src/client.rs index 2973b01e..cbe27e70 100644 --- a/crates/videre-host/src/client.rs +++ b/crates/videre-host/src/client.rs @@ -38,6 +38,10 @@ impl Host for HostState { .await } + async fn observe(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + registry(self)?.observe(&VenueId::from(venue), receipt) + } + async fn status( &mut self, venue: String, diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index 6a47fdf7..815205e6 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -7,7 +7,9 @@ //! header, run the guard interposition seam on it (advisory-only for now: //! see [`EgressGuard`]), and only then submit. //! Status and cancel are pass-throughs; they are not submissions, so they -//! skip the header, the guard, and the quota. +//! skip the header, the guard, and the quota. Observe puts an +//! externally-obtained receipt under status watch without touching the +//! adapter at all. //! //! Invocation is serialised per adapter through the supervised-actor //! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent @@ -318,8 +320,8 @@ struct VenueRegistryInner { ledger: Mutex, watch_limit: WatchLimit, /// Receipts under status watch, appended by accepted submissions and - /// pruned as they reach a terminal status, expire, or overflow - /// [`WatchLimit`]. + /// explicit observes, pruned as they reach a terminal status, expire, + /// or overflow [`WatchLimit`]. watched: Mutex>, } @@ -481,11 +483,26 @@ impl VenueRegistry { adapter.quote(&body).await } - /// Put a `(venue, receipt)` pair under status watch. Idempotent: a - /// re-submitted receipt keeps its existing watch entry. Bounded: - /// expired entries evict first, and at the cap the new watch is - /// refused and logged rather than an existing live watch dropped. - fn watch(&self, venue: &VenueId, receipt: Vec) { + /// Put an externally-obtained `(venue, receipt)` pair under status + /// watch: the `observe` half of the client face, for receipts the + /// pool never submitted (an accepted submit is watched implicitly). + /// Not a submission: no header, no guard, no quota; the watch cap + /// bounds it. Idempotent. + pub fn observe(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { + let _ = self.resolve(venue)?; + if self.watch(venue, receipt) { + Ok(()) + } else { + Err(VenueError::Unavailable("status watch set full".to_owned())) + } + } + + /// Put a `(venue, receipt)` pair under status watch, reporting + /// whether it is watched. Idempotent: a re-submitted receipt keeps + /// its existing watch entry. Bounded: expired entries evict first, + /// and at the cap the new watch is refused and logged rather than + /// an existing live watch dropped. + fn watch(&self, venue: &VenueId, receipt: Vec) -> bool { let (evicted, admitted) = { let mut watched = self.inner.watched.lock().expect("watch list poisoned"); let evicted = prune_expired(&mut watched); @@ -515,6 +532,7 @@ impl VenueRegistry { "status watch set full - transitions for this receipt will not be reported", ); } + admitted } /// Number of receipts currently under status watch. @@ -1444,6 +1462,82 @@ mod tests { assert_eq!(registry.watched_count(), 1); } + #[tokio::test] + async fn observe_watches_an_externally_obtained_receipt() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls.clone()).with_status_script([Ok(IntentStatus::Fulfilled)]); + let registry = registry_with(SubmitQuota::default(), None, adapter); + + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe succeeds"); + // Re-observing keeps the existing entry. + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe is idempotent"); + assert_eq!(registry.watched_count(), 1); + // No adapter work happened at observe time. + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + + // The watch polls like a submitted one: the terminal status + // reports once and prunes the entry. + let updates = registry.poll_status_transitions().await; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].receipt, b"onchain"); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Fulfilled)); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_rejects_an_unknown_venue() { + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(Arc::new(StubCalls::default())), + ); + assert!(matches!( + registry.observe(&VenueId::from("unlisted"), b"r".to_vec()), + Err(VenueError::UnknownVenue) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_of_a_dead_venue_is_unavailable() { + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("install adapter"); + liveness.mark_dead(); + assert!(matches!( + registry.observe(&cow(), b"r".to_vec()), + Err(VenueError::Unavailable(_)) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_at_the_watch_cap_is_refused_typedly() { + let limit = WatchLimit::new(1, Duration::from_secs(3600)); + let registry = + watch_bounded_registry(limit, StubAdapter::new(Arc::new(StubCalls::default()))); + + registry.observe(&cow(), b"a".to_vec()).expect("admitted"); + let err = registry + .observe(&cow(), b"b".to_vec()) + .expect_err("overflow refused"); + assert!(matches!(err, VenueError::Unavailable(_))); + // The live watch is kept; the overflow was refused. + assert_eq!(registry.watched_count(), 1); + } + #[tokio::test] async fn requires_signing_outcome_is_not_watched() { let calls = Arc::new(StubCalls::default()); diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index ace107a8..eadd9165 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -388,6 +388,44 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { ); } +/// ethflow-watcher (built by `#[videre_sdk::keeper]`) boots on the venue +/// platform with its shipped manifest and handles a delivered cow status +/// transition without trapping. +#[tokio::test] +async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { + let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { + return; + }; + let manifest = workspace_path("modules/ethflow-watcher/module.toml"); + let videre = Arc::new(platform(&EngineConfig::default())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert_eq!(supervisor.alive_count(), 1); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + let update = videre_host::IntentStatusUpdate { + venue: "cow".to_owned(), + receipt: vec![0xAB; 56], + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(update)) + .await, + 1 + ); + assert_eq!(supervisor.alive_count(), 1); +} + /// The event-loop wiring, through the real seam: the platform's `events` /// source opens against the booted service map, its poll task drives the /// supervisor, and the module's handler observably ran (its log line is diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index c3e9b1af..63ff375d 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -103,6 +103,19 @@ pub trait VenueTransport: sealed::SealedTransport { body: Vec, ) -> impl Future>; + /// Put an externally-obtained receipt under the host's status + /// watch; an accepted submit is watched implicitly. Defaults to + /// `unsupported`: a transport that can watch foreign receipts opts + /// in. + fn observe( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future> { + let _ = (venue, receipt); + async { Err(VenueFault::Unsupported) } + } + /// Report where a previously submitted intent is in its life. fn status( &self, @@ -137,6 +150,10 @@ impl VenueTransport for HostVenues { shims::submit(venue.as_str(), &body).map_err(VenueFault::from) } + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + shims::observe(venue.as_str(), receipt).map_err(VenueFault::from) + } + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { shims::status(venue.as_str(), receipt).map_err(VenueFault::from) } @@ -208,6 +225,12 @@ impl VenueClient { Ok(self.transport.submit(&V::ID, bytes).await?) } + /// Put an externally-obtained receipt under the host's status + /// watch at the bound venue. + pub async fn observe(&self, receipt: &[u8]) -> Result<(), ClientError> { + Ok(self.transport.observe(&V::ID, receipt).await?) + } + /// Report where a previously submitted intent is in its life. pub async fn status(&self, receipt: &[u8]) -> Result { Ok(self.transport.status(&V::ID, receipt).await?) diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index 3beb4710..eff3f0ba 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -14,7 +14,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-sdk" } +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -22,5 +23,4 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index 47cc57c0..ae9cb5f0 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -1,6 +1,6 @@ -# ethflow-watcher: see `CoWSwapEthFlow.OrderPlacement`, lift the embedded -# `GPv2OrderData` into an `OrderCreation`, and submit it via the CoW -# Protocol orderbook with the EIP-1271 signing scheme. +# ethflow-watcher: decode `CoWSwapEthFlow.OrderPlacement` logs, compute +# the orderbook UID, and put it under the pool's status watch via the +# cow venue adapter. Observe-only: the module never submits. [module] name = "ethflow-watcher" @@ -10,16 +10,13 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -# Least-privilege: the module exercises logging, local-store and -# cow-api today; `chain` is listed as optional so a follow-up (e.g. -# adding an eth_call to read the EthFlow refund pointer) -# can use it without manifest churn, without widening the required -# grant for a capability the module does not call yet. -required = ["logging", "local-store", "cow-api"] -optional = ["chain"] +# Least-privilege: `client` for the pool observe path, `logging` for +# structured runtime logs, `local-store` for the `observed:` journal. +required = ["client", "logging", "local-store"] +optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api`; no direct `http` calls. +# All venue I/O rides the pool router; no direct `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ @@ -39,3 +36,9 @@ kind = "chain-log" chain_id = 11155111 address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + +# Status transitions the registry polls for watched receipts at the cow +# venue; the module journals `observed:{uid}` on the first one. +[[subscription]] +kind = "intent-status" +venue = "cow" diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 8cca9f36..67c92f9e 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -1,23 +1,21 @@ //! # ethflow-watcher (Shepherd module) //! //! Subscribes to `CoWSwapOnchainOrders.OrderPlacement` logs from the -//! canonical CoWSwap EthFlow contracts and verifies the orderbook's -//! native indexer caught each placement via `GET /api/v1/orders/{uid}`. -//! See `strategy.rs` for the design rationale: the orderbook -//! backend indexes EthFlow `OrderPlacement` events server-side with -//! its own dual-validTo bookkeeping, so `POST /api/v1/orders` is -//! structurally the wrong endpoint for on-chain EthFlow orders. The -//! module observes and verifies, it does not submit. +//! canonical CoWSwap EthFlow contracts, computes each placement's +//! orderbook UID, and puts it under the pool's status watch through the +//! typed cow venue client. The registry polls the cow adapter and fans +//! transitions back as `intent-status` events; the module journals +//! `observed:{uid}` on the first one. Observe-only: it never submits +//! (see `strategy.rs`). //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates the `chain-logs` event variant to `strategy::on_chain_logs`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` and `videre_sdk` client seams. It does not know +//! `wit-bindgen` exists. +//! - `lib.rs` (this file) is the per-cdylib glue: the +//! `#[videre_sdk::keeper]` handler impl that binds the world derived +//! from `module.toml` and delegates each event to `strategy`. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -25,59 +23,48 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -// The wit-bindgen-generated import shims only resolve against the -// engine's wasm component host - they have no native-target -// equivalent. Cfg-gate the entire glue layer so the `rlib` artefact -// (consumed by `shepherd-backtest`) carries just the -// strategy code without dangling `extern "C"` imports. The -// `use wit_bindgen as _` line below silences the unused-crate -// lint on native targets where the macro never expands. +// The keeper glue only resolves against the engine's wasm component +// host. Cfg-gate it so the `rlib` artefact (consumed by +// `shepherd-backtest`) carries just the strategy code without dangling +// `extern "C"` imports; the `use wit_bindgen as _` line silences the +// unused-crate lint on native targets where the macro never expands. #[cfg(not(target_arch = "wasm32"))] use wit_bindgen as _; -#[cfg(target_arch = "wasm32")] -wit_bindgen::generate!({ - path: [ - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - pub mod strategy; -// `WitBindgenHost` and the fault and level `From` impls -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -// Gated on `wasm32` so the strategy can be reused in native targets -// (e.g. the backtest replay harness in `crates/shepherd-backtest`). #[cfg(target_arch = "wasm32")] -use nexum::host::types; +mod glue { + use cow_venue::client::CowClient; -#[cfg(target_arch = "wasm32")] -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); + use crate::strategy; -#[cfg(target_arch = "wasm32")] -struct EthFlowWatcher; + struct EthFlowWatcher; -#[cfg(target_arch = "wasm32")] -impl Guest for EthFlowWatcher { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - tracing::info!("ethflow-watcher init"); - Ok(()) - } + #[videre_sdk::keeper] + impl EthFlowWatcher { + fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + tracing::info!("ethflow-watcher init"); + Ok(()) + } - fn on_event(event: types::Event) -> Result<(), Fault> { - if let types::Event::ChainLogs(batch) = event { + async fn on_chain_logs(batch: nexum::host::types::ChainLogs) -> Result<(), Fault> { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs)?; + strategy::on_chain_logs(&WitBindgenHost, &CowClient::new(), batch.chain_id, &logs) + .await?; + Ok(()) + } + + fn on_intent_status(update: nexum::host::types::IntentStatusUpdate) -> Result<(), Fault> { + strategy::on_intent_status( + &WitBindgenHost, + &update.venue, + &update.receipt, + &update.status, + )?; + Ok(()) } - // Block / Tick / Message are not used by this module. - Ok(()) } } - -#[cfg(target_arch = "wasm32")] -export!(EthFlowWatcher); diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 4d24db00..002a9984 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -1,11 +1,11 @@ //! Pure strategy logic for the ethflow-watcher module. //! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`]; tests under `#[cfg(test)]` -//! drive the same function with `shepherd_sdk_test::MockHost`. +//! Every interaction with the world flows through two seams: the +//! `nexum_sdk::host` traits for the `observed:` journal and the typed +//! [`CowClient`] over [`VenueTransport`] for the pool. The `lib.rs` +//! glue binds both to the module's own imports; tests drive the same +//! functions with `nexum_sdk_test::MockHost` and an in-memory spy +//! transport. //! //! ## Design (redesign) //! @@ -13,36 +13,35 @@ //! to `/api/v1/orders` with the EthFlow contract as the EIP-1271 owner. //! Empirical evidence (2026-06-22 Sepolia soak) showed that path cannot //! succeed: the orderbook backend indexes EthFlow `OrderPlacement` -//! events natively and writes server-only fields (`onchainUser`, -//! `onchainOrderData`, `ethflowData.userValidTo`) the public POST body -//! does not carry. Submissions through `/api/v1/orders` are rejected -//! with `ExcessiveValidTo` even though the same UID is `fulfilled` on -//! the orderbook by the time we look. +//! events natively and writes server-only fields the public POST body +//! does not carry. //! -//! This strategy therefore **observes + verifies** instead of -//! submitting: +//! This strategy therefore **observes + verifies** through the pool: //! //! 1. Decode the `OrderPlacement` log against the canonical EthFlow //! contract addresses. -//! 2. Compute the orderbook UID from the on-chain order shape -//! (`OrderData::uid(domain, contract)`). -//! 3. GET `/api/v1/orders/{uid}` to confirm the orderbook indexer -//! picked up the placement. On 200, record `observed:{uid}` in the -//! keeper idempotency journal so log re-delivery is a no-op. On -//! 404, log at Info - typical indexer lag, do not write the marker -//! so the next re-delivery rechecks. Any other error is logged at -//! Warn for operator follow-up. +//! 2. Compute the orderbook UID from the on-chain order shape: the +//! externally-obtained receipt at the cow venue. +//! 3. `observe` the receipt through the pool router, which polls the +//! cow adapter's `status` and fans transitions back as +//! `intent-status` events. On the first one, record `observed:{uid}` +//! in the keeper idempotency journal so log re-delivery is a no-op. +//! A refused observe is logged and left unjournalled, so the next +//! re-delivery retries. use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolEvent; +use cow_venue::assembly; +use cow_venue::client::{CowClient, CowVenue}; use cowprotocol::{ Chain, CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, GPv2OrderData, OnchainSignature, OrderUid, }; use nexum_sdk::events::Log; -use nexum_sdk::host::Fault; +use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, events, gpv2_to_order_data}; +use nexum_sdk::status_body::StatusBody; +use videre_sdk::client::{Venue, VenueTransport}; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays @@ -70,16 +69,51 @@ pub(crate) struct DecodedPlacement { } /// Entry point: decode every `OrderPlacement` chain-log in a dispatch batch -/// and feed each decoded placement to the observe path. -pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Result<(), Fault> { +/// and put each decoded placement's UID under the pool's status watch. +pub async fn on_chain_logs( + host: &H, + venue: &CowClient, + chain_id: u64, + logs: &[Log], +) -> Result<(), Fault> { for log in logs { if let Some(placement) = decode_order_placement(log) { - observe_placement(host, chain_id, &placement)?; + observe_placement(host, venue, chain_id, &placement).await?; } } Ok(()) } +/// A registry status transition for a watched receipt. Foreign venues +/// are ignored; the first transition for a cow receipt records the +/// `observed:{uid}` marker (the orderbook indexed the placement), and +/// every transition is logged. +pub fn on_intent_status( + host: &H, + venue: &str, + receipt: &[u8], + status: &[u8], +) -> Result<(), Fault> { + if venue != CowVenue::ID.as_str() { + return Ok(()); + } + let Ok(uid) = OrderUid::try_from(receipt) else { + tracing::warn!( + "ethflow status update with a non-uid receipt ({} bytes)", + receipt.len(), + ); + return Ok(()); + }; + let body = StatusBody::decode(status).map_err(|e| Fault::InvalidInput(e.to_string()))?; + let uid_hex = format!("{uid}"); + let journal = Journal::observed(host); + if !journal.contains(&uid_hex)? { + journal.record(&uid_hex)?; + } + tracing::info!("ethflow observed {uid_hex}: {:?}", body.status); + Ok(()) +} + // ---- decode ---- /// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. @@ -96,7 +130,7 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { return None; } - if log.topics().first() != Some(&events::ORDER_PLACEMENT.topic0) { + if log.topics().first() != Some(&OrderPlacement::SIGNATURE_HASH) { return None; } let decoded = OrderPlacement::decode_log(&log.inner).ok()?; @@ -109,57 +143,43 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { }) } -// ---- observe + verify (redesign) ---- +// ---- observe + verify (pool) ---- -/// Compute the orderbook UID for the placement and confirm the -/// orderbook's native EthFlow indexer picked it up. -fn observe_placement( +/// Compute the orderbook UID for the placement and put it under the +/// pool's status watch. A refused observe (venue down, watch set full) +/// is logged and left unjournalled, so re-delivery retries. +async fn observe_placement( host: &H, + venue: &CowClient, chain_id: u64, placement: &DecodedPlacement, ) -> Result<(), Fault> { - let uid_hex = match compute_uid(chain_id, placement) { - Some(uid) => format!("{uid}"), - None => { - tracing::warn!( - "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", - placement.sender, - ); - return Ok(()); - } + let Some(uid) = compute_uid(chain_id, placement) else { + tracing::warn!( + "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", + placement.sender, + ); + return Ok(()); }; + let uid_hex = format!("{uid}"); - // Idempotency: once verified, do not re-check on log re-delivery + // Idempotency: once observed, do not re-watch on log re-delivery // (engine restart, reorg replay, supervisor restart). let journal = Journal::observed(host); if journal.contains(&uid_hex)? { return Ok(()); } - let path = format!("/api/v1/orders/{uid_hex}"); - match host.cow_api_request(chain_id, "GET", &path, None) { - Ok(_) => { - journal.record(&uid_hex)?; + match venue.observe(uid.as_slice()).await { + Ok(()) => { tracing::info!( - "ethflow observed {uid_hex} (orderbook indexed, sender={:#x})", - placement.sender, - ); - } - Err(CowApiError::Http(http)) if http.status == 404 => { - // Indexer lag is expected immediately after the block lands - - // shepherd's WebSocket can deliver the log a few hundred - // milliseconds before the orderbook's own indexer commits. - // Do NOT write the marker so a later re-delivery (or a future - // block-tick poll) can recheck. Info keeps the soak dashboard - // quiet on normal lag. - tracing::info!( - "ethflow not yet indexed {uid_hex} (sender={:#x}); will recheck on re-delivery", + "ethflow watching {uid_hex} (sender={:#x})", placement.sender, ); } Err(err) => { tracing::warn!( - "ethflow indexer check failed {uid_hex}: {err} (sender={:#x})", + "ethflow watch failed {uid_hex}: {err} (sender={:#x})", placement.sender, ); } @@ -168,30 +188,140 @@ fn observe_placement( } /// Compute the canonical 56-byte orderbook UID for the placement. -/// `OrderData::uid` packs `digest || owner || valid_to`; the owner -/// input is the EthFlow contract (which signs via EIP-1271), not the -/// native-token sender. +/// The UID packs `digest || owner || valid_to`; the owner input is the +/// EthFlow contract (which signs via EIP-1271), not the native-token +/// sender. fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option { let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(&placement.order)?; - Some(order_data.uid(&domain, placement.contract)) + let order = assembly::gpv2_to_order_data(&placement.order)?; + Some(assembly::order_uid(chain, &order, placement.contract)) } #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + use alloy_primitives::{U256, address, hex}; use alloy_sol_types::SolValue; use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; use nexum_sdk::Level; - use nexum_sdk::host::{Fault, LocalStoreHost as _}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::HttpFailure; - use shepherd_sdk_test::MockHost; + use nexum_sdk::host::LocalStoreHost as _; + use nexum_sdk::status_body::IntentStatus as Lifecycle; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::VenueId; + use videre_sdk::rt::complete; + use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// One recorded transport call: which verb, and for `observe` the + /// routed venue and receipt. + #[derive(Clone, Debug, Eq, PartialEq)] + enum Call { + Quote, + Submit, + Observe(String, Vec), + Status, + Cancel, + } + + /// Records every call; `observe` pops a scripted response and + /// defaults to accepted once the script drains. The other verbs + /// refuse: the observe-only strategy must never reach them. + /// Cloneable over shared state so the test keeps a handle after + /// one moves into the client. + #[derive(Clone, Default)] + struct SpyVenues { + calls: Rc>>, + observe_script: Rc>>>, + } + + impl SpyVenues { + fn script_observe(&self, result: Result<(), VenueFault>) { + self.observe_script.borrow_mut().push_back(result); + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + fn observe_count(&self) -> usize { + self.calls + .borrow() + .iter() + .filter(|c| matches!(c, Call::Observe(..))) + .count() + } + } + + impl videre_sdk::client::sealed::SealedTransport for SpyVenues {} + + impl VenueTransport for SpyVenues { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Quote); + Err(VenueFault::Unsupported) + } + + async fn submit( + &self, + _venue: &VenueId, + _body: Vec, + ) -> Result { + self.calls.borrow_mut().push(Call::Submit); + Err(VenueFault::Unsupported) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + self.calls + .borrow_mut() + .push(Call::Observe(venue.to_string(), receipt.to_vec())); + self.observe_script + .borrow_mut() + .pop_front() + .unwrap_or(Ok(())) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + self.calls.borrow_mut().push(Call::Status); + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + self.calls.borrow_mut().push(Call::Cancel); + Err(VenueFault::Unsupported) + } + } + + /// Drive the async strategy on the synchronous test boundary. + fn run_logs( + host: &MockHost, + spy: &SpyVenues, + chain_id: u64, + logs: &[Log], + ) -> Result<(), Fault> { + let client = CowClient::with_transport(spy.clone()); + complete(on_chain_logs(host, &client, chain_id, logs)) + .expect("guest futures complete in one poll") + } + + fn open_status() -> Vec { + StatusBody { + status: Lifecycle::Open, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes") + } + fn sample_order() -> GPv2OrderData { GPv2OrderData { sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), @@ -246,11 +376,14 @@ mod tests { .into() } - fn computed_uid(placement: &DecodedPlacement) -> String { - format!( - "{}", - compute_uid(SEPOLIA, placement).expect("sepolia + canonical markers") - ) + fn sample_log() -> Log { + let (topics, data) = encode_log(&sample_event()); + make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data) + } + + fn sample_uid() -> OrderUid { + let placement = decode_order_placement(&sample_log()).expect("decode succeeds"); + compute_uid(SEPOLIA, &placement).expect("sepolia + canonical markers") } // ---- decode (invariants preserved) ---- @@ -258,9 +391,7 @@ mod tests { #[test] fn decodes_well_formed_placement() { let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).expect("decode succeeds"); + let decoded = decode_order_placement(&sample_log()).expect("decode succeeds"); assert_eq!(decoded.contract, ETH_FLOW_PRODUCTION); assert_eq!(decoded.sender, event.sender); assert_eq!(decoded.signature.scheme, OnchainSigningScheme::Eip1271); @@ -294,11 +425,7 @@ mod tests { #[test] fn compute_uid_pins_owner_to_ethflow_contract_and_validto() { let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); - - let uid = compute_uid(SEPOLIA, &decoded).expect("sepolia + canonical markers"); + let uid = sample_uid(); let bytes: [u8; 56] = uid.into(); // owner suffix (bytes 32..52) = EthFlow contract address. assert_eq!(&bytes[32..52], ETH_FLOW_PRODUCTION.as_slice()); @@ -311,273 +438,202 @@ mod tests { #[test] fn compute_uid_returns_none_on_unsupported_chain() { - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); + let decoded = decode_order_placement(&sample_log()).unwrap(); assert!(compute_uid(9999, &decoded).is_none()); } - // ---- observe + verify dispatch (Host-trait integration) ---- + // ---- observe via the pool (transport integration) ---- - /// 200 from `GET /api/v1/orders/{uid}` → `observed:{uid}` written - /// + Info log + zero submit attempts. + /// A placement registers exactly one status watch at the cow venue + /// with the computed UID as the receipt, and journals nothing yet: + /// the marker waits for the first status transition. #[test] - fn placement_log_marks_observed_on_orderbook_200() { + fn placement_log_registers_the_uid_watch() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - // Minimal stub of the orderbook's GET response - strategy only - // checks for 200 vs 404 vs other, the body is opaque to it. - host.cow_api.respond_to_request_for( - "GET", - format!("/api/v1/orders/{uid}"), - Ok(r#"{"status":"fulfilled"}"#.to_string()), - ); + let spy = SpyVenues::default(); + let uid = sample_uid(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 response must write observed:{{uid}} marker" - ); assert_eq!( - host.cow_api.request_calls().len(), - 1, - "exactly one orderbook GET per log" + spy.calls(), + vec![Call::Observe("cow".to_owned(), uid.as_slice().to_vec())], + "exactly one observe, nothing else", ); - assert_eq!( - host.cow_api.call_count(), - 0, - "observe path must never call submit_order" - ); - } - - /// 404 from `GET /api/v1/orders/{uid}` → no marker written + Info - /// log + the next re-delivery rechecks (no early dedup). - #[test] - fn placement_log_does_not_mark_observed_on_orderbook_404() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - }))); - - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "404 must NOT write observed: so re-delivery can recheck" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - let ev = logs.expect_one(|e| e.message.contains("not yet indexed")); - assert_eq!( - ev.level, - Level::INFO, - "indexer lag is expected; Info keeps soak dashboards quiet" + host.store.snapshot().is_empty(), + "observed:{{uid}} waits for the first status transition", ); } - /// Non-404 error from the orderbook check → Warn log + no marker. + /// A refused observe warns, journals nothing, and the next + /// re-delivery retries the watch. #[test] - fn placement_log_warns_on_orderbook_other_error() { + fn watch_refusal_warns_and_redelivery_retries() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - - host.cow_api - .respond_to_request(Err(CowApiError::Fault(Fault::Unavailable( - "bad gateway".into(), - )))); + let spy = SpyVenues::default(); + spy.script_observe(Err(VenueFault::Unavailable("venue down".to_owned()))); - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, SEPOLIA, &[sample_log()])); result.unwrap(); - assert!( - host.store.snapshot().is_empty(), - "non-404 error must not write any marker" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("watch failed")); + + // Unjournalled, so the re-delivered log observes again. + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); + assert_eq!(spy.observe_count(), 2); } /// Idempotency: a placement that already has `observed:{uid}` in - /// local store does NOT trigger a fresh GET on re-delivery. + /// local store does NOT touch the pool on re-delivery. #[test] fn previously_observed_placement_is_skipped_on_redelivery() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let spy = SpyVenues::default(); + let uid = sample_uid(); host.store .set(&format!("observed:{uid}"), b"") .expect("seed observed marker"); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "observed:{{uid}} must short-circuit before the orderbook GET" - ); - assert_eq!( - host.cow_api.call_count(), - 0, - "and certainly no submit_order" + assert!( + spy.calls().is_empty(), + "observed:{{uid}} must short-circuit before the pool", ); } /// Defensive: unsupported chain id surfaces a Warn but does not - /// panic and does not touch the orderbook. + /// panic and does not touch the pool. #[test] - fn unsupported_chain_logs_warn_without_orderbook_call() { + fn unsupported_chain_logs_warn_without_venue_call() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); + let spy = SpyVenues::default(); // 9999 is not in cowprotocol::Chain. - let (result, logs) = capture_tracing(|| on_chain_logs(&host, 9999, &[log])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, 9999, &[sample_log()])); result.unwrap(); - assert_eq!(host.cow_api.request_calls().len(), 0); - assert_eq!(host.cow_api.call_count(), 0); + assert!(spy.calls().is_empty()); assert!(host.store.snapshot().is_empty()); logs.expect_one(|e| { e.level == Level::WARN && e.message.contains("ethflow uid build skipped") }); } - /// Strategy must never call `submit_order` - the trait still - /// exposes it for other modules (twap-monitor legitimately - /// submits), but ethflow-watcher's observe design never does. - /// Belt-and-suspenders regression guard. + /// The strategy is observer-only: no call path reaches quote, + /// submit, status, or cancel. Belt-and-suspenders regression guard. #[test] - fn strategy_never_calls_submit_order() { + fn strategy_never_submits() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - host.cow_api.respond_to_request(Ok("{}".to_string())); + let spy = SpyVenues::default(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert_eq!( - host.cow_api.call_count(), - 0, - "submit_order count must stay at zero - ethflow-watcher is observer-only" + assert!( + spy.calls().iter().all(|c| matches!(c, Call::Observe(..))), + "observe is the only verb the strategy may use", ); } - /// Guard: the `sol!` decoder's topic-0 matches the - /// `shepherd:cow/cow-events` package of record. A typo or ABI - /// drift would silently miss every EthFlow event. - #[test] - fn topic0_matches_order_placement_canonical_signature() { - assert_eq!( - OrderPlacement::SIGNATURE_HASH, - events::ORDER_PLACEMENT.topic0, - "sol! topic-0 must match the shepherd:cow/cow-events pin", - ); - } + // ---- intent-status transitions ---- - /// Stronger guard than the constant check above: read the shipped - /// `module.toml` and assert its pinned `event_signature` actually - /// equals the package-of-record topic-0 - catches a manifest/code - /// drift the decoder assertion cannot see. + /// The first cow transition journals `observed:{uid}` and logs it. #[test] - fn manifest_topic0_matches_order_placement_signature_hash() { - let manifest = include_str!("../module.toml"); - let expected = format!("{:#x}", events::ORDER_PLACEMENT.topic0); + fn status_update_journals_the_observed_marker() { + let host = MockHost::new(); + let uid = sample_uid(); + + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", uid.as_slice(), &open_status())); + result.unwrap(); + assert!( - manifest.contains(&expected), - "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", + host.store + .snapshot() + .contains_key(&format!("observed:{uid}")), + "the first transition must write observed:{{uid}}", ); + let ev = logs.expect_one(|e| e.message.contains("ethflow observed")); + assert_eq!(ev.level, Level::INFO); } - /// 429 (rate-limit) from the orderbook check → Warn log + no marker. - /// Verifies the strategy does not conflate 429 with 404 (which would - /// suppress the warning) and does not panic or return an error. + /// Later transitions keep the single marker and stay Ok. #[test] - fn placement_log_warns_on_429_rate_limit() { + fn repeated_transitions_keep_one_marker() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let uid = sample_uid(); - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 429, - body: Some("Too Many Requests".to_string()), - }))); + on_intent_status(&host, "cow", uid.as_slice(), &open_status()).unwrap(); + let fulfilled = StatusBody { + status: Lifecycle::Fulfilled, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes"); + on_intent_status(&host, "cow", uid.as_slice(), &fulfilled).unwrap(); - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); + assert_eq!(host.store.snapshot().len(), 1); + } - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "429 must NOT write observed: marker" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); + /// A transition from a foreign venue is not ethflow's: ignored. + #[test] + fn foreign_venue_status_update_is_ignored() { + let host = MockHost::new(); + on_intent_status(&host, "echo-venue", sample_uid().as_slice(), &open_status()).unwrap(); + assert!(host.store.snapshot().is_empty()); } - /// HTTP 200 with a malformed (non-JSON) body → `observed:{uid}` still - /// written. The strategy only inspects Ok vs Err, never parses the body, - /// so any successful response confirms indexer pickup regardless of body - /// content. + /// A cow receipt that is not a 56-byte UID warns without a marker. #[test] - fn placement_log_marks_observed_on_malformed_response_body() { + fn non_uid_receipt_warns_without_marker() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", b"abc", &open_status())); + result.unwrap(); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("non-uid receipt")); + } - host.cow_api - .respond_to_request(Ok("not-valid-json{{{{".to_string())); + /// A status body the SDK cannot decode is a typed fault, never a + /// silent marker. + #[test] + fn malformed_status_body_is_a_typed_fault() { + let host = MockHost::new(); + let err = on_intent_status(&host, "cow", sample_uid().as_slice(), &[0xFF, 0x00]) + .expect_err("undecodable status body"); + assert!(matches!(err, Fault::InvalidInput(_))); + assert!(host.store.snapshot().is_empty()); + } - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + // ---- package-of-record parity ---- + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every EthFlow event. + #[test] + fn topic0_matches_the_cow_events_package_of_record() { + let wit = include_str!("../../../wit/shepherd-cow/cow-events.wit"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 with malformed body must still write observed: — strategy does not parse the response", + wit.contains(&expected), + "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", + ); + } + + /// Read the shipped `module.toml` and assert its pinned + /// `event_signature` equals the decoder topic-0 - catches a + /// manifest/code drift the wit assertion cannot see. + #[test] + fn manifest_topic0_matches_order_placement_signature_hash() { + let manifest = include_str!("../module.toml"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); + assert!( + manifest.contains(&expected), + "module.toml event_signature must equal the decoder topic-0 ({expected})", ); } } diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 33e73f10..8a9685f2 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -7,6 +7,10 @@ interface client { quote: func(venue: string, body: list) -> result; submit: func(venue: string, body: list) -> result; + /// Put an externally-obtained receipt (e.g. an on-chain placement) + /// under the host's status watch; an accepted submit is watched + /// implicitly. Idempotent. + observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; status: func(venue: string, receipt: receipt) -> result; cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; } From b447e35f334fb51393bc8984deb7634cc7048392 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 17 Jul 2026 23:55:27 +0000 Subject: [PATCH 49/53] cow: retire the legacy cow-api host cone Delete shepherd-cow-host, the shepherd:cow legacy worlds (cow-api, cow-ext, shepherd; cow-events stays as the event-ABI package of record), the shepherd-sdk cow surface, and the shepherd-sdk-test mocks. The shepherd binary composes the core lattice with the videre platform alone. The keeper sweep moves to composable-cow behind the sweep slice; stop-loss migrates onto #[videre_sdk::keeper] and pool submit through the typed CowClient, keyed on the venue-and-body intent-id. twap gates topic-0 on the sol decoder pinned against cow-events; the WIT layering gate moves to cow-venue. ADR-0005/0006 marked superseded. --- Cargo.lock | 119 +--- Cargo.toml | 20 +- README.md | 7 +- crates/composable-cow/Cargo.toml | 17 +- crates/composable-cow/src/lib.rs | 7 +- .../run.rs => composable-cow/src/sweep.rs} | 10 +- .../run.rs => composable-cow/tests/sweep.rs} | 411 +++++------ crates/cow-venue/data/classification.toml | 2 +- crates/cow-venue/tests/wit_layering.rs | 25 + crates/nexum-sdk-test/src/lib.rs | 4 +- crates/nexum-sdk/src/proptests.rs | 3 +- crates/nexum-sdk/src/wit_bindgen_macro.rs | 3 +- crates/shepherd-cow-host/Cargo.toml | 52 -- crates/shepherd-cow-host/src/config.rs | 59 -- crates/shepherd-cow-host/src/config/tests.rs | 45 -- crates/shepherd-cow-host/src/cow.rs | 49 -- crates/shepherd-cow-host/src/cow_orderbook.rs | 204 ------ .../src/cow_orderbook/tests.rs | 362 ---------- crates/shepherd-cow-host/src/ext_cow.rs | 319 --------- crates/shepherd-cow-host/src/lib.rs | 20 - crates/shepherd-cow-host/tests/cow_boot.rs | 207 ------ crates/shepherd-sdk-test/Cargo.toml | 24 - crates/shepherd-sdk-test/src/lib.rs | 669 ------------------ crates/shepherd-sdk-test/tests/mock_venue.rs | 374 ---------- crates/shepherd-sdk/Cargo.toml | 45 -- crates/shepherd-sdk/README.md | 82 --- crates/shepherd-sdk/src/cow/error.rs | 305 -------- crates/shepherd-sdk/src/cow/events.rs | 111 --- crates/shepherd-sdk/src/cow/mod.rs | 83 --- crates/shepherd-sdk/src/cow/transport.rs | 226 ------ crates/shepherd-sdk/src/lib.rs | 85 --- crates/shepherd-sdk/src/prelude.rs | 31 - crates/shepherd-sdk/src/proptests.rs | 39 - crates/shepherd-sdk/src/wit_bindgen_macro.rs | 79 --- crates/shepherd/Cargo.toml | 1 - crates/shepherd/src/main.rs | 50 +- .../0005-cow-api-via-cached-orderbookapi.md | 8 +- .../adr/0006-cow-twap-ethflow-host-helpers.md | 8 +- engine.load.toml | 11 +- engine.m3.toml | 15 +- extensions.toml | 4 - justfile | 2 +- modules/examples/stop-loss/Cargo.toml | 9 +- modules/examples/stop-loss/module.toml | 29 +- modules/examples/stop-loss/src/lib.rs | 60 +- modules/examples/stop-loss/src/strategy.rs | 422 +++++------ modules/twap-monitor/Cargo.toml | 3 +- modules/twap-monitor/src/strategy.rs | 33 +- scripts/e2e-report-gen.sh | 4 +- tools/orderbook-mock/src/main.rs | 6 +- wit/shepherd-cow/cow-api.wit | 62 -- wit/shepherd-cow/cow-ext.wit | 9 - wit/shepherd-cow/shepherd.wit | 7 - 53 files changed, 599 insertions(+), 4242 deletions(-) rename crates/{shepherd-sdk/src/cow/run.rs => composable-cow/src/sweep.rs} (97%) rename crates/{shepherd-sdk/tests/run.rs => composable-cow/tests/sweep.rs} (58%) create mode 100644 crates/cow-venue/tests/wit_layering.rs delete mode 100644 crates/shepherd-cow-host/Cargo.toml delete mode 100644 crates/shepherd-cow-host/src/config.rs delete mode 100644 crates/shepherd-cow-host/src/config/tests.rs delete mode 100644 crates/shepherd-cow-host/src/cow.rs delete mode 100644 crates/shepherd-cow-host/src/cow_orderbook.rs delete mode 100644 crates/shepherd-cow-host/src/cow_orderbook/tests.rs delete mode 100644 crates/shepherd-cow-host/src/ext_cow.rs delete mode 100644 crates/shepherd-cow-host/src/lib.rs delete mode 100644 crates/shepherd-cow-host/tests/cow_boot.rs delete mode 100644 crates/shepherd-sdk-test/Cargo.toml delete mode 100644 crates/shepherd-sdk-test/src/lib.rs delete mode 100644 crates/shepherd-sdk-test/tests/mock_venue.rs delete mode 100644 crates/shepherd-sdk/Cargo.toml delete mode 100644 crates/shepherd-sdk/README.md delete mode 100644 crates/shepherd-sdk/src/cow/error.rs delete mode 100644 crates/shepherd-sdk/src/cow/events.rs delete mode 100644 crates/shepherd-sdk/src/cow/mod.rs delete mode 100644 crates/shepherd-sdk/src/cow/transport.rs delete mode 100644 crates/shepherd-sdk/src/lib.rs delete mode 100644 crates/shepherd-sdk/src/prelude.rs delete mode 100644 crates/shepherd-sdk/src/proptests.rs delete mode 100644 crates/shepherd-sdk/src/wit_bindgen_macro.rs delete mode 100644 wit/shepherd-cow/cow-api.wit delete mode 100644 wit/shepherd-cow/cow-ext.wit delete mode 100644 wit/shepherd-cow/shepherd.wit diff --git a/Cargo.lock b/Cargo.lock index 2e31d334..1bc62a3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "thiserror 2.0.18", @@ -349,7 +349,7 @@ dependencies = [ "alloy-transport-ws", "futures", "pin-project", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -539,7 +539,7 @@ dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.14.0", - "reqwest 0.13.4", + "reqwest", "serde_json", "tower", "tracing", @@ -1490,9 +1490,13 @@ dependencies = [ "alloy-primitives", "alloy-sol-types", "borsh", + "cow-venue", "cowprotocol", "nexum-sdk", + "nexum-sdk-test", "proptest", + "tracing", + "videre-sdk", ] [[package]] @@ -1620,14 +1624,11 @@ dependencies = [ "cowprotocol-primitives", "cowprotocol-signing", "js-sys", - "reqwest 0.12.28", "serde", "serde_json", "serde_with", "thiserror 2.0.18", "url", - "wasm-bindgen", - "wasm-bindgen-futures", ] [[package]] @@ -2886,7 +2887,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", ] [[package]] @@ -3864,7 +3864,7 @@ dependencies = [ "axum", "clap", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -4542,44 +4542,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.8", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -5187,7 +5149,6 @@ dependencies = [ "anyhow", "nexum-launch", "nexum-runtime", - "shepherd-cow-host", "tokio", "videre-host", ] @@ -5208,64 +5169,6 @@ dependencies = [ "videre-sdk", ] -[[package]] -name = "shepherd-cow-host" -version = "0.2.0" -dependencies = [ - "alloy-chains", - "alloy-primitives", - "anyhow", - "cowprotocol", - "http", - "metrics", - "nexum-runtime", - "reqwest 0.13.4", - "serde", - "serde_json", - "strum", - "tempfile", - "thiserror 2.0.18", - "tokio", - "toml 1.1.2+spec-1.1.0", - "tracing", - "url", - "wasmtime", - "wiremock", -] - -[[package]] -name = "shepherd-sdk" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "composable-cow", - "cow-venue", - "cowprotocol", - "nexum-sdk", - "nexum-sdk-test", - "proptest", - "serde_json", - "shepherd-sdk-test", - "strum", - "thiserror 2.0.18", - "tracing", - "videre-sdk", -] - -[[package]] -name = "shepherd-sdk-test" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "composable-cow", - "cowprotocol", - "nexum-sdk", - "nexum-sdk-test", - "serde_json", - "shepherd-sdk", -] - [[package]] name = "shlex" version = "2.0.1" @@ -5380,13 +5283,12 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", - "serde_json", - "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -5936,7 +5838,6 @@ dependencies = [ "nexum-sdk", "nexum-sdk-test", "serde_json", - "shepherd-sdk", "tracing", "videre-sdk", "wit-bindgen 0.59.0", diff --git a/Cargo.toml b/Cargo.toml index 4de3f985..e40e0558 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,6 @@ members = [ "crates/no-std-probe", "crates/shepherd", "crates/shepherd-backtest", - "crates/shepherd-cow-host", - "crates/shepherd-sdk", - "crates/shepherd-sdk-test", "crates/videre-host", "crates/videre-macros", "crates/videre-sdk", @@ -124,19 +121,12 @@ alloy-transport-ws = { version = "2.1", default-features = false } # the engine can key maps and signatures on `Chain` instead of a bare u64. alloy-chains = { version = "0.2", default-features = false, features = ["std", "serde"] } -# CoW Protocol bindings. Pinned to one version across the workspace -# (was `1.0.0-alpha` in engine vs `1.0.0-alpha.3` in SDK before -# hoisting). The engine takes `http-client` for `OrderBookApi`; -# guest-side consumers (SDK, strategies) express their own -# `default-features = false` builds for the `cdylib` wasm target. -cowprotocol = { version = "0.2.0", default-features = false, features = ["http-client"] } - -# HTTP transport for `cow_api::request` REST passthrough and the -# orderbook-mock test surface. +# HTTP transport for the SDK helpers and the orderbook-mock test +# surface. reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } -# Typed HTTP method/request/response for the CoW passthrough and the -# engine's wasi:http gate. Single `http` version in the graph via reqwest, -# so `reqwest::Method` is `http::Method`. +# Typed HTTP method/request/response for the engine's wasi:http gate. +# Single `http` version in the graph via reqwest, so `reqwest::Method` +# is `http::Method`. http = "1" # Body trait + combinators (already in the graph via wasmtime-wasi-http) # for the engine's response-body cap on the wasi:http gate. diff --git a/README.md b/README.md index 8c94e3ac..21aa9c19 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,10 @@ Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs | `crates/nexum-runtime/` | The **engine** - the Nexum Runtime's reference host: a wasmtime implementation of the `nexum:host` contract. | | `crates/nexum-launch/` | The generic launcher library - shared CLI, config load, tracing, and the preset-driven launch. | | `crates/nexum-cli/` | The bare `nexum` binary - the core lattice with no extension payload. | -| `crates/shepherd/` | The `shepherd` binary - the cow composition root wiring the cow-api extension. | +| `crates/shepherd/` | The `shepherd` binary - the cow composition root registering the videre venue platform. | | `crates/nexum-sdk/` | Generic guest SDK - the host trait seam, bind macro, chain/config/address helpers, wasi:http `fetch`, and tracing facade for any module. | -| `crates/shepherd-sdk/` | CoW-domain guest SDK - the cow-api trait and CoW Protocol helpers on top of `nexum-sdk`. | | `wit/nexum-host/` | The **`nexum:host`** WIT package - the host/guest contract every engine implements and every module imports. | -| `wit/shepherd-cow/` | The `shepherd:cow` WIT package - CoW Protocol extensions on top of `nexum:host`. | +| `wit/shepherd-cow/` | The `shepherd:cow` WIT package - the CoW event ABIs of record. | | `modules/` | Guest modules - TWAP and EthFlow watch-towers, examples, and test fixtures. | | `docs/` | Architecture and design notes. Start with [`docs/00-overview.md`](docs/00-overview.md). | @@ -84,7 +83,7 @@ name = "twap-monitor" version = "0.1.0" [capabilities] -required = ["chain", "local-store", "cow-api"] +required = ["chain", "local-store", "client"] optional = ["http"] [[subscription]] diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml index ceb66cc6..b0988936 100644 --- a/crates/composable-cow/Cargo.toml +++ b/crates/composable-cow/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "ComposableCoW keeper machinery: the conditional-order body and the structured poll Verdict, with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter." +description = "ComposableCoW keeper machinery: the conditional-order body, the structured poll Verdict with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter, and the sweep composition over the venue client." [lib] # Plain library, keeper-side only. The CoW venue crate is orderbook-only @@ -19,6 +19,21 @@ alloy-sol-types.workspace = true borsh.workspace = true cowprotocol = { version = "0.2.0", default-features = false } nexum-sdk = { path = "../nexum-sdk" } +# `sweep` slice: the keeper run over the typed CoW client on the +# `videre:venue/client` seam. +cow-venue = { path = "../cow-venue", features = ["client", "assembly"], optional = true } +videre-sdk = { path = "../videre-sdk", optional = true } +tracing = { workspace = true, optional = true } + +[features] +# The poll-loop composition conditional-commitment keepers share: +# gate/journal discipline, pool submission, and retry dispatch. +sweep = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] [dev-dependencies] proptest.workspace = true +nexum-sdk-test = { path = "../nexum-sdk-test" } + +[[test]] +name = "sweep" +required-features = ["sweep"] diff --git a/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs index 505fa115..d4dc3680 100644 --- a/crates/composable-cow/src/lib.rs +++ b/crates/composable-cow/src/lib.rs @@ -3,13 +3,18 @@ //! ComposableCoW keeper machinery, kept out of the CoW venue: the //! conditional-order body ([`ComposableBody`]) and the structured poll //! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined -//! behind [`LegacyRevertAdapter`]. +//! behind [`LegacyRevertAdapter`]. The `sweep` slice adds the shared +//! poll-loop composition ([`run`]) over the typed CoW venue client. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] pub mod body; pub mod poll; +#[cfg(feature = "sweep")] +pub mod sweep; pub use body::ComposableBody; pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; +#[cfg(feature = "sweep")] +pub use sweep::run; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/composable-cow/src/sweep.rs similarity index 97% rename from crates/shepherd-sdk/src/cow/run.rs rename to crates/composable-cow/src/sweep.rs index 0a19bc8e..d3fa5dd7 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/composable-cow/src/sweep.rs @@ -1,4 +1,4 @@ -//! Keeper run: the poll-loop composition conditional- +//! Keeper sweep: the poll-loop composition conditional- //! commitment modules share. //! //! [`run`] walks the keeper watch set, polls each gate-ready @@ -19,7 +19,8 @@ //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes, hex}; -use composable_cow::Verdict; +use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, intent_id}; use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ @@ -28,10 +29,7 @@ use nexum_sdk::keeper::{ use videre_sdk::keeper::retry_action; use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; -use super::{ - CowClient, CowIntent, CowIntentBody, SignedOrder, gpv2_to_order_data, intent_id, - order_data_to_body, -}; +use crate::Verdict; /// Poll every gate-ready watch once at `tick` and run each outcome's /// effect. One source poll per ready watch; a `Post` outcome makes at diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/composable-cow/tests/sweep.rs similarity index 58% rename from crates/shepherd-sdk/tests/run.rs rename to crates/composable-cow/tests/sweep.rs index f4e32081..7d400f4c 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/composable-cow/tests/sweep.rs @@ -1,29 +1,76 @@ -//! Keeper-run acceptance tests against the composed -//! `shepherd_sdk_test::MockHost`. These live as an integration test -//! (not `#[cfg(test)]`) because the mock crate links `shepherd-sdk` -//! externally, and the external and unit-test copies of the traits -//! are distinct types. +//! Sweep acceptance tests: `run` over the generic store mocks with a +//! scripted venue transport on the `videre:venue/client` seam. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; -use composable_cow::Verdict; +use composable_cow::{Verdict, run}; +use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; -use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk::host::LocalStoreHost as _; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; -use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{ - CowApiError, CowApiTransport, CowClient, CowIntent, CowIntentBody, CowVenue, OrderRejection, - SignedOrder, gpv2_to_order_data, order_data_to_body, run, +use nexum_sdk_test::{MockHost, capture_tracing}; +use videre_sdk::client::sealed::SealedTransport; +use videre_sdk::keeper::submission_key; +use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, UnsignedTx, Venue as _, VenueFault, + VenueId, VenueTransport, }; -use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; -/// The typed client every test drives `run` with: the transitional -/// cow-api bridge over the composed mock host. -fn venue(host: &MockHost) -> CowClient> { - CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +/// Scripted venue transport: one submit outcome per queued entry, +/// every submit recorded. Quote, status, and cancel are off the sweep +/// path. +#[derive(Default)] +struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, +} + +impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } +} + +impl SealedTransport for &MockVenue {} + +impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } +} + +fn client(venue: &MockVenue) -> CowClient<&MockVenue> { + CowClient::with_transport(venue) } /// Closure-backed source so each test scripts its own outcome and @@ -66,17 +113,6 @@ fn sample_tick() -> Tick { } } -/// `validTo` a given number of seconds from now. The `OrderCreation` -/// constructor's client-side max-horizon policy reads the wall clock -/// (not the block clock), so test orders must expire relative to it. -fn valid_to_in(seconds: u64) -> u32 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the epoch") - .as_secs(); - u32::try_from(now + seconds).expect("test validTo fits u32") -} - fn submittable_order() -> GPv2OrderData { GPv2OrderData { sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), @@ -84,7 +120,7 @@ fn submittable_order() -> GPv2OrderData { receiver: Address::ZERO, sellAmount: U256::from(1_000_000_u64), buyAmount: U256::from(999_u64), - validTo: valid_to_in(3_600), + validTo: u32::MAX, appData: cowprotocol::EMPTY_APP_DATA_HASH, feeAmount: U256::ZERO, kind: OrderKind::SELL, @@ -108,18 +144,28 @@ fn seed_watch(host: &MockHost) -> String { .unwrap() } -/// The intent-id the keeper journals for `order`: the venue-and-body -/// key over the same signed body `run` derives pre-submit. -fn intent_id(order: &GPv2OrderData) -> String { +/// The encoded intent body the sweep submits for `order`. +fn intent_bytes(order: &GPv2OrderData) -> Vec { let order_data = gpv2_to_order_data(order).expect("known markers"); - shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: order_data_to_body(&order_data), owner: sample_owner().into_array(), signature: hex!("c0ffeec0ffeec0ffee").to_vec(), - }))) + })) + .to_bytes() .expect("body encodes") } +/// The intent-id the sweep journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + submission_key(&CowVenue::ID, &intent_bytes(order)) +} + +fn accepted() -> Result { + Ok(SubmitOutcome::Accepted(vec![0xAA])) +} + // ---- lifecycle outcomes ---- #[test] @@ -127,28 +173,30 @@ fn try_next_block_leaves_the_store_untouched() { let host = MockHost::new(); seed_watch(&host); let before = host.store.snapshot(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), &sample_tick(), ) .unwrap(); assert_eq!(host.store.snapshot(), before); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); } #[test] -fn try_on_block_sets_the_block_gate() { +fn wait_block_sets_the_block_gate() { let host = MockHost::new(); let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::WaitBlock { wait_until: 2_000, reason: [0; 4], @@ -164,14 +212,15 @@ fn try_on_block_sets_the_block_gate() { } #[test] -fn try_at_epoch_sets_the_epoch_gate() { +fn wait_timestamp_sets_the_epoch_gate() { let host = MockHost::new(); let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::WaitTimestamp { wait_until: 1_800_000_000, reason: [0; 4], @@ -192,10 +241,11 @@ fn invalid_removes_the_watch_and_its_gates() { let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); Gates::new(&host).set_next_block(watch, 1).unwrap(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), &sample_tick(), ) @@ -214,10 +264,11 @@ fn gated_watch_is_not_polled() { .set_next_block(WatchRef::parse(&key).unwrap(), 5_000) .unwrap(); let polls = Cell::new(0_u32); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -234,10 +285,11 @@ fn malformed_watch_rows_are_skipped() { let host = MockHost::new(); host.store.set("watch:no-separator", b"junk").unwrap(); let polls = Cell::new(0_u32); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -256,22 +308,26 @@ fn ready_submits_once_and_journals_the_intent_id() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok("0xserveruid".to_string())); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + assert_eq!(venue.submit_count(), 1); assert!( Journal::submitted(&host) .contains(&intent_id(&order)) .unwrap(), "submitted:{{intent_id}} marker must be recorded", ); - assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); + + // The next tick short-circuits on the journal: no second submit. + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); } #[test] @@ -279,24 +335,29 @@ fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok("0xfeedface".to_string())); + let venue = MockVenue::default(); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(vec![0xFE, 0xED, 0xFA, 0xCE]))); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); - assert!( - !snapshot.contains_key("submitted:0xfeedface"), + assert_eq!( + snapshot + .keys() + .filter(|k| k.starts_with("submitted:")) + .count(), + 1, "marker must key on the pre-submit intent-id, not the server receipt", ); } #[test] -fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { +fn ready_skips_the_venue_when_the_intent_id_is_journalled() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); @@ -304,10 +365,11 @@ fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { .record(&intent_id(&order)) .unwrap(); let polls = Cell::new(0_u32); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) @@ -318,7 +380,7 @@ fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { assert_eq!(polls.get(), 1, "the source is still consulted"); assert_eq!( - host.cow_api.call_count(), + venue.submit_count(), 0, "the journal guard must short-circuit before any network work", ); @@ -330,68 +392,60 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { let key = seed_watch(&host); let mut order = submittable_order(); order.kind = B256::repeat_byte(0x42); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) .unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert!(host.store.snapshot().contains_key(&key)); } -/// A Ready order whose `validTo` exceeds the constructor's client-side -/// one-year horizon can never be submitted: the rejection is -/// deterministic for the polled payload, so the watch must drop -/// through the ledger instead of re-polling and warn-looping on every -/// block forever. (Watch-tower net effect: submit, orderbook rejects -/// with `ExcessiveValidTo`, classifier drops.) +/// A sweep cannot sign: a `requires-signing` outcome is surfaced, not +/// journalled, so the next tick re-poses the same ask. #[test] -fn ready_beyond_the_valid_to_horizon_drops_the_watch() { +fn requires_signing_is_surfaced_and_not_journalled() { let host = MockHost::new(); let key = seed_watch(&host); - let mut order = submittable_order(); - order.validTo = valid_to_in(2 * 365 * 24 * 3_600); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: SEPOLIA, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0x22], + }))); let source = src(move |_, _, _, _| ready_outcome(&order)); - let (result, logs) = capture_tracing(|| run(&host, &venue(&host), &source, &sample_tick())); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &sample_tick())); result.unwrap(); - assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); + assert_eq!(venue.submit_count(), 1); let snapshot = host.store.snapshot(); - assert!( - !snapshot.contains_key(&key), - "an unsubmittable order must not survive to warn-loop forever", - ); + assert!(snapshot.contains_key(&key), "the watch survives"); assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); - assert!(logs.any(|e| e.message.contains("submit dropped watch"))); + assert!(logs.any(|e| e.message.contains("requires signing"))); } // ---- submission failure dispatch ---- -fn rejection(error_type: &str) -> CowApiError { - CowApiError::Rejected(OrderRejection { - status: 400, - error_type: error_type.into(), - description: "test".into(), - data: None, - }) -} - #[test] -fn transient_rejection_keeps_the_watch_ungated() { +fn transient_fault_keeps_the_watch_ungated() { let host = MockHost::new(); let key = seed_watch(&host); let watch_key = WatchRef::parse(&key).unwrap(); let order = submittable_order(); - host.cow_api.respond(Err(rejection("InsufficientFee"))); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); run( &host, - &venue(&host), + &client(&venue), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -405,88 +459,91 @@ fn transient_rejection_keeps_the_watch_ungated() { } #[test] -fn permanent_rejection_drops_the_watch_through_the_ledger() { +fn denied_fault_drops_the_watch_through_the_ledger() { let host = MockHost::new(); let key = seed_watch(&host); Gates::new(&host) .set_next_block(WatchRef::parse(&key).unwrap(), 1) .unwrap(); let order = submittable_order(); - host.cow_api.respond(Err(rejection("InvalidSignature"))); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); - run( - &host, - &venue(&host), - &src(move |_, _, _, _| ready_outcome(&order)), - &sample_tick(), - ) - .unwrap(); + let source = src(move |_, _, _, _| ready_outcome(&order)); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &sample_tick())); + result.unwrap(); assert!( host.store.is_empty(), - "a permanent rejection must drop the watch and its gates", + "a permanent refusal must drop the watch and its gates", ); + assert!(logs.any(|e| e.message.contains("submit dropped watch"))); } -/// The orderbook already holds the order: the receipt is recorded, the -/// watch survives, and the next tick short-circuits on the journal -/// instead of re-posting. +/// A rate-limit fault with server guidance backs the watch off on the +/// epoch clock - `RetryAction::Backoff` reached through the ledger. #[test] -fn duplicated_order_records_the_receipt_and_keeps_the_watch() { +fn rate_limited_submit_backs_off_through_the_epoch_gate() { let host = MockHost::new(); let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); let order = submittable_order(); - host.cow_api.respond(Err(rejection("DuplicatedOrder"))); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); - let source = { - let order = order.clone(); - src(move |_, _, _, _| ready_outcome(&order)) - }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); + let tick = sample_tick(); + run( + &host, + &client(&venue), + &src(move |_, _, _, _| ready_outcome(&order)), + &tick, + ) + .unwrap(); - assert!(host.store.snapshot().contains_key(&key)); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap(), - "already-submitted must journal the intent-id", + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "backoff must keep the watch"); + assert_eq!( + snapshot.get(&watch.next_epoch_key()).unwrap(), + &(tick.epoch_s + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", ); - - // The next tick must not touch the orderbook again. - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } /// Restart regression: a keeper that posted, journalled, and then /// restarted over the same persistent local store must not post the -/// same order again - one orderbook POST across both lives. +/// same order again - one venue submit across both lives. #[test] fn restart_with_a_journalled_intent_does_not_repost() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok("0xserveruid".to_string())); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); // A restarted keeper: fresh instance, the local store carried over. let restarted = MockHost::new(); for (key, value) in host.store.snapshot() { restarted.store.set(&key, &value).unwrap(); } - restarted.cow_api.respond(Ok("0xserveruid".to_string())); + let venue_after = MockVenue::default(); + venue_after.enqueue_submit(accepted()); - run(&restarted, &venue(&restarted), &source, &sample_tick()).unwrap(); + run(&restarted, &client(&venue_after), &source, &sample_tick()).unwrap(); assert_eq!( - host.cow_api.call_count() + restarted.cow_api.call_count(), + venue.submit_count() + venue_after.submit_count(), 1, - "resubmit after restart must make no second orderbook POST", + "resubmit after restart must make no second venue submit", ); assert!( Journal::submitted(&restarted) @@ -495,124 +552,34 @@ fn restart_with_a_journalled_intent_does_not_repost() { ); } -/// A rate-limit fault with server guidance backs the watch off on the -/// epoch clock - `RetryAction::Backoff` reached through the ledger. -#[test] -fn rate_limited_submit_backs_off_through_the_epoch_gate() { - let host = MockHost::new(); - let key = seed_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - let order = submittable_order(); - host.cow_api - .respond(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(2_500), - })))); - - let tick = sample_tick(); - run( - &host, - &venue(&host), - &src(move |_, _, _, _| ready_outcome(&order)), - &tick, - ) - .unwrap(); - - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&key), "backoff must keep the watch"); - assert_eq!( - snapshot.get(&watch.next_epoch_key()).unwrap(), - &(tick.epoch_s + 3).to_le_bytes().to_vec(), - "2500ms rounds up to a 3s backoff from the tick clock", - ); - assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); -} - // ---- the generic seam ---- /// The seam proof: a `Post` verdict reaches the venue transport as the -/// encoded `CowIntentBody` under the CoW venue id, the journal keys on -/// the generic submission key, and the legacy cow-api seam is never -/// touched. +/// encoded `CowIntentBody` under the CoW venue id, and the journal +/// keys on the generic submission key. #[test] fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { - use std::cell::RefCell; - - use videre_sdk::keeper::submission_key; - use videre_sdk::{ - IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, - VenueTransport, - }; - - /// Records the venue and wire bytes of every submit. - struct SpyTransport { - calls: RefCell)>>, - } - - impl videre_sdk::client::sealed::SealedTransport for &SpyTransport {} - - impl VenueTransport for &SpyTransport { - async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { - unreachable!("quote not exercised") - } - - async fn submit( - &self, - venue: &VenueId, - body: Vec, - ) -> Result { - self.calls.borrow_mut().push((venue.to_string(), body)); - Ok(SubmitOutcome::Accepted(vec![0xAA])) - } - - async fn status( - &self, - _venue: &VenueId, - _receipt: &[u8], - ) -> Result { - unreachable!("status not exercised") - } - - async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { - unreachable!("cancel not exercised") - } - } - let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - let spy = SpyTransport { - calls: RefCell::new(Vec::new()), - }; - let client = CowClient::with_transport(&spy); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &client, &source, &sample_tick()).unwrap(); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); - let order_data = gpv2_to_order_data(&order).expect("known markers"); - let expected = CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: order_data_to_body(&order_data), - owner: sample_owner().into_array(), - signature: hex!("c0ffeec0ffeec0ffee").to_vec(), - })) - .to_bytes() - .expect("body encodes"); - - let calls = spy.calls.borrow(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, CowVenue::ID.as_str()); - assert_eq!(calls[0].1, expected, "the wire carries the intent body"); + let expected = intent_bytes(&order); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!(submits[0].0, CowVenue::ID.as_str()); + assert_eq!(submits[0].1, expected, "the wire carries the intent body"); assert!( Journal::submitted(&host) .contains(&submission_key(&CowVenue::ID, &expected)) .unwrap(), "the journal keys on the generic submission key", ); - assert_eq!( - host.cow_api.call_count(), - 0, - "the legacy cow-api seam is never touched", - ); } diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index f7910590..f2cbb40b 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -32,7 +32,7 @@ # permanent rather than retried every block forever. # # Relationship to `cowprotocol::ApiError::retry_hint()`. The upstream -# `cowprotocol` crate (a shepherd-sdk dependency) also classifies +# `cowprotocol` crate also classifies # orderbook `errorType`s, via `RetryHint`. Ratified: this table, not # `RetryHint`, is shepherd's classification source of truth. It is # shepherd's own, more conservative retry policy, kept as data of record diff --git a/crates/cow-venue/tests/wit_layering.rs b/crates/cow-venue/tests/wit_layering.rs new file mode 100644 index 00000000..4ccf59ed --- /dev/null +++ b/crates/cow-venue/tests/wit_layering.rs @@ -0,0 +1,25 @@ +//! Layering gate: no generic WIT package references `shepherd:cow`. +//! The bundle-layer package carries only the event ABIs; the generic +//! host and videre packages must never name it. + +use std::path::Path; + +#[test] +fn generic_wit_packages_never_reference_shepherd_cow() { + let wit_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); + for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { + let pkg = pkg.expect("wit dir entry").path(); + if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { + continue; + } + for file in std::fs::read_dir(&pkg).expect("wit package dir") { + let path = file.expect("wit package entry").path(); + let text = std::fs::read_to_string(&path).expect("read wit file"); + assert!( + !text.contains("shepherd:cow"), + "{} references shepherd:cow", + path.display(), + ); + } + } +} diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index ceee3ec0..1da359e2 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -54,8 +54,8 @@ //! bridges with a trivial converter on its own crate boundary - see the //! tutorial for the exact shape. //! -//! Domain SDK test crates compose these mocks with their own (the CoW -//! `shepherd-sdk-test` embeds them next to its `MockCowApi`). +//! Domain test crates compose these mocks with their own scripted +//! venue transports on the `videre:venue/client` seam. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/nexum-sdk/src/proptests.rs b/crates/nexum-sdk/src/proptests.rs index 0f3e24b9..2a4709e7 100644 --- a/crates/nexum-sdk/src/proptests.rs +++ b/crates/nexum-sdk/src/proptests.rs @@ -11,7 +11,8 @@ //! `balance-tracker`'s persistence path). //! //! The CoW-domain properties (`decode_revert`, the -//! `gpv2_to_order_data` marker guard) live in `shepherd-sdk`. +//! `gpv2_to_order_data` marker guard) live in `composable-cow` and +//! `cow-venue`. #![cfg(test)] diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 586df413..98059b8b 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -20,8 +20,7 @@ //! not import is a compile error. //! //! A domain SDK layers its own interfaces on top by invoking this -//! macro and adding trait impls for the same `WitBindgenHost` (the -//! CoW SDK's `bind_cow_host_via_wit_bindgen!` does exactly that). +//! macro and adding trait impls for the same `WitBindgenHost`. //! //! Usage in a module's `lib.rs`: //! diff --git a/crates/shepherd-cow-host/Cargo.toml b/crates/shepherd-cow-host/Cargo.toml deleted file mode 100644 index 47ba5e1e..00000000 --- a/crates/shepherd-cow-host/Cargo.toml +++ /dev/null @@ -1,52 +0,0 @@ -[package] -name = "shepherd-cow-host" -version = "0.2.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[lib] -path = "src/lib.rs" - -[dependencies] -# The host runtime this extension plugs into: HostState, the RuntimeTypes -# lattice, the extension seam, the capability namespace, and the shared -# `nexum:host/types` bindgen module the cow world binds through `with`. -nexum-runtime = { path = "../nexum-runtime" } - -# `cow-api` backend. cowprotocol pulls `OrderBookApi`, `OrderCreation`, -# `OrderUid`, the orderbook base URL table per `Chain`, and the typed -# error surface the host re-projects into the cow-api-error variant. -cowprotocol.workspace = true -# REST passthrough for `cow_api::request`. -reqwest.workspace = true -# Typed HTTP method for the CoW passthrough. -http.workspace = true -# Typed EIP-155 chain ids for the orderbook pool keys. -alloy-chains.workspace = true -# `hex::encode_prefixed` for the returned OrderUid. -alloy-primitives.workspace = true - -# WASM Component Model linker the extension hook writes into. -wasmtime.workspace = true - -# `strum::IntoStaticStr` on the error enum for metric labels. -strum.workspace = true -thiserror.workspace = true -anyhow.workspace = true -tracing.workspace = true -metrics.workspace = true -serde_json = { workspace = true, features = ["std"] } -url.workspace = true -# `[extensions.cow]` config table: serde derive for `CowConfig`, toml -# for the opaque `toml::Value` handed over by the engine config. -serde.workspace = true -toml.workspace = true - -[dev-dependencies] -tokio.workspace = true -wiremock.workspace = true -tempfile.workspace = true diff --git a/crates/shepherd-cow-host/src/config.rs b/crates/shepherd-cow-host/src/config.rs deleted file mode 100644 index f0612b25..00000000 --- a/crates/shepherd-cow-host/src/config.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! The `[extensions.cow]` config table, owned by this extension. -//! -//! `engine.toml` stays domain-free: the engine hands every -//! `[extensions.]` table to the composition root verbatim, and -//! this module parses the `cow` one. - -use std::collections::HashMap; - -use alloy_chains::Chain; -use nexum_runtime::engine_config::EngineConfig; -use serde::Deserialize; -use strum::IntoStaticStr; -use thiserror::Error; - -/// The `[extensions.cow]` table from `engine.toml`. -/// -/// ```toml -/// [extensions.cow.orderbook_urls] -/// 11155111 = "http://localhost:9999" -/// ``` -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CowConfig { - /// Per-chain orderbook base URL overrides keyed by EIP-155 chain - /// id (numeric or named, as with `[chains.]`). Chains without - /// an entry use the canonical `cowprotocol::Chain` URL. - #[serde(default)] - pub orderbook_urls: HashMap, -} - -/// Boot-time errors from parsing the cow extension's config. -/// -/// `IntoStaticStr` exposes the snake_case variant name for -/// structured-log `error_kind` fields, matching the other host-side -/// error enums. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowConfigError { - /// The `[extensions.cow]` table failed to deserialize. - #[error("parse [extensions.cow]: {0}")] - Section(#[from] toml::de::Error), -} - -impl TryFrom<&EngineConfig> for CowConfig { - type Error = CowConfigError; - - /// Parse the `[extensions.cow]` table. An absent table yields an - /// empty override set, so every chain uses its canonical URL. - fn try_from(cfg: &EngineConfig) -> Result { - match cfg.extensions.get("cow") { - Some(section) => Ok(section.clone().try_into()?), - None => Ok(Self::default()), - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/shepherd-cow-host/src/config/tests.rs b/crates/shepherd-cow-host/src/config/tests.rs deleted file mode 100644 index b91b8c77..00000000 --- a/crates/shepherd-cow-host/src/config/tests.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::*; - -fn engine_cfg(toml_src: &str) -> EngineConfig { - toml::from_str(toml_src).expect("engine config parses") -} - -fn mainnet() -> Chain { - Chain::from_id(1) -} - -#[test] -fn extension_table_resolves() { - let cfg = engine_cfg( - r#" -[extensions.cow.orderbook_urls] -1 = "http://localhost:8888" -"#, - ); - let cow = CowConfig::try_from(&cfg).expect("extension table parses"); - assert_eq!( - cow.orderbook_urls.get(&mainnet()).map(String::as_str), - Some("http://localhost:8888"), - ); -} - -#[test] -fn absent_config_yields_no_overrides() { - let cow = CowConfig::try_from(&EngineConfig::default()).expect("empty config parses"); - assert!(cow.orderbook_urls.is_empty()); -} - -#[test] -fn misspelled_extension_key_errors() { - // deny_unknown_fields turns a typo inside the new table into a - // boot-time error instead of a silent fall-through to the live - // orderbook. - let cfg = engine_cfg( - r#" -[extensions.cow] -orderbook_url = "http://localhost:9999" -"#, - ); - let err = CowConfig::try_from(&cfg).expect_err("unknown key in [extensions.cow] rejected"); - assert!(matches!(err, CowConfigError::Section(_))); -} diff --git a/crates/shepherd-cow-host/src/cow.rs b/crates/shepherd-cow-host/src/cow.rs deleted file mode 100644 index d1f0041e..00000000 --- a/crates/shepherd-cow-host/src/cow.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! CoW orderbook seam: REST passthrough plus typed order submission, -//! mirroring the inherent `OrderBookPool` API. - -use std::future::Future; - -use alloy_chains::Chain; -use cowprotocol::OrderUid; - -use crate::cow_orderbook::{CowApiError, OrderBookPool}; - -/// Async CoW orderbook backend. `get` (concrete client lookup) is -/// deliberately not part of the seam; it leaks `OrderBookApi`. -pub trait CowApi { - /// REST passthrough against the chain's orderbook base URL. - fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> impl Future> + Send; - - /// Typed submission of a JSON-encoded `OrderCreation`. - fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> impl Future> + Send; -} - -impl CowApi for OrderBookPool { - fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> impl Future> + Send { - OrderBookPool::request(self, chain, method, path, body) - } - - fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> impl Future> + Send { - OrderBookPool::submit_order_json(self, chain, body) - } -} diff --git a/crates/shepherd-cow-host/src/cow_orderbook.rs b/crates/shepherd-cow-host/src/cow_orderbook.rs deleted file mode 100644 index 61202dc0..00000000 --- a/crates/shepherd-cow-host/src/cow_orderbook.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! `shepherd:cow/cow-api` backend. -//! -//! Two responsibilities: -//! -//! 1. `request` - generic REST passthrough. Module gives the HTTP -//! method, path (relative to the chain's orderbook base URL), and -//! optional JSON body. We dispatch via `reqwest`, return the -//! response body verbatim. -//! 2. `submit_order` - typed submission. Module gives a JSON-encoded -//! `cowprotocol::OrderCreation`; we parse, dispatch via -//! `cowprotocol::OrderBookApi::post_order`, return the assigned -//! `OrderUid` as a `0x`-prefixed hex string. -//! -//! Per-chain `OrderBookApi` instances are constructed once at engine -//! boot from the discriminated chain set in `cowprotocol::Chain`. -//! Chains the SDK does not know about return `Unsupported` at the -//! host call boundary. - -use std::collections::HashMap; -use std::time::Duration; - -use alloy_chains::Chain; -use cowprotocol::{Chain as CowChain, OrderBookApi, OrderCreation, OrderUid}; -use nexum_runtime::engine_config::EngineConfig; -use strum::IntoStaticStr; -use thiserror::Error; - -use crate::config::{CowConfig, CowConfigError}; - -/// Process-wide pool of `OrderBookApi` clients keyed by chain. -#[derive(Debug, Clone)] -pub struct OrderBookPool { - clients: HashMap, - http: reqwest::Client, -} - -/// Canonical CoW Protocol chain set the engine ships clients for. -/// -/// Both `Default::default()` and `OrderBookPool::from_config` walk -/// this single source of truth so a new chain joining CoW protocol -/// only needs a one-line addition here instead of two parallel -/// arrays. -const DEFAULT_CHAINS: &[CowChain] = &[ - CowChain::Mainnet, - CowChain::Gnosis, - CowChain::Sepolia, - CowChain::ArbitrumOne, - CowChain::Base, -]; - -impl Default for OrderBookPool { - /// Build a pool covering every `cowprotocol::Chain` variant. Each entry - /// uses the canonical `api.cow.fi/{slug}/api/v1` base URL from the SDK. - /// Override individual entries via `OrderBookApi::new_with_base_url` for - /// barn or staging targets. - fn default() -> Self { - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("reqwest client builder"); - let clients = DEFAULT_CHAINS - .iter() - .map(|c| (Chain::from_id(c.id()), OrderBookApi::new(*c))) - .collect(); - Self { clients, http } - } -} - -impl OrderBookPool { - /// Build a pool from engine config, honouring the - /// `[extensions.cow.orderbook_urls]` overrides. Chains without an - /// override fall back to the canonical `cowprotocol::Chain` URLs - /// (same as [`OrderBookPool::default`]). - /// - /// Used by the load test to point all submissions at - /// `tools/orderbook-mock`, and by staging/barn deployments that - /// run against a non-production orderbook. - pub fn from_config(cfg: &EngineConfig) -> Result { - let cow_cfg = CowConfig::try_from(cfg)?; - let http = reqwest::Client::new(); - let mut clients: HashMap = DEFAULT_CHAINS - .iter() - .map(|c| (Chain::from_id(c.id()), OrderBookApi::new(*c))) - .collect(); - // Sort by numeric id so override logs are deterministic - // (`Chain` is not `Ord`). - let mut entries: Vec<_> = cow_cfg.orderbook_urls.iter().collect(); - entries.sort_by_key(|(c, _)| c.id()); - for (chain, url) in entries { - let chain_id = chain.id(); - match url.parse::() { - Ok(parsed) => { - tracing::info!(chain_id, url, "cow-api: orderbook URL override"); - clients.insert(*chain, OrderBookApi::new_with_base_url(parsed)); - } - Err(e) => { - tracing::warn!(chain_id, url, error = %e, "cow-api: bad orderbook_url, falling back to canonical"); - } - } - } - Ok(Self { clients, http }) - } - - /// Look up the client for a chain. - pub fn get(&self, chain: Chain) -> Result<&OrderBookApi, CowApiError> { - self.clients - .get(&chain) - .ok_or(CowApiError::UnknownChain(chain)) - } - - /// REST passthrough. The base URL is whichever URL the pool's - /// `OrderBookApi` client carries - overrides set via - /// `OrderBookApi::new_with_base_url` (staging, wiremock) flow - /// through here too, which keeps the passthrough and the typed - /// `submit_order_json` path aimed at the same orderbook. - pub async fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> Result { - use http::Method; - let api = self.get(chain)?; - let base = api.base_url().clone(); - // `path` may or may not lead with a slash; `Url::join` handles - // both, but we strip a single leading `/` so consumers can - // write either `/orders/...` or `orders/...` interchangeably. - let trimmed = path.strip_prefix('/').unwrap_or(path); - let url = base - .join(trimmed) - .map_err(|e| CowApiError::BadPath(format!("{path:?}: {e}")))?; - - if ![Method::GET, Method::POST, Method::PUT, Method::DELETE].contains(&method) { - return Err(CowApiError::BadMethod(method)); - } - // `reqwest::Method` is `http::Method`, so the typed method flows - // straight through. - let request = self.http.request(method, url); - let request = if let Some(body) = body { - request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body.to_owned()) - } else { - request - }; - - let response = request.send().await.map_err(CowApiError::Network)?; - let status = response.status().as_u16(); - let text = response.text().await.map_err(CowApiError::Network)?; - // Non-2xx responses are surfaced as HttpError so the guest can - // distinguish 404 (not found) from 200 (success) via the - // cow-api-error http case status. - // The full response body is preserved in the error for structured - // decoding (e.g. `{"errorType": "...", "description": "..."}`). - if status >= 400 { - return Err(CowApiError::HttpError { status, body: text }); - } - Ok(text) - } - - /// Typed submission. `body` is the JSON encoding of - /// `cowprotocol::OrderCreation`. The chain's orderbook validates - /// `from`, the EIP-712 hash, and (if `Eip1271`) the contract - /// signature; we return whatever UID it assigns. - pub async fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> Result { - let creation: OrderCreation = serde_json::from_slice(body).map_err(CowApiError::Decode)?; - let api = self.get(chain)?; - let uid = api.post_order(&creation).await?; - Ok(uid) - } -} - -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` (`"unknown_chain"`, `"bad_method"`, ...) so the -/// `shepherd_cow_api_*` metric labels and structured-log fields stay -/// in sync with the Rust source of truth instead of growing a -/// `match err { ... => "decode" ... }` ladder per call site. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowApiError { - #[error("unknown chain {0} (no cowprotocol::Chain variant)")] - UnknownChain(Chain), - #[error("bad HTTP method `{0}` (expected GET/POST/PUT/DELETE)")] - BadMethod(http::Method), - #[error("invalid path: {0}")] - BadPath(String), - #[error("HTTP {status}")] - HttpError { status: u16, body: String }, - #[error("network: {0}")] - Network(#[from] reqwest::Error), - #[error("decode OrderCreation JSON: {0}")] - Decode(#[from] serde_json::Error), - #[error("orderbook: {0}")] - Orderbook(#[from] cowprotocol::Error), -} - -#[cfg(test)] -mod tests; diff --git a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs b/crates/shepherd-cow-host/src/cow_orderbook/tests.rs deleted file mode 100644 index f4155888..00000000 --- a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs +++ /dev/null @@ -1,362 +0,0 @@ -use super::*; -use http::Method; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -/// The canonical CoW mainnet chain, as the pool keys it (alloy `Chain` -/// derived from the cowprotocol id). -fn mainnet() -> Chain { - Chain::from_id(CowChain::Mainnet.id()) -} - -#[test] -fn pool_indexes_default_chains() { - let pool = OrderBookPool::default(); - assert!(pool.get(Chain::from_id(1)).is_ok(), "mainnet present"); - assert!(pool.get(Chain::from_id(100)).is_ok(), "gnosis present"); - assert!( - pool.get(Chain::from_id(11_155_111)).is_ok(), - "sepolia present" - ); - assert!(pool.get(Chain::from_id(42_161)).is_ok(), "arbitrum present"); - assert!(pool.get(Chain::from_id(8_453)).is_ok(), "base present"); -} - -#[test] -fn from_config_applies_extension_override() { - let cfg: EngineConfig = toml::from_str( - r#" -[extensions.cow.orderbook_urls] -1 = "http://localhost:9999" -"#, - ) - .expect("engine config parses"); - let pool = OrderBookPool::from_config(&cfg).expect("pool builds"); - let api = pool.get(mainnet()).expect("mainnet client"); - assert!( - api.base_url().as_str().starts_with("http://localhost:9999"), - "extension override applied, got {}", - api.base_url(), - ); -} - -#[test] -fn unknown_chain_surfaces_typed_error() { - let pool = OrderBookPool::default(); - assert!(matches!( - pool.get(Chain::from_id(99_999)), - Err(CowApiError::UnknownChain(c)) if c == Chain::from_id(99_999) - )); -} - -/// Build a pool whose Mainnet entry points at `mock.uri()`. -/// `OrderBookApi::new_with_base_url` ships in cowprotocol; we -/// rely on it so wiremock-driven tests can exercise the full -/// request path without re-implementing the HTTP client. -fn pool_with_mainnet_at(mock: &MockServer) -> OrderBookPool { - let mut clients = std::collections::HashMap::new(); - clients.insert( - mainnet(), - OrderBookApi::new_with_base_url(mock.uri().parse().expect("mock uri parses")), - ); - OrderBookPool { - clients, - http: reqwest::Client::new(), - } -} - -#[tokio::test] -async fn request_passes_get_path_through() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/version")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"version":"x.y.z"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request(mainnet(), Method::GET, "/api/v1/version", None) - .await - .expect("request succeeds"); - assert_eq!(body, r#"{"version":"x.y.z"}"#); -} - -#[tokio::test] -async fn request_relative_path_works() { - // Module passes a path without a leading slash. The - // passthrough should still resolve against the orderbook - // base URL. - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/native_price/0xabc")) - .respond_with(ResponseTemplate::new(200).set_body_string("1.23")) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request(mainnet(), Method::GET, "api/v1/native_price/0xabc", None) - .await - .expect("relative path resolves"); - assert_eq!(body, "1.23"); -} - -#[tokio::test] -async fn request_rejects_unknown_method() { - let pool = OrderBookPool::default(); - let err = pool - .request(mainnet(), Method::PATCH, "/x", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::BadMethod(_))); -} - -#[tokio::test] -async fn request_post_with_body_is_forwarded() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/v1/quote")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"quote":"ok"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request( - mainnet(), - Method::POST, - "/api/v1/quote", - Some(r#"{"sellToken":"0x01"}"#), - ) - .await - .expect("post with body succeeds"); - assert_eq!(body, r#"{"quote":"ok"}"#); -} - -#[tokio::test] -async fn request_4xx_response_surfaces_http_error_with_body() { - let mock = MockServer::start().await; - let error_body = r#"{"errorType":"InsufficientFee","description":"fee too low"}"#; - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(400).set_body_string(error_body)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .request( - mainnet(), - Method::POST, - "/api/v1/orders", - Some(r#"{"test":true}"#), - ) - .await - .unwrap_err(); - match err { - CowApiError::HttpError { status, body } => { - assert_eq!(status, 400); - assert_eq!(body, error_body); - } - other => panic!("expected HttpError, got: {other:?}"), - } -} - -#[tokio::test] -async fn request_rejects_unknown_chain() { - let pool = OrderBookPool::default(); - let err = pool - .request(Chain::from_id(99_999), Method::GET, "/x", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::UnknownChain(c) if c == Chain::from_id(99_999))); -} - -#[tokio::test] -async fn submit_order_propagates_orderbook_envelope() { - // The orderbook rejects with a typed envelope. The pool must - // surface `cowprotocol::Error::OrderbookApi { status, api }` - // so the WIT adapter can forward `api` to the cow-api-error - // rejected case. The string - // `DuplicatedOrder` is what the live - // Sepolia orderbook returns for an already-submitted order; - // it parses as `ApiError` even though the retriable-error - // classifier does not recognise the spelling. - let mock = MockServer::start().await; - let envelope = r#"{"errorType":"DuplicatedOrder","description":"order already exists"}"#; - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(400).set_body_string(envelope)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .submit_order_json(mainnet(), sample_order_json().as_bytes()) - .await - .expect_err("orderbook 400 surfaces as error"); - - match err { - CowApiError::Orderbook(cowprotocol::Error::OrderbookApi { status, api }) => { - assert_eq!(status, 400); - assert_eq!(api.error_type, "DuplicatedOrder"); - assert_eq!(api.description, "order already exists"); - } - other => panic!("expected OrderbookApi envelope, got {other:?}"), - } -} - -#[tokio::test] -async fn submit_order_propagates_orderbook_response() { - let mock = MockServer::start().await; - let body_json = sample_order_json(); - // cowprotocol POST /api/v1/orders returns the order UID - // (56-byte hex) as a JSON string body. - let returned_uid = format!("\"0x{}\"", "ab".repeat(56)); - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(201).set_body_string(returned_uid.clone())) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let uid = pool - .submit_order_json(mainnet(), body_json.as_bytes()) - .await - .expect("submit succeeds"); - assert_eq!(uid.as_slice().len(), 56); - assert_eq!(uid.as_slice(), &[0xab; 56]); -} - -/// A minimal but accepted-by-cowprotocol OrderCreation JSON. We -/// generate it inside the test so the JSON shape stays in lockstep -/// with the published `cowprotocol` version. -fn sample_order_json() -> String { - use alloy_primitives::{Address, U256}; - use cowprotocol::OrderCreation; - use cowprotocol::app_data::{EMPTY_APP_DATA_HASH, EMPTY_APP_DATA_JSON}; - use cowprotocol::order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}; - use cowprotocol::signature::Signature; - use cowprotocol::signing_scheme::SigningScheme; - - let order_data = OrderData { - sell_token: Address::from([0x01; 20]), - buy_token: Address::from([0x02; 20]), - receiver: None, - sell_amount: U256::from(100u64), - buy_amount: U256::from(99u64), - valid_to: u32::MAX, - app_data: EMPTY_APP_DATA_HASH, - fee_amount: U256::ZERO, - kind: OrderKind::Sell, - partially_fillable: false, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - }; - let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); - let creation = OrderCreation::new( - &order_data, - signature, - Address::from([0x03; 20]), - EMPTY_APP_DATA_JSON.to_owned(), - None, - ) - .expect("valid OrderCreation"); - serde_json::to_string(&creation).expect("serialise OrderCreation") -} - -#[tokio::test] -async fn request_rejects_malformed_path() { - // `Url::join` is very lenient for valid UTF-8 inputs. The - // `BadPath` variant fires only when `Url::join` returns a parse - // error, which is hard to provoke. Using a bare scheme-like - // string (`"://not-a-path"`) is NOT rejected because after - // stripping the leading `/` it is treated as a relative path - // component. Instead, feed a string that *will* reach the - // network but is handled by wiremock with a 404, confirming the - // passthrough returns Ok even for nonsensical paths. - let mock = MockServer::start().await; - let pool = pool_with_mainnet_at(&mock); - // wiremock returns 404 for any un-mocked route, now surfaced - // as HttpError (not Ok) since we distinguish HTTP status codes. - let err = pool - .request(mainnet(), Method::GET, "://not-a-path", None) - .await - .unwrap_err(); - assert!( - matches!(err, CowApiError::HttpError { status: 404, .. }), - "Url::join treats this as a relative path; wiremock 404 surfaces as HttpError" - ); -} - -#[tokio::test] -async fn request_network_error_on_dead_server() { - // Build the pool against a port that no one is listening on. - // We use port 1 (TCP echo / privileged) which is never bound - // by user-space processes, guaranteeing a connection-refused. - let mut clients = std::collections::HashMap::new(); - clients.insert( - mainnet(), - OrderBookApi::new_with_base_url("http://127.0.0.1:1/".parse().expect("valid url")), - ); - let pool = OrderBookPool { - clients, - http: reqwest::Client::new(), - }; - let err = pool - .request(mainnet(), Method::GET, "/api/v1/version", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Network(_))); -} - -#[tokio::test] -async fn request_5xx_response_surfaces_http_error_with_body() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/health")) - .respond_with(ResponseTemplate::new(500).set_body_string(r#"{"error":"internal"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .request(mainnet(), Method::GET, "/api/v1/health", None) - .await - .unwrap_err(); - match err { - CowApiError::HttpError { status, body } => { - assert_eq!(status, 500); - assert_eq!(body, r#"{"error":"internal"}"#); - } - other => panic!("expected HttpError, got: {other:?}"), - } -} - -#[tokio::test] -async fn submit_order_rejects_invalid_json() { - let pool = OrderBookPool::default(); - let err = pool - .submit_order_json(mainnet(), b"not json") - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Decode(_))); -} - -#[tokio::test] -async fn submit_order_rejects_wrong_schema() { - let pool = OrderBookPool::default(); - let err = pool - .submit_order_json(mainnet(), br#"{"valid":"json"}"#) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Decode(_))); -} diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs deleted file mode 100644 index b7c93271..00000000 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! The cow-api extension: `shepherd:cow/cow-api` wired through the -//! extension seam rather than hard-linked into the core host. -//! -//! Shape: a local `bindgen!` for the extension world, a `Host` impl for -//! the foreign `HostState` reached through [`ExtState`], a payload -//! trait ([`CowBackend`]) the lattice `Ext` member satisfies, and an -//! [`Extension`] impl carrying the linker hook and capability namespace. -//! -//! The bindgen shares `nexum:host/types` with the core bindings via -//! `with`, so the `fault` the extension's `cow-api-error` embeds is the -//! same type the core host constructs. - -use std::marker::PhantomData; -use std::sync::Arc; -use std::time::Instant; - -use alloy_chains::Chain; -use nexum_runtime::bindings::nexum::host::types::Fault; -use nexum_runtime::host::component::{BuilderContext, ComponentBuilder, RuntimeTypes}; -use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::state::{ExtState, HostState}; -use nexum_runtime::manifest::NamespaceCaps; -use wasmtime::component::{HasSelf, Linker}; - -use crate::cow::CowApi; -use crate::cow_orderbook::{CowApiError, OrderBookPool}; - -mod bindings { - wasmtime::component::bindgen!({ - path: [ - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/cow-ext", - imports: { default: async }, - with: { "nexum:host/types": nexum_runtime::bindings::nexum::host::types }, - }); -} - -use bindings::shepherd::cow::cow_api::{ - CowApiError as WitCowApiError, HttpFailure, OrderRejection, -}; - -/// Capability namespace this extension owns. Merged into capability -/// enforcement so a module importing `shepherd:cow/cow-api` validates. -pub const COW_CAPABILITIES: NamespaceCaps = NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], -}; - -/// Extension payload providing a cow-api backend. The lattice `Ext` member -/// implements this so the `Host` impl can extract the backend generically. -pub trait CowBackend { - /// The cow orderbook backend type. - type Cow: CowApi; - /// Borrow the cow backend. - fn cow(&self) -> &Self::Cow; -} - -/// The cow-api payload the reference engine ships in its `Ext` slot. -#[derive(Clone)] -pub struct ReferenceExt { - /// `cow-api` backend - per-chain `OrderBookApi` clients + reqwest. - pub cow: OrderBookPool, -} - -impl CowBackend for ReferenceExt { - type Cow = OrderBookPool; - fn cow(&self) -> &OrderBookPool { - &self.cow - } -} - -/// Builds the reference `Ext` payload: the cow orderbook pool from -/// `[extensions.cow]`. Lives here because the cow cone (and so the -/// [`OrderBookPool`] it opens) belongs to this extension crate, not the -/// core runtime. -pub struct ReferenceExtBuilder; - -impl ComponentBuilder for ReferenceExtBuilder { - type Output = ReferenceExt; - - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - let cow = OrderBookPool::from_config(ctx.config)?; - Ok(ReferenceExt { cow }) - } -} - -/// The cow-api extension over a lattice whose `Ext` payload carries a cow -/// backend. -struct CowExtension(PhantomData T>); - -impl Extension for CowExtension -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - fn namespace(&self) -> &'static str { - "cow" - } - - fn capabilities(&self) -> NamespaceCaps { - COW_CAPABILITIES - } - - fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { - // Link only the cow-api interface. The whole-world - // `CowExt::add_to_linker` would also re-add the shared - // `nexum:host/types` instance, which the core event-module - // linker already provides, tripping a "defined twice" error. - bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( - linker, - |s| s, - )?; - Ok(()) - } -} - -/// Build the cow extension for a lattice whose `Ext` payload carries a cow -/// backend. Wired at the composition root into `build_linker` and -/// capability enforcement. -pub fn extension() -> Arc> -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - Arc::new(CowExtension(PhantomData)) -} - -/// Project the backend [`CowApiError`] into the WIT `cow-api-error`. -/// -/// Local-shape failures (unknown chain, bad method/path, decode) become -/// a shared [`Fault`]; a transport-layer HTTP failure becomes an -/// [`Http`](bindings::shepherd::cow::cow_api::CowApiError::Http) case; an -/// orderbook rejection envelope is parsed once here into a -/// [`Rejected`](bindings::shepherd::cow::cow_api::CowApiError::Rejected) -/// case so the guest never re-decodes the failure body. -fn cow_error_to_wit(err: CowApiError) -> WitCowApiError { - match err { - CowApiError::UnknownChain(chain) => WitCowApiError::Fault(Fault::Unsupported(format!( - "chain {chain} not in cowprotocol" - ))), - CowApiError::BadMethod(m) => { - WitCowApiError::Fault(Fault::InvalidInput(format!("unsupported HTTP method: {m}"))) - } - CowApiError::BadPath(msg) => WitCowApiError::Fault(Fault::InvalidInput(msg)), - CowApiError::HttpError { status, body } => WitCowApiError::Http(HttpFailure { - status, - body: Some(body), - }), - CowApiError::Network(e) => WitCowApiError::Fault(Fault::Unavailable(e.to_string())), - CowApiError::Decode(e) => WitCowApiError::Fault(Fault::InvalidInput(format!( - "invalid OrderCreation JSON: {e}" - ))), - CowApiError::Orderbook(e) => orderbook_error_to_wit(e), - } -} - -/// Map a `cowprotocol::Error` to WIT form. -/// -/// An `OrderbookApi` reply is parsed once into a typed -/// [`OrderRejection`] carrying the orderbook's `errorType` / -/// `description` plus its optional structured `data` payload, -/// re-encoded as a JSON string. A non-2xx reply with an unparseable -/// body becomes an [`HttpFailure`]. Everything else is a host-side -/// [`Fault::Internal`]. -fn orderbook_error_to_wit(err: cowprotocol::Error) -> WitCowApiError { - match err { - cowprotocol::Error::OrderbookApi { status, api } => { - WitCowApiError::Rejected(OrderRejection { - status, - error_type: api.error_type, - description: api.description, - data: api.data.map(|d| d.to_string()), - }) - } - cowprotocol::Error::UnexpectedStatus { status, body } => { - WitCowApiError::Http(HttpFailure { - status, - body: Some(body), - }) - } - other => WitCowApiError::Fault(Fault::Internal(other.to_string())), - } -} - -impl bindings::shepherd::cow::cow_api::Host for HostState -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - async fn request( - &mut self, - chain_id: u64, - method: String, - path: String, - body: Option, - ) -> Result { - let start = Instant::now(); - let chain = Chain::from_id(chain_id); - tracing::debug!(chain_id, %method, %path, "cow-api::request"); - // The guest hands us a free-form method string; normalise to - // uppercase so `get` and `GET` both resolve, then type it. The - // allowlist itself lives behind the seam. - let method = match http::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) { - Ok(m) => m, - Err(_) => { - return Err(WitCowApiError::Fault(Fault::InvalidInput(format!( - "unsupported HTTP method: {method}" - )))); - } - }; - let result = self - .ext() - .cow() - .request(chain, method, &path, body.as_deref()) - .await - .map_err(cow_error_to_wit); - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::request done"); - result - } - - async fn submit_order( - &mut self, - chain_id: u64, - order_data: Vec, - ) -> Result { - let start = Instant::now(); - let chain = Chain::from_id(chain_id); - tracing::debug!(chain_id, bytes = order_data.len(), "cow-api::submit-order"); - let result = self - .ext() - .cow() - .submit_order_json(chain, &order_data) - .await - .map(|uid| alloy_primitives::hex::encode_prefixed(uid.as_slice())) - .map_err(cow_error_to_wit); - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::submit-order done"); - let outcome = if result.is_ok() { "ok" } else { "err" }; - metrics::counter!( - "shepherd_cow_api_submit_total", - "chain_id" => chain_id.to_string(), - "outcome" => outcome, - ) - .increment(1); - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cowprotocol::error::ApiError; - - #[test] - fn orderbook_api_error_becomes_typed_rejection() { - // The orderbook rejects with a typed envelope. The mapping - // parses it once, host-side, into an `order-rejection` so the - // guest dispatches on `error-type` without a second decode. - let api = ApiError { - error_type: "DuplicatedOrder".to_owned(), - description: "order already exists".to_owned(), - data: Some(serde_json::json!({"min_fee": "1234"})), - }; - let err = cowprotocol::Error::OrderbookApi { status: 400, api }; - - let WitCowApiError::Rejected(rejection) = orderbook_error_to_wit(err) else { - panic!("orderbook envelope must project to a typed rejection"); - }; - assert_eq!(rejection.status, 400); - assert_eq!(rejection.error_type, "DuplicatedOrder"); - assert_eq!(rejection.description, "order already exists"); - // The envelope's structured payload survives as a JSON string. - assert_eq!(rejection.data.as_deref(), Some(r#"{"min_fee":"1234"}"#)); - } - - #[test] - fn unexpected_status_becomes_http_failure() { - // A non-2xx reply with an unparseable body carries no typed - // rejection; it surfaces as a raw http-failure with the body - // preserved for diagnostics. - let err = cowprotocol::Error::UnexpectedStatus { - status: 502, - body: "upstream".to_owned(), - }; - - let WitCowApiError::Http(http) = orderbook_error_to_wit(err) else { - panic!("unexpected-status must project to an http-failure"); - }; - assert_eq!(http.status, 502); - assert_eq!(http.body.as_deref(), Some("upstream")); - } - - #[test] - fn backend_http_error_projects_to_http_failure() { - // The passthrough backend surfaces a non-2xx as `HttpError`; - // it must reach the guest as an http-failure so a 404 is - // matchable on `status`. - let err = CowApiError::HttpError { - status: 404, - body: "not found".to_owned(), - }; - - let WitCowApiError::Http(http) = cow_error_to_wit(err) else { - panic!("backend HttpError must project to an http-failure"); - }; - assert_eq!(http.status, 404); - assert_eq!(http.body.as_deref(), Some("not found")); - } - - #[test] - fn unknown_chain_projects_to_unsupported_fault() { - let err = CowApiError::UnknownChain(Chain::from_id(9999)); - assert!(matches!( - cow_error_to_wit(err), - WitCowApiError::Fault(Fault::Unsupported(_)), - )); - } -} diff --git a/crates/shepherd-cow-host/src/lib.rs b/crates/shepherd-cow-host/src/lib.rs deleted file mode 100644 index 2980ec7e..00000000 --- a/crates/shepherd-cow-host/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! The cow-api host extension: `shepherd:cow/cow-api` wired into the nexum -//! runtime through the linker extension seam. -//! -//! The core runtime knows nothing of CoW Protocol; this crate owns the -//! `cowprotocol` dependency, the `shepherd:cow/cow-ext` bindgen, the -//! orderbook backend, and the [`Extension`](nexum_runtime::host::extension::Extension) -//! value the composition root assembles into the linker and capability -//! registry. It depends on the runtime (for `HostState`, the extension -//! seam, and the shared `nexum:host/types` bindgen); the runtime never -//! depends on it, so the CoW cone stays out of the bare engine. - -pub mod config; -mod cow; -pub mod cow_orderbook; -pub mod ext_cow; - -pub use config::{CowConfig, CowConfigError}; -pub use cow::CowApi; -pub use cow_orderbook::{CowApiError, OrderBookPool}; -pub use ext_cow::{CowBackend, ReferenceExt, ReferenceExtBuilder, extension}; diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs deleted file mode 100644 index b195b537..00000000 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Boot-order coverage for the cow-api extension: a module that imports -//! `shepherd:cow/cow-api` boots and dispatches once the extension is wired -//! at the composition root, and fails to boot without it. -//! -//! These exercise the real wit-bindgen + supervisor path against pre-built -//! wasm artefacts and skip gracefully when the artefact is absent. - -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use nexum_runtime::bindings::nexum; -use nexum_runtime::engine_config::{EngineConfig, ModuleLimits}; -use nexum_runtime::host::component::{Components, RuntimeTypes}; -use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use nexum_runtime::host::state::HostState; -use nexum_runtime::supervisor::{Supervisor, build_linker}; -use shepherd_cow_host::{OrderBookPool, ReferenceExt, extension}; -use wasmtime::component::Linker; - -const SEPOLIA: u64 = 11_155_111; - -/// Reference-shaped lattice: the core backends plus the cow-api payload in -/// the extension slot, matching what the CLI composition root assembles. -#[derive(Debug, Clone, Copy, Default)] -struct CowTestTypes; - -impl nexum_runtime::sealed::SealedRuntimeTypes for CowTestTypes {} - -impl RuntimeTypes for CowTestTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -fn cow_extensions() -> Vec>> { - vec![extension::()] -} - -fn make_wasmtime_engine() -> wasmtime::Engine { - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - wasmtime::Engine::new(&config).expect("wasmtime engine") -} - -fn make_linker(engine: &wasmtime::Engine) -> Linker> { - build_linker::(engine, &cow_extensions()).expect("build_linker") -} - -/// A chainless provider pool: no `[chains]` entries, so every -/// `chain::request` surfaces `UnknownChain`. Enough to prove boot and -/// dispatch without a live RPC endpoint. -async fn chainless_pool() -> ProviderPool { - ProviderPool::from_config(&EngineConfig::default()) - .await - .expect("chainless provider pool") -} - -async fn test_components(store: LocalStore) -> Components { - Components { - chain: chainless_pool().await, - store, - ext: ReferenceExt { - cow: OrderBookPool::default(), - }, - logs: nexum_runtime::host::logs::LogPipeline::in_memory(ModuleLimits::default().logs()), - } -} - -fn temp_local_store() -> (tempfile::TempDir, LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("ls.redb"); - let store = LocalStore::open(path).expect("local store"); - (dir, store) -} - -/// Path to a module's `.wasm` artefact under the workspace target dir. -/// `CARGO_MANIFEST_DIR` is `crates/shepherd-cow-host`; two parents up is -/// the workspace root, mirroring the runtime's own helper. -fn module_wasm(module_name: &str) -> PathBuf { - let artifact = module_name.replace('-', "_"); - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) -} - -fn module_wasm_or_skip(module_name: &str) -> Option { - let p = module_wasm(module_name); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", - p.display() - ); - None - } -} - -fn production_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) -} - -fn synthetic_sepolia_block() -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id: SEPOLIA, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - } -} - -async fn boot_production_module( - engine: &wasmtime::Engine, - linker: &Linker>, - local_store: &LocalStore, - wasm: &Path, - manifest: &Path, -) -> Supervisor { - let components = test_components(local_store.clone()).await; - let limits = ModuleLimits::default(); - Supervisor::boot_single( - engine, - linker, - wasm, - Some(manifest), - &components, - &limits, - &cow_extensions(), - None, - ) - .await - .expect("boot_single") -} - -/// stop-loss imports `shepherd:cow/cow-api`; it boots with the cow -/// extension and a block dispatch reaches it. -#[tokio::test] -async fn e2e_stop_loss_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("stop-loss") else { - return; - }; - let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - -/// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (stop-loss) must NOT -/// boot when the cow extension is absent from the linker AND the -/// capability registry. The paired linker-hook + capability-namespace -/// registration is what makes the same module boot in the tests above; -/// drop the pairing and boot fails. -#[tokio::test] -async fn stop_loss_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("stop-loss") else { - return; - }; - let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); - let engine = make_wasmtime_engine(); - // Core-only: no cow linker hook, no cow capability namespace. - let linker = build_linker::(&engine, &[]).expect("build_linker"); - let (_dir, store) = temp_local_store(); - let components = test_components(store).await; - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await; - - let err = result - .err() - .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: ethflow-watcher declares - // the cow-api capability, which a core-only registry does not - // recognise (registering it is exactly what the cow extension does). - // Rules out an unrelated failure masquerading as the invariant. - let chain = format!("{err:#}"); - assert!( - chain.contains(r#"unknown capability "cow-api""#), - "expected the cow-api unknown-capability failure, got: {chain}", - ); -} diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml deleted file mode 100644 index ea2a4a47..00000000 --- a/crates/shepherd-sdk-test/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "shepherd-sdk-test" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "In-memory CoW host mock for Shepherd module unit tests. Implements shepherd_sdk::cow::CowApiHost and composes the nexum-sdk-test mocks." - -[lib] -# Plain library, host-only - module Cargo.toml lists this under -# [dev-dependencies] so it never ships in the wasm bundle. - -[dependencies] -nexum-sdk = { path = "../nexum-sdk" } -nexum-sdk-test = { path = "../nexum-sdk-test" } -shepherd-sdk = { path = "../shepherd-sdk" } -serde_json = { workspace = true, features = ["std"] } - -[dev-dependencies] -# Order construction for the MockVenue acceptance tests that drive -# the keeper run end to end. -alloy-primitives.workspace = true -composable-cow = { path = "../composable-cow" } -cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs deleted file mode 100644 index b0a8dd22..00000000 --- a/crates/shepherd-sdk-test/src/lib.rs +++ /dev/null @@ -1,669 +0,0 @@ -//! # shepherd-sdk-test -//! -//! In-memory implementation of the CoW-domain -//! [`shepherd_sdk::cow::CowApiHost`] trait, plus a [`MockHost`] that -//! composes it with the generic `nexum-sdk-test` mocks so a CoW module -//! can write integration tests for its strategy logic without -//! `wit-bindgen`, `wasmtime`, or a network round-trip. -//! -//! ## Usage -//! -//! Add as a dev-dep on the module crate and test against [`MockHost`]: -//! -//! ```rust -//! // Glob-import the host traits so the method shortcuts resolve. -//! use nexum_sdk::host::*; -//! use shepherd_sdk::cow::CowApiHost as _; -//! use shepherd_sdk_test::MockHost; -//! -//! let host = MockHost::new(); -//! host.cow_api.respond(Ok("0xuid".into())); -//! -//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); -//! assert_eq!(host.cow_api.call_count(), 1); -//! ``` -//! -//! Per-call venue scripting - outcome queues, status sequences, fault -//! injection - goes through [`MockVenue`] on the same seam: -//! -//! ```rust -//! use nexum_sdk::host::Fault; -//! use shepherd_sdk::cow::{CowApiError, CowApiHost as _}; -//! use shepherd_sdk_test::MockHost; -//! -//! let host = MockHost::with_venue(); -//! host.cow_api -//! .enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); -//! host.cow_api.enqueue_submit(Ok("0xuid".into())); -//! -//! assert!(host.submit_order(1, b"{}").is_err()); -//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); -//! ``` -//! -//! Modules that never touch the orderbook use `nexum-sdk-test`'s -//! `MockHost` directly instead. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -use std::cell::RefCell; -use std::collections::{HashMap, VecDeque}; - -use nexum_sdk::Level; -use nexum_sdk::host::{ - ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, - MessagingHost, RemoteStoreHost, -}; -use nexum_sdk::prelude::{Address, B256, Signature}; -use nexum_sdk_test::{ - MockChain, MockIdentity, MockLocalStore, MockLogging, MockMessaging, MockRemoteStore, -}; -use shepherd_sdk::cow::{CowApiError, CowApiHost}; - -/// Composed in-memory host for CoW modules: the generic per-trait -/// mocks plus a venue mock on the `shepherd:cow/cow-api` seam - -/// [`MockCowApi`] by default, [`MockVenue`] via -/// [`with_venue`](MockHost::with_venue). Each field exposes the -/// per-trait mock so tests can program responses and assert on calls. -#[derive(Default)] -pub struct MockHost { - /// `nexum:host/chain` mock. - pub chain: MockChain, - /// `nexum:host/identity` mock. - pub identity: MockIdentity, - /// `nexum:host/local-store` mock. - pub store: MockLocalStore, - /// `nexum:host/remote-store` mock. - pub remote_store: MockRemoteStore, - /// `nexum:host/messaging` mock. - pub messaging: MockMessaging, - /// `shepherd:cow/cow-api` mock. - pub cow_api: V, - /// `nexum:host/logging` mock. - pub logging: MockLogging, -} - -impl MockHost { - /// Fresh empty host. Equivalent to `Default::default`. - pub fn new() -> Self { - Self::default() - } -} - -impl MockHost { - /// Fresh empty host with [`MockVenue`] on the cow-api seam. - pub fn with_venue() -> Self { - Self::default() - } -} - -impl ChainHost for MockHost { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.chain.request(chain_id, method, params) - } -} - -impl LocalStoreHost for MockHost { - fn get(&self, key: &str) -> Result>, Fault> { - self.store.get(key) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.store.set(key, value) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.store.delete(key) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.store.list_keys(prefix) - } - fn contains(&self, key: &str) -> Result { - self.store.contains(key) - } - fn len(&self, key: &str) -> Result, Fault> { - // Qualified: the mock's inherent `len` counts rows. - LocalStoreHost::len(&self.store, key) - } - fn count(&self, prefix: &str) -> Result { - self.store.count(prefix) - } -} - -impl IdentityHost for MockHost { - fn accounts(&self) -> Result, Fault> { - self.identity.accounts() - } - fn sign(&self, account: Address, message: &[u8]) -> Result { - self.identity.sign(account, message) - } - fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { - self.identity.sign_typed_data(account, typed_data) - } -} - -impl RemoteStoreHost for MockHost { - fn upload(&self, data: &[u8]) -> Result { - self.remote_store.upload(data) - } - fn download(&self, reference: B256) -> Result, Fault> { - self.remote_store.download(reference) - } - fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { - self.remote_store.read_feed(owner, topic) - } - fn write_feed(&self, topic: B256, data: &[u8]) -> Result { - self.remote_store.write_feed(topic, data) - } -} - -impl MessagingHost for MockHost { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.messaging.publish(content_topic, payload) - } - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.messaging - .query(content_topic, start_time, end_time, limit) - } -} - -impl CowApiHost for MockHost { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.cow_api.submit_order(chain_id, body) - } - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.cow_api.cow_api_request(chain_id, method, path, body) - } -} - -impl LoggingHost for MockHost { - fn log(&self, level: Level, message: &str) { - self.logging.log(level, message); - } -} - -// ---------------------------------------------------------------- cow-api - -/// In-memory [`CowApiHost`] that captures every submission and returns -/// a programmable response. -#[derive(Default)] -pub struct MockCowApi { - response: RefCell>>, - calls: RefCell>, - /// `cow_api_request` mock state. Keyed by `(method, path)` so - /// tests can program different responses for `GET - /// /api/v1/app_data/0x...` vs other endpoints. Falls back to the - /// unkeyed `request_response` if no key matches. - request_responses: - RefCell>>, - request_response: RefCell>>, - request_calls: RefCell>, -} - -/// One recorded [`MockCowApi::submit_order`] invocation. -#[derive(Clone, Debug)] -pub struct SubmitCall { - /// Chain the guest targeted. - pub chain_id: u64, - /// Raw `OrderCreation` JSON body. - pub body: Vec, -} - -/// One recorded [`MockCowApi::cow_api_request`] invocation. -#[derive(Clone, Debug)] -pub struct RequestCall { - /// Chain the guest targeted. - pub chain_id: u64, - /// HTTP-style verb. - pub method: String, - /// Absolute orderbook path, e.g. `/api/v1/app_data/0xabcd...`. - pub path: String, - /// Optional JSON body (for POST/PUT). - pub body: Option, -} - -impl MockCowApi { - /// Program the response the mock returns on every subsequent - /// `submit_order` call. Defaults to an `Unsupported` fault if - /// unset. - pub fn respond(&self, result: Result) { - *self.response.borrow_mut() = Some(result); - } - - /// All submissions, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last submission, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Convenience: parse the most recent body as JSON. - pub fn last_body_as_json(&self) -> Option { - self.last_call() - .and_then(|c| serde_json::from_slice(&c.body).ok()) - } - - /// Count of submissions. - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } -} - -impl MockCowApi { - /// Program a response for a specific `(method, path)` pair. - /// Highest priority - used when both this and `respond_to_request` - /// are set. - pub fn respond_to_request_for( - &self, - method: impl Into, - path: impl Into, - result: Result, - ) { - self.request_responses - .borrow_mut() - .insert((method.into(), path.into()), result); - } - - /// Program the catch-all response for `cow_api_request` calls - /// that don't match a specific `(method, path)` key. Defaults - /// to an `Unsupported` fault. - pub fn respond_to_request(&self, result: Result) { - *self.request_response.borrow_mut() = Some(result); - } - - /// All `cow_api_request` invocations, in arrival order. - pub fn request_calls(&self) -> Vec { - self.request_calls.borrow().clone() - } -} - -impl CowApiHost for MockCowApi { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.calls.borrow_mut().push(SubmitCall { - chain_id, - body: body.to_vec(), - }); - self.response.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockCowApi: no response configured".to_string(), - ))) - }) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.request_calls.borrow_mut().push(RequestCall { - chain_id, - method: method.to_string(), - path: path.to_string(), - body: body.map(str::to_string), - }); - if let Some(r) = self - .request_responses - .borrow() - .get(&(method.to_string(), path.to_string())) - .cloned() - { - return r; - } - self.request_response.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockCowApi: no cow_api_request response configured".to_string(), - ))) - }) - } -} - -// ---------------------------------------------------------------- venue - -/// Scripted in-memory venue on the [`CowApiHost`] seam: programmable -/// per-call behaviour, unlike [`MockCowApi`]'s single replayed -/// response. Compose it with the generic mocks via -/// [`MockHost::with_venue`]. -/// -/// The two queue disciplines differ deliberately. Submissions are -/// discrete effects, so the submit queue strictly drains - one outcome -/// per call, then the configured fallback (default: an `Unsupported` -/// fault), so a test that scripts N outcomes catches an unexpected -/// N+1th submit. Responses are observations, so a `(method, path)` -/// sequence advances per call and its final entry replays forever - a -/// terminal order status persists no matter how often it is re-polled. -/// An injected fault overrides both (without consuming the queues) -/// until cleared, modelling a venue outage. -#[derive(Default)] -pub struct MockVenue { - submit_queue: RefCell>, - submit_fallback: RefCell>, - response_sequences: RefCell>>, - response_fallback: RefCell>, - fault: RefCell>, - calls: RefCell>, - request_calls: RefCell>, -} - -/// One scripted venue reply: the body / UID on success, a typed -/// [`CowApiError`] otherwise. -type VenueOutcome = Result; - -impl MockVenue { - /// Append one `submit_order` outcome to the queue; each call - /// consumes one, in order. - pub fn enqueue_submit(&self, outcome: Result) { - self.submit_queue.borrow_mut().push_back(outcome); - } - - /// Steady-state `submit_order` response once the queue is drained. - /// Unset, a drained queue yields an `Unsupported` fault. - pub fn set_submit_fallback(&self, outcome: Result) { - *self.submit_fallback.borrow_mut() = Some(outcome); - } - - /// Append one outcome to the `(method, path)` response sequence. - /// Each matching `cow_api_request` call advances the sequence; the - /// final entry sticks. - pub fn enqueue_response( - &self, - method: impl Into, - path: impl Into, - outcome: Result, - ) { - self.response_sequences - .borrow_mut() - .entry((method.into(), path.into())) - .or_default() - .push_back(outcome); - } - - /// Append one status-probe outcome for the order, keyed on the - /// orderbook's `GET /api/v1/orders/{uid}` route. - pub fn enqueue_order_status(&self, uid: &str, outcome: Result) { - self.enqueue_response("GET", format!("/api/v1/orders/{uid}"), outcome); - } - - /// Catch-all `cow_api_request` response for calls with no - /// programmed sequence. Unset, those yield an `Unsupported` fault. - pub fn set_response_fallback(&self, outcome: Result) { - *self.response_fallback.borrow_mut() = Some(outcome); - } - - /// Fail every venue call with `err` until - /// [`clear_fault`](Self::clear_fault) - a scripted outage. Queued - /// outcomes are not consumed while the fault is active. - pub fn inject_fault(&self, err: CowApiError) { - *self.fault.borrow_mut() = Some(err); - } - - /// Lift an injected fault; queued outcomes resume where they left - /// off. - pub fn clear_fault(&self) { - *self.fault.borrow_mut() = None; - } - - /// All submissions, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last submission, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Convenience: parse the most recent submission body as JSON. - pub fn last_body_as_json(&self) -> Option { - self.last_call() - .and_then(|c| serde_json::from_slice(&c.body).ok()) - } - - /// Count of submissions (failed and injected-fault calls included). - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } - - /// All `cow_api_request` invocations, in arrival order. - pub fn request_calls(&self) -> Vec { - self.request_calls.borrow().clone() - } - - /// Scripted submit outcomes not yet consumed - assert `0` to prove - /// a scenario played out in full. - pub fn pending_submits(&self) -> usize { - self.submit_queue.borrow().len() - } -} - -impl CowApiHost for MockVenue { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.calls.borrow_mut().push(SubmitCall { - chain_id, - body: body.to_vec(), - }); - if let Some(err) = self.fault.borrow().as_ref() { - return Err(err.clone()); - } - if let Some(outcome) = self.submit_queue.borrow_mut().pop_front() { - return outcome; - } - self.submit_fallback.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockVenue: submit queue exhausted and no fallback configured".to_string(), - ))) - }) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.request_calls.borrow_mut().push(RequestCall { - chain_id, - method: method.to_string(), - path: path.to_string(), - body: body.map(str::to_string), - }); - if let Some(err) = self.fault.borrow().as_ref() { - return Err(err.clone()); - } - if let Some(sequence) = self - .response_sequences - .borrow_mut() - .get_mut(&(method.to_string(), path.to_string())) - { - // Advance until one entry remains, then replay it: the - // sequence's final state persists. - if sequence.len() > 1 { - return sequence.pop_front().expect("length checked above"); - } - if let Some(last) = sequence.front() { - return last.clone(); - } - } - self.response_fallback.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockVenue: no response programmed for this request".to_string(), - ))) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cow_api_captures_body_and_returns_uid() { - let api = MockCowApi::default(); - api.respond(Ok("0xdeadbeef".into())); - let uid = api.submit_order(1, b"{\"x\":1}").unwrap(); - assert_eq!(uid, "0xdeadbeef"); - let last = api.last_call().unwrap(); - assert_eq!(last.chain_id, 1); - assert_eq!(last.body, b"{\"x\":1}"); - assert_eq!(api.last_body_as_json().unwrap()["x"], 1); - } - - #[test] - fn cow_api_default_response_is_unsupported() { - let api = MockCowApi::default(); - let err = api.submit_order(1, b"{}").unwrap_err(); - assert!( - matches!(err, CowApiError::Fault(Fault::Unsupported(_))), - "got {err:?}", - ); - } - - // ---- MockVenue ---- - - #[test] - fn venue_submit_queue_drains_in_order_then_falls_back() { - let venue = MockVenue::default(); - venue.enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); - venue.enqueue_submit(Ok("0xuid".into())); - - assert!(matches!( - venue.submit_order(1, b"{}"), - Err(CowApiError::Fault(Fault::Timeout)), - )); - assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); - assert_eq!(venue.pending_submits(), 0); - - // A drained queue is unsupported by default: an unscripted - // extra submit fails loudly. - assert!(matches!( - venue.submit_order(1, b"{}"), - Err(CowApiError::Fault(Fault::Unsupported(_))), - )); - - venue.set_submit_fallback(Ok("0xsteady".into())); - assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xsteady"); - assert_eq!(venue.call_count(), 4, "every call is recorded"); - } - - #[test] - fn venue_records_submissions_like_the_single_shot_mock() { - let venue = MockVenue::default(); - venue.enqueue_submit(Ok("0xuid".into())); - venue.submit_order(7, b"{\"x\":1}").unwrap(); - - let last = venue.last_call().unwrap(); - assert_eq!(last.chain_id, 7); - assert_eq!(last.body, b"{\"x\":1}"); - assert_eq!(venue.last_body_as_json().unwrap()["x"], 1); - } - - #[test] - fn venue_fault_injection_overrides_queues_until_cleared() { - let venue = MockVenue::default(); - venue.enqueue_submit(Ok("0xuid".into())); - venue.enqueue_response("GET", "/api/v1/orders/0x1", Ok("{}".into())); - venue.inject_fault(CowApiError::Fault(Fault::Unavailable("down".into()))); - - assert!(matches!( - venue.submit_order(1, b"{}"), - Err(CowApiError::Fault(Fault::Unavailable(_))), - )); - assert!( - venue - .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) - .is_err() - ); - - // The outage consumed nothing: outcomes resume on recovery. - venue.clear_fault(); - assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); - assert_eq!( - venue - .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) - .unwrap(), - "{}", - ); - assert_eq!(venue.call_count(), 2); - assert_eq!(venue.request_calls().len(), 2); - } - - #[test] - fn venue_response_sequence_advances_and_final_entry_sticks() { - let venue = MockVenue::default(); - for body in ["\"open\"", "\"open\"", "\"fulfilled\""] { - venue.enqueue_order_status("0xuid", Ok(body.into())); - } - let probe = || { - venue - .cow_api_request(1, "GET", "/api/v1/orders/0xuid", None) - .unwrap() - }; - assert_eq!(probe(), "\"open\""); - assert_eq!(probe(), "\"open\""); - assert_eq!(probe(), "\"fulfilled\""); - // The terminal entry replays for any later re-poll. - assert_eq!(probe(), "\"fulfilled\""); - } - - #[test] - fn venue_unscripted_request_uses_the_fallback_then_defaults() { - let venue = MockVenue::default(); - assert!(matches!( - venue.cow_api_request(1, "GET", "/api/v1/anything", None), - Err(CowApiError::Fault(Fault::Unsupported(_))), - )); - venue.set_response_fallback(Ok("catch-all".into())); - assert_eq!( - venue - .cow_api_request(1, "GET", "/api/v1/anything", None) - .unwrap(), - "catch-all", - ); - } - - #[test] - fn mock_host_with_venue_dispatches_through_cow_host_bound() { - let host = MockHost::with_venue(); - host.cow_api.enqueue_submit(Ok("0xuid".into())); - - let _: &dyn shepherd_sdk::cow::CowHost = &host; - assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); - assert_eq!(host.cow_api.call_count(), 1); - } - - #[test] - fn mock_host_dispatches_through_cow_host_bound() { - let host = MockHost::new(); - host.chain - .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); - host.cow_api.respond(Ok("0xuid".into())); - - // Through the `CowHost` bound. - let _: &dyn shepherd_sdk::cow::CowHost = &host; - host.set("key", b"val").unwrap(); - assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); - assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); - assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); - host.log(Level::INFO, "happy path"); - - assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); - assert_eq!(host.logging.lines().len(), 1); - assert_eq!(host.store.len(), 1); - } -} diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs deleted file mode 100644 index ec371ca2..00000000 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! MockVenue acceptance tests: the scripted venue driving the keeper -//! run (multi-tick retry, backoff, and outage scenarios the -//! single-replayed-response mock cannot express) and module-shaped -//! strategy code polling the venue directly. - -use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; -use composable_cow::Verdict; -use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; -use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; -use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{ - CowApiError, CowApiTransport, CowClient, CowHost, CowIntent, CowIntentBody, OrderRejection, - SignedOrder, gpv2_to_order_data, order_data_to_body, order_uid_hex, run, -}; -use shepherd_sdk_test::{MockHost, MockVenue}; - -const SEPOLIA: u64 = 11_155_111; - -type VenueHost = MockHost; - -/// The typed client every test drives `run` with: the transitional -/// cow-api bridge over the scripted venue host. -fn venue(host: &VenueHost) -> CowClient> { - CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) -} - -/// Closure-backed source so each test scripts its own outcome. -struct FnSource(F); - -impl ConditionalSource for FnSource -where - F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, -{ - type Outcome = Verdict; - - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { - (self.0)(host, watch, params, tick) - } -} - -/// Pin the closure to the higher-ranked source signature at the -/// construction site so inference never guesses a too-narrow lifetime. -fn src(f: F) -> FnSource -where - F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, -{ - FnSource(f) -} - -fn sample_owner() -> Address { - address!("00112233445566778899aabbccddeeff00112233") -} - -fn sample_tick() -> Tick { - Tick { - chain_id: SEPOLIA, - block: 1_000, - epoch_s: 1_700_000_000, - } -} - -/// `validTo` a given number of seconds from now. The `OrderCreation` -/// constructor's client-side max-horizon policy reads the wall clock -/// (not the block clock), so test orders must expire relative to it. -fn valid_to_in(seconds: u64) -> u32 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the epoch") - .as_secs(); - u32::try_from(now + seconds).expect("test validTo fits u32") -} - -fn submittable_order() -> GPv2OrderData { - GPv2OrderData { - sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), - buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), - receiver: Address::ZERO, - sellAmount: U256::from(1_000_000_u64), - buyAmount: U256::from(999_u64), - validTo: valid_to_in(3_600), - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - } -} - -fn ready_outcome(order: &GPv2OrderData) -> Verdict { - Verdict::Post { - order: Box::new(order.clone()), - signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), - next_poll_timestamp: 0, - } -} - -fn ready_source( - order: &GPv2OrderData, -) -> FnSource, &[u8], &Tick) -> Verdict> { - let order = order.clone(); - src(move |_, _, _, _| ready_outcome(&order)) -} - -fn seed_watch(host: &VenueHost) -> String { - WatchSet::new(host) - .put( - &sample_owner(), - &keccak256(b"conditional order params"), - b"params", - ) - .unwrap() -} - -fn client_uid(order: &GPv2OrderData) -> String { - order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") -} - -/// The intent-id the keeper journals for `order`: the venue-and-body -/// key over the same signed body `run` derives pre-submit. -fn intent_id(order: &GPv2OrderData) -> String { - let order_data = gpv2_to_order_data(order).expect("known markers"); - shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: order_data_to_body(&order_data), - owner: sample_owner().into_array(), - signature: hex!("c0ffeec0ffeec0ffee").to_vec(), - }))) - .expect("body encodes") -} - -fn rejection(error_type: &str) -> CowApiError { - CowApiError::Rejected(OrderRejection { - status: 400, - error_type: error_type.into(), - description: "test".into(), - data: None, - }) -} - -// ---- keeper use ---- - -/// A transient rejection on the first tick keeps the watch alive; the -/// next tick's scripted success is journalled. Per-call scripting is -/// the point: one venue plays a different outcome on each tick. -#[test] -fn keeper_retries_a_transient_rejection_then_submits() { - let host = MockHost::with_venue(); - let key = seed_watch(&host); - let order = submittable_order(); - host.cow_api - .enqueue_submit(Err(rejection("InsufficientFee"))); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - - let source = ready_source(&order); - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(host.store.snapshot().contains_key(&key), "watch survives"); - assert!( - !Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); - - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 2); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); - assert_eq!( - host.cow_api.pending_submits(), - 0, - "scenario played out in full" - ); -} - -/// A rate-limit with server guidance gates the watch on the epoch -/// clock; the venue is only reached again once the gate clears, and -/// the queued success then lands. -#[test] -fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { - let host = MockHost::with_venue(); - seed_watch(&host); - let order = submittable_order(); - host.cow_api - .enqueue_submit(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(2_500), - })))); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - - let t0 = sample_tick(); - let source = ready_source(&order); - run(&host, &venue(&host), &source, &t0).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - - // 2500ms rounds up to a 3s epoch gate: a tick inside it never - // reaches the venue. - let gated = Tick { - epoch_s: t0.epoch_s + 2, - ..t0 - }; - run(&host, &venue(&host), &source, &gated).unwrap(); - assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); - - let clear = Tick { - epoch_s: t0.epoch_s + 3, - ..t0 - }; - run(&host, &venue(&host), &source, &clear).unwrap(); - assert_eq!(host.cow_api.call_count(), 2); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); -} - -/// A venue outage is transient: the watch stays, nothing is gated, and -/// the first tick after recovery submits the queued outcome. -#[test] -fn keeper_survives_a_venue_outage_and_submits_on_recovery() { - let host = MockHost::with_venue(); - let key = seed_watch(&host); - let watch_key = WatchRef::parse(&key).unwrap(); - let order = submittable_order(); - host.cow_api - .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); - - let source = ready_source(&order); - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&key)); - assert!(!snapshot.contains_key(&watch_key.next_block_key())); - assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); - assert_eq!(host.cow_api.call_count(), 1); - - host.cow_api.clear_fault(); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 2); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); -} - -/// A scripted permanent rejection drops the watch through the ledger. -#[test] -fn keeper_drops_the_watch_on_a_scripted_permanent_rejection() { - let host = MockHost::with_venue(); - seed_watch(&host); - let order = submittable_order(); - host.cow_api - .enqueue_submit(Err(rejection("InvalidSignature"))); - - run(&host, &venue(&host), &ready_source(&order), &sample_tick()).unwrap(); - - assert!(host.store.is_empty(), "watch and gates must go"); - assert_eq!(host.cow_api.call_count(), 1); -} - -/// Keeper rows written through the composed host stay invisible to a -/// sibling store namespace, and a decoy watch planted there never -/// reaches the sweep - the store-fidelity seam under the venue tests. -#[test] -fn keeper_sweep_ignores_sibling_namespace_watches() { - let host = MockHost::with_venue(); - seed_watch(&host); - let sibling = host.store.namespaced("other-module"); - assert!(sibling.is_empty(), "keeper rows must not leak across"); - sibling - .set( - "watch:0x00112233445566778899aabbccddeeff00112233:0xdead", - b"decoy", - ) - .unwrap(); - - let order = submittable_order(); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - let polls = std::cell::Cell::new(0_u32); - run( - &host, - &venue(&host), - &src(|_, _, _, _| { - polls.set(polls.get() + 1); - ready_outcome(&order) - }), - &sample_tick(), - ) - .unwrap(); - - assert_eq!(polls.get(), 1, "only this module's watch is swept"); - assert_eq!(host.cow_api.call_count(), 1); -} - -// ---- module use ---- - -/// Module-shaped fill tracker: probe the orderbook status route and -/// journal an `observed:` receipt once the order reports fulfilled. -/// Generic over [`CowHost`] exactly like production strategy code. -fn record_fill(host: &H, chain_id: u64, uid: &str) -> Result { - let path = format!("/api/v1/orders/{uid}"); - let Ok(body) = host.cow_api_request(chain_id, "GET", &path, None) else { - return Ok(false); - }; - let fulfilled = serde_json::from_str::(&body) - .ok() - .and_then(|v| v.get("status").and_then(|s| s.as_str().map(str::to_owned))) - .is_some_and(|status| status == "fulfilled"); - if fulfilled { - Journal::observed(host).record(uid)?; - } - Ok(fulfilled) -} - -/// The status sequence advances one entry per module poll and its -/// terminal entry persists across any number of re-polls. -#[test] -fn module_tracks_a_fill_through_a_status_sequence() { - let host = MockHost::with_venue(); - for body in [ - r#"{"status":"open"}"#, - r#"{"status":"open"}"#, - r#"{"status":"fulfilled"}"#, - ] { - host.cow_api.enqueue_order_status("0xuid", Ok(body.into())); - } - - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); - // Terminal status sticks: an over-eager re-poll sees it again. - assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); - - assert!(Journal::observed(&host).contains("0xuid").unwrap()); - assert_eq!(host.cow_api.request_calls().len(), 4); - assert_eq!(host.cow_api.request_calls()[0].path, "/api/v1/orders/0xuid"); -} - -/// An outage mid-sequence surfaces to the module as a failed probe and -/// consumes nothing: the sequence resumes where it left off. -#[test] -fn module_probe_rides_out_an_injected_outage() { - let host = MockHost::with_venue(); - host.cow_api - .enqueue_order_status("0xuid", Ok(r#"{"status":"open"}"#.into())); - host.cow_api - .enqueue_order_status("0xuid", Ok(r#"{"status":"fulfilled"}"#.into())); - - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - - host.cow_api - .inject_fault(CowApiError::Fault(Fault::Timeout)); - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - - host.cow_api.clear_fault(); - assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); - assert!(Journal::observed(&host).contains("0xuid").unwrap()); -} - -/// The free `watch_key` helper produces exactly the key -/// `WatchSet::put` writes through the venue host, so a test can seed -/// or assert rows without a host turbofish. -#[test] -fn watch_key_helper_unifies_with_the_venue_host() { - let host = MockHost::with_venue(); - let hash: B256 = keccak256(b"conditional order params"); - let written = WatchSet::new(&host) - .put(&sample_owner(), &hash, b"params") - .unwrap(); - assert_eq!(watch_key(&sample_owner(), &hash), written); -} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml deleted file mode 100644 index a2c7f216..00000000 --- a/crates/shepherd-sdk/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "shepherd-sdk" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, and prelude on top of cowprotocol types." - -[lib] -# Plain library - modules link this and emit their own cdylib for the -# WASM Component. Building shepherd-sdk on the host target is also -# supported so the helpers are unit-testable without a wasm toolchain. - -[dependencies] -# Re-export shim: the venue-neutral intent body types now live in the -# cow-venue default slice; this crate re-exports them under `cow` until -# the module ports move off the legacy path. The `client` slice carries -# the table-driven retry classification the cow error surface delegates -# to; the `assembly` slice carries the chain-edge order projections the -# keeper run still drives. -cow-venue = { path = "../cow-venue", features = ["client", "assembly"] } -# The structured poll seam the keeper run dispatches on. -composable-cow = { path = "../composable-cow" } -nexum-sdk = { path = "../nexum-sdk" } -# The typed client seam the keeper run submits through; also the -# `VenueTransport` contract the legacy cow-api bridge implements. -videre-sdk = { path = "../videre-sdk" } -cowprotocol = { version = "0.2.0", default-features = false } -alloy-primitives.workspace = true -serde_json.workspace = true -strum.workspace = true -thiserror.workspace = true -tracing.workspace = true - -[dev-dependencies] -# `capture_tracing` observes the keeper run's diagnostics in the -# acceptance tests. -nexum-sdk-test = { path = "../nexum-sdk-test" } -alloy-sol-types.workspace = true -proptest.workspace = true -# Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, -# and the keeper run acceptance-tests against the composed MockHost -# as an integration test - the mock crate links shepherd-sdk -# externally, so the unit-test copy of the traits would not unify. -shepherd-sdk-test = { path = "../shepherd-sdk-test" } diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md deleted file mode 100644 index 13a9ae0c..00000000 --- a/crates/shepherd-sdk/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# shepherd-sdk - -CoW-domain guest SDK for [Shepherd](https://github.com/nullislabs/shepherd) modules. - -`shepherd-sdk` layers the CoW Protocol surface on top of the generic -`nexum-sdk`: the module keeps its own `wit_bindgen::generate!` call -(which emits the world-specific `Guest` trait and host-import shims -into the module's own crate), pulls the host trait seam and generic -helpers from `nexum-sdk`, and pulls the CoW types and helpers from -here. Nothing is re-exported between the two crates; modules import -each directly. - -## Quick tour - -```rust -use nexum_sdk::prelude::*; -use shepherd_sdk::prelude::*; -use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; -``` - -| Module | What it provides | -|---|---| -| `prelude` | One-liner `use ::*` for cowprotocol order / signing / orderbook surface (alloy primitives come from `nexum_sdk::prelude`). | -| `cow` | `CowApiHost` trait for `shepherd:cow/cow-api` + the `CowHost` bound over the core `nexum_sdk::host::Host`. | -| `cow::order` | `gpv2_to_order_data` - `GPv2OrderData` -> typed `OrderData`. | -| `cow::composable` | `sol! IConditionalOrder` errors + `Verdict` + `LegacyRevertAdapter`. | -| `cow::error` | `CowApiError` (mirror of `cow-api-error`: `Fault` / `Http` / `Rejected`) + `RetryAction` enum + `classify_api_error` over an `OrderRejection`. | -| `wit_bindgen_macro` | `bind_cow_host_via_wit_bindgen!` - the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. | - -## Testing modules host-free - -Add the companion `shepherd-sdk-test` crate as a dev-dep and write -your strategy function against `&impl shepherd_sdk::cow::CowHost` -(or `&impl nexum_sdk::host::Host` if it never touches the -orderbook). Tests against `MockHost` then run without `wit-bindgen` -or `wasmtime`: - -```rust,ignore -let host = shepherd_sdk_test::MockHost::new(); -host.cow_api.respond(Ok("0xuid".into())); -submit_watch(&host, 1).unwrap(); -assert_eq!(host.cow_api.call_count(), 1); -``` - -## Why no `wit_bindgen::generate!` in the SDK - -The macro emits types into the calling crate (the module's cdylib). -Re-exporting wit-bindgen output from a library would duplicate -symbols and break the component-export contract. Helpers in this -SDK take primitive arguments (`&[u8]`, `&str`, `Option<&str>`) so -the SDK stays world-neutral; modules unpack their wit-bindgen -`Fault` / `Log` into primitives at the call site. Trade-off -documented in ADR-0006 and ADR-0007 in `docs/adr/`. - -## Layout - -``` -crates/shepherd-sdk/ -├── src/ -│ ├── lib.rs crate root + intra-doc links -│ ├── prelude.rs cowprotocol bulk re-exports -│ ├── cow/ -│ │ ├── mod.rs CowApiHost + CowHost -│ │ ├── order.rs gpv2_to_order_data -│ │ ├── composable.rs IConditionalOrder + Verdict + LegacyRevertAdapter -│ │ └── error.rs RetryAction + classify_api_error -│ └── wit_bindgen_macro.rs bind_cow_host_via_wit_bindgen! -└── README.md you are here - -(The generic surface - host trait seam, chain / config / address -helpers, http, tracing - lives in the sibling `nexum-sdk` crate.) -``` - -## Generating docs locally - -```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk --no-deps --open -``` - -The CI gate `cargo doc -p shepherd-sdk --no-deps` runs under those -flags, so all public items carry doc comments and intra-doc links -resolve. diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs deleted file mode 100644 index a5e0de03..00000000 --- a/crates/shepherd-sdk/src/cow/error.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Typed `shepherd:cow/cow-api` error surface and orderbook rejection -//! classification. -//! -//! [`CowApiError`] mirrors the WIT `cow-api-error` variant: a shared -//! host [`Fault`], a raw [`HttpFailure`], or a typed [`OrderRejection`] -//! the host parsed once from the orderbook's `{errorType, description}` -//! envelope. The guest dispatches on the variant directly, so no -//! second JSON decode of a failure body happens strategy-side. -//! -//! [`classify_api_error`] maps a decoded [`OrderRejection`] into the -//! keeper [`RetryAction`] the retry ledger dispatches on; -//! [`classify_submit_error`] widens the table to the whole -//! [`CowApiError`] surface. - -use nexum_sdk::host::{Fault, HostFault}; -use strum::IntoStaticStr; - -pub use nexum_sdk::keeper::RetryAction; - -/// A non-2xx orderbook reply with no typed rejection envelope. `body` -/// is the raw response text, foreign orderbook JSON kept verbatim: a -/// caller matches on `status` and reads `body` only for diagnostics. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct HttpFailure { - /// HTTP status code. - pub status: u16, - /// Raw response body, when the host captured one. - pub body: Option, -} - -/// A typed orderbook rejection of a submitted order, parsed once -/// host-side from the `{errorType, description, data}` envelope. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OrderRejection { - /// HTTP status returned with the rejection. - pub status: u16, - /// Machine-readable `errorType` (e.g. `"InsufficientFee"`). - pub error_type: String, - /// Human-readable description. - pub description: String, - /// The envelope's optional structured payload (e.g. a minimum-fee - /// quote), serialised to a JSON string by the host via - /// `serde_json::Value::to_string`. - pub data: Option, -} - -/// Mirror of `shepherd:cow/cow-api.cow-api-error`. The domain-side -/// counterpart the [`bind_cow_host_via_wit_bindgen`](crate::bind_cow_host_via_wit_bindgen) -/// macro converts the per-cdylib wit-bindgen error into, so strategy -/// logic dispatches on one host-neutral type. -/// -/// `IntoStaticStr` exposes the variant name as a snake_case `&'static -/// str`; [`HostFault::label`] refines the [`Fault`] case to the -/// embedded fault's own label so metric and log labels stay granular. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowApiError { - /// A shared host fault (unsupported, timeout, transport down, ...). - #[error(transparent)] - Fault(Fault), - /// A raw non-2xx HTTP reply without a typed rejection envelope. - #[error("orderbook http {}", .0.status)] - Http(HttpFailure), - /// A typed orderbook rejection of a submitted order. - #[error("orderbook rejected ({} {}): {}", .0.status, .0.error_type, .0.description)] - Rejected(OrderRejection), -} - -impl nexum_sdk::host::sealed::SealedHostFault for CowApiError {} - -impl HostFault for CowApiError { - fn fault(&self) -> Option<&Fault> { - match self { - CowApiError::Fault(f) => Some(f), - _ => None, - } - } - - fn label(&self) -> &'static str { - match self { - CowApiError::Fault(f) => f.label(), - other => other.into(), - } - } -} - -/// Classify a decoded orderbook [`OrderRejection`] into the keeper -/// [`RetryAction`] via the shipped CoW classification table -/// ([`cow_venue::classify`]): the `errorType` drives the action - -/// transient types retry next block, throttle types back off, permanent -/// types drop. The one invariant the table enforces: an `errorType` -/// absent from the data is permanent, never retried every block forever. -/// -/// Non-`Rejected` failures carry no `error_type`; classify those with -/// [`classify_submit_error`]. -/// -/// # Example -/// -/// ``` -/// use shepherd_sdk::cow::{classify_api_error, OrderRejection, RetryAction}; -/// -/// // Transient: orderbook rejects with InsufficientFee -> retry next block. -/// let transient = OrderRejection { -/// status: 400, -/// error_type: "InsufficientFee".to_string(), -/// description: "fee too low".to_string(), -/// data: None, -/// }; -/// assert_eq!(classify_api_error(&transient), RetryAction::TryNextBlock); -/// -/// // Permanent: InvalidSignature -> drop the watch / placement. -/// let permanent = OrderRejection { -/// status: 400, -/// error_type: "InvalidSignature".to_string(), -/// description: "bad sig".to_string(), -/// data: None, -/// }; -/// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); -/// ``` -pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - cow_venue::classify(&rejection.error_type) -} - -/// Whether the rejection says the orderbook already holds this exact -/// order, per the classification table's `already-submitted` flag -/// (`DuplicatedOrder`, plus the `DuplicateOrder` spelling older -/// deployments emit). Already-submitted is success wearing an error -/// status - dropping the watch on it would kill every future tranche of -/// a TWAP - so the caller records the `submitted:` receipt and keeps the -/// watch. -pub fn is_already_submitted(rejection: &OrderRejection) -> bool { - cow_venue::is_already_submitted(&rejection.error_type) -} - -/// Classify a whole [`CowApiError`] from a submission into the keeper -/// [`RetryAction`]. -/// -/// A typed rejection dispatches through [`classify_api_error`]; a -/// rate-limit fault with server guidance becomes `Backoff` (hint -/// rounded up to whole seconds, minimum one). Everything else -/// (transport faults, raw HTTP errors, unguided rate limits) is -/// transient -> `TryNextBlock`, so a flaky orderbook never poisons a -/// still-valid order. -pub fn classify_submit_error(err: &CowApiError) -> RetryAction { - match err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - CowApiError::Fault(Fault::RateLimited(limit)) => match limit.retry_after_ms { - Some(ms) => RetryAction::Backoff { - seconds: ms.div_ceil(1000).max(1), - }, - None => RetryAction::TryNextBlock, - }, - _ => RetryAction::TryNextBlock, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use nexum_sdk::host::RateLimit; - - fn rejection(error_type: &str) -> OrderRejection { - OrderRejection { - status: 400, - error_type: error_type.to_string(), - description: "test".to_string(), - data: None, - } - } - - #[test] - fn retriable_kind_yields_try_next_block() { - assert_eq!( - classify_api_error(&rejection("InsufficientFee")), - RetryAction::TryNextBlock, - ); - } - - /// A throttle errorType backs off rather than retrying next block, - /// so the table reaches every retry arm - the `Backoff` producer the - /// hand-coded classifier lacked. - #[test] - fn throttle_kind_yields_backoff() { - assert_eq!( - classify_api_error(&rejection("TooManyLimitOrders")), - RetryAction::Backoff { seconds: 30 }, - ); - } - - #[test] - fn permanent_kinds_yield_drop() { - for kind in [ - "InvalidSignature", - "WrongOwner", - "UnsupportedToken", - "InvalidAppData", - "InvalidEip1271Signature", - ] { - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::Drop, - "{kind}", - ); - } - } - - #[test] - fn unknown_kind_yields_drop() { - assert_eq!( - classify_api_error(&rejection("NewlyMintedErrorType")), - RetryAction::Drop, - ); - } - - /// Both spellings pin: the orderbook emits `DuplicatedOrder`, the - /// older `DuplicateOrder` form must classify identically. Neither - /// may drop the watch - that would kill every future tranche. - #[test] - fn duplicated_order_is_already_submitted_and_never_drops() { - for kind in ["DuplicatedOrder", "DuplicateOrder"] { - assert!(is_already_submitted(&rejection(kind)), "{kind}"); - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::TryNextBlock, - "{kind}", - ); - } - assert!(!is_already_submitted(&rejection("InsufficientFee"))); - assert!(!is_already_submitted(&rejection("InvalidSignature"))); - } - - #[test] - fn submit_error_rejection_routes_through_the_table() { - assert_eq!( - classify_submit_error(&CowApiError::Rejected(rejection("InvalidSignature"))), - RetryAction::Drop, - ); - assert_eq!( - classify_submit_error(&CowApiError::Rejected(rejection("InsufficientFee"))), - RetryAction::TryNextBlock, - ); - } - - #[test] - fn submit_error_rate_limit_hint_becomes_backoff_in_whole_seconds() { - let limited = |ms| CowApiError::Fault(Fault::RateLimited(RateLimit { retry_after_ms: ms })); - assert_eq!( - classify_submit_error(&limited(Some(2_500))), - RetryAction::Backoff { seconds: 3 }, - ); - // Sub-second hints round up to a full second, never to zero. - assert_eq!( - classify_submit_error(&limited(Some(1))), - RetryAction::Backoff { seconds: 1 }, - ); - // No guidance -> plain next-block retry. - assert_eq!( - classify_submit_error(&limited(None)), - RetryAction::TryNextBlock - ); - } - - #[test] - fn submit_error_transient_shapes_stay_try_next_block() { - assert_eq!( - classify_submit_error(&CowApiError::Fault(Fault::Timeout)), - RetryAction::TryNextBlock, - ); - assert_eq!( - classify_submit_error(&CowApiError::Http(HttpFailure { - status: 502, - body: None, - })), - RetryAction::TryNextBlock, - ); - } - - #[test] - fn fault_case_recovers_embedded_fault_and_label() { - let err = CowApiError::Fault(Fault::Timeout); - assert_eq!(err.fault(), Some(&Fault::Timeout)); - // Fault case refines the label to the embedded fault's own. - assert_eq!(err.label(), "timeout"); - - let rl = CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(250), - })); - assert_eq!(rl.label(), "rate_limited"); - } - - #[test] - fn non_fault_cases_expose_variant_label_and_no_fault() { - let http = CowApiError::Http(HttpFailure { - status: 404, - body: None, - }); - assert_eq!(http.fault(), None); - assert_eq!(http.label(), "http"); - - let rejected = CowApiError::Rejected(rejection("InvalidSignature")); - assert_eq!(rejected.fault(), None); - assert_eq!(rejected.label(), "rejected"); - } -} diff --git a/crates/shepherd-sdk/src/cow/events.rs b/crates/shepherd-sdk/src/cow/events.rs deleted file mode 100644 index 20acdd42..00000000 --- a/crates/shepherd-sdk/src/cow/events.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! CoW on-chain event ABIs, mirroring `shepherd:cow/cow-events`. -//! -//! `wit/shepherd-cow/cow-events.wit` is the package of record; the -//! constants here are parity-tested against it, the `cowprotocol` -//! `sol!` types, and each keeper's `module.toml`. - -use alloy_primitives::{B256, b256}; - -/// One on-chain event surface: the canonical Solidity signature and -/// its keccak256 topic-0. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EventAbi { - /// Canonical Solidity event signature. - pub signature: &'static str, - /// keccak256 of [`Self::signature`]: the log's topic-0. - pub topic0: B256, -} - -/// `ComposableCoW.ConditionalOrderCreated`. -pub const CONDITIONAL_ORDER_CREATED: EventAbi = EventAbi { - signature: "ConditionalOrderCreated(address,(address,bytes32,bytes))", - topic0: b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), -}; - -/// `CoWSwapOnchainOrders.OrderPlacement` (EthFlow). -pub const ORDER_PLACEMENT: EventAbi = EventAbi { - signature: "OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,\ - uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)", - topic0: b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), -}; - -/// Every event surface the keepers decode. -pub const ALL: &[EventAbi] = &[CONDITIONAL_ORDER_CREATED, ORDER_PLACEMENT]; - -#[cfg(test)] -mod tests { - use alloy_primitives::keccak256; - use alloy_sol_types::SolEvent; - use cowprotocol::{CoWSwapOnchainOrders, ComposableCoW}; - - use super::*; - - #[test] - fn topic0_is_keccak_of_signature() { - for abi in ALL { - assert_eq!(abi.topic0, keccak256(abi.signature), "{}", abi.signature); - } - } - - #[test] - fn matches_the_sol_decoder_types() { - assert_eq!( - ComposableCoW::ConditionalOrderCreated::SIGNATURE, - CONDITIONAL_ORDER_CREATED.signature, - ); - assert_eq!( - ComposableCoW::ConditionalOrderCreated::SIGNATURE_HASH, - CONDITIONAL_ORDER_CREATED.topic0, - ); - assert_eq!( - CoWSwapOnchainOrders::OrderPlacement::SIGNATURE, - ORDER_PLACEMENT.signature, - ); - assert_eq!( - CoWSwapOnchainOrders::OrderPlacement::SIGNATURE_HASH, - ORDER_PLACEMENT.topic0, - ); - } - - #[test] - fn wit_package_of_record_pins_every_surface() { - let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); - let flat: String = wit - .lines() - .map(|l| l.trim().trim_start_matches("/// ")) - .collect(); - for abi in ALL { - assert!( - flat.contains(abi.signature), - "cow-events.wit must pin the signature {}", - abi.signature, - ); - assert!( - flat.contains(&format!("{:#x}", abi.topic0)), - "cow-events.wit must pin the topic-0 {:#x}", - abi.topic0, - ); - } - } - - /// Layering gate: no generic WIT package references `shepherd:cow`. - #[test] - fn generic_wit_packages_never_reference_shepherd_cow() { - let wit_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); - for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { - let pkg = pkg.expect("wit dir entry").path(); - if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { - continue; - } - for file in std::fs::read_dir(&pkg).expect("wit package dir") { - let path = file.expect("wit package entry").path(); - let text = std::fs::read_to_string(&path).expect("read wit file"); - assert!( - !text.contains("shepherd:cow"), - "{} references shepherd:cow", - path.display(), - ); - } - } - } -} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs deleted file mode 100644 index 8909134c..00000000 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! CoW Protocol bridging. -//! -//! ABI decoding helpers, the orderbook error surface, and [`run()`] - -//! the poll/submit composition over the keeper stores, submitting -//! through the typed [`CowClient`] on the `videre:venue/client` seam. -//! The chain-edge order projections live in the `cow-venue` `assembly` -//! slice (the venue adapter owns them) and are re-exported here. -//! -//! The poll seam is the structured -//! [`Verdict`](composable_cow::Verdict), carried by the -//! `composable-cow` keeper crate together with the quarantined revert -//! decoding; only orderbook concerns live here. -//! -//! The codec submodules stay purely host-neutral: helpers take -//! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can -//! be unit-tested without wit-bindgen scaffolding and re-used -//! unchanged by TWAP, EthFlow, and future strategy modules. The -//! keeper run is generic over the host traits and the venue transport -//! alone; [`CowApiTransport`] carries it over the legacy -//! `shepherd:cow/cow-api` import until the module worlds flip. - -pub mod error; -pub mod events; -pub mod run; -pub mod transport; - -/// Chain-edge order assembly, re-exported from the `cow-venue` -/// `assembly` slice the venue adapter owns. -pub use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; -pub use error::{ - CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, - classify_submit_error, is_already_submitted, -}; -pub use run::run; -pub use transport::CowApiTransport; - -/// The venue-neutral intent body types and their borsh `IntentBody` -/// codec, re-exported from the `cow-venue` default slice, plus the -/// typed CoW venue client. The shim keeps this path stable while the -/// module ports move off the legacy surface. -pub use cow_venue::{ - BuyToken, BuyTokenDestination, CowClient, CowIntent, CowIntentBody, CowVenue, OrderBody, - OrderBuilder, OrderKind, OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, -}; - -use nexum_sdk::host::Host; - -/// `shepherd:cow/cow-api` - the legacy orderbook submission path, -/// retiring. The keeper [`run()`] submits through the typed -/// [`CowClient`]; [`CowApiTransport`] bridges it onto this seam until -/// the module worlds flip to `videre:venue/client`. -pub trait CowApiHost { - /// Submit an `OrderCreation` JSON body. The host returns the - /// canonical order UID on success. A rejection surfaces as a typed - /// [`CowApiError::Rejected`]; classify it with - /// [`classify_api_error`]. - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result; - - /// REST-style request against the CoW Protocol orderbook for the - /// given chain. The host routes to the correct base URL - /// (`https://api.cow.fi//api/v1/...`). Returns the raw - /// response body. Strategies that need a typed surface should - /// wrap this in an SDK helper. - /// - /// `method` is `"GET" | "POST" | "PUT" | "DELETE"`. - /// `path` is the absolute orderbook path beginning with `/api/v1`. - /// `body` is an optional JSON request body (only used for POST/PUT). - /// - /// A non-2xx reply surfaces as [`CowApiError::Http`]; callers - /// distinguish "orderbook does not know this resource" from a - /// genuine upstream failure by matching `http.status == 404`. - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result; -} - -/// Host bound for strategies that reach the CoW Protocol orderbook. -pub trait CowHost: Host + CowApiHost {} -impl CowHost for T {} diff --git a/crates/shepherd-sdk/src/cow/transport.rs b/crates/shepherd-sdk/src/cow/transport.rs deleted file mode 100644 index ece99f70..00000000 --- a/crates/shepherd-sdk/src/cow/transport.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Transitional venue transport over the legacy `shepherd:cow/cow-api` -//! seam. -//! -//! [`CowApiTransport`] implements the videre [`VenueTransport`] -//! contract by assembling the orderbook `OrderCreation` from the -//! decoded [`CowIntentBody`] and driving -//! [`CowApiHost::submit_order`], so the keeper [`run`](super::run()) -//! submits through the typed [`CowClient`](super::CowClient) while -//! module worlds still import the legacy host extension. Deleted when -//! the worlds flip to `videre:venue/client`. - -use alloy_primitives::{Address, hex}; -use cow_venue::assembly; -use cow_venue::body::{CowIntent, CowIntentBody}; -use cowprotocol::Chain; -use nexum_sdk::host::Fault; -use nexum_sdk::keeper::RetryAction; -use videre_sdk::client::sealed::SealedTransport; -use videre_sdk::{ - IntentBody as _, IntentStatus, Quotation, SubmitOutcome, VenueFault, VenueId, VenueTransport, -}; - -use super::{CowApiError, CowApiHost, classify_api_error, is_already_submitted}; - -/// The `videre:venue/client` verbs carried over the legacy -/// `shepherd:cow/cow-api` import: submit only, pre-bound to one chain's -/// orderbook. Quote, status, and cancel have no legacy submission-path -/// counterpart and refuse as `unsupported`. -pub struct CowApiTransport<'h, H> { - host: &'h H, - chain_id: u64, -} - -impl<'h, H: CowApiHost> CowApiTransport<'h, H> { - /// Bind the legacy seam to one chain's orderbook. - #[must_use] - pub const fn new(host: &'h H, chain_id: u64) -> Self { - Self { host, chain_id } - } -} - -impl SealedTransport for CowApiTransport<'_, H> {} - -impl VenueTransport for CowApiTransport<'_, H> { - async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { - Err(VenueFault::Unsupported) - } - - async fn submit(&self, _venue: &VenueId, body: Vec) -> Result { - let CowIntentBody::V1(intent) = - CowIntentBody::from_bytes(&body).map_err(|e| VenueFault::InvalidBody(e.to_string()))?; - // The legacy seam posts EIP-1271 only; the pre-sign flow needs - // the adapter. - let CowIntent::Signed(signed) = intent else { - return Err(VenueFault::Unsupported); - }; - let order = assembly::body_to_order_data(&signed.order); - let owner = Address::from(signed.owner); - let creation = assembly::build_order_creation(&order, &signed.signature, owner) - .map_err(|e| VenueFault::InvalidBody(e.to_string()))?; - let json = serde_json::to_vec(&creation) - .map_err(|e| VenueFault::Unavailable(format!("order encode failed: {e}")))?; - match self.host.submit_order(self.chain_id, &json) { - Ok(uid) => Ok(SubmitOutcome::Accepted(receipt_bytes(&uid))), - // Already-held is success wearing an error status; the - // receipt is the client-derived UID (empty on a chain the - // SDK cannot derive for). - Err(CowApiError::Rejected(r)) if is_already_submitted(&r) => { - let receipt = Chain::try_from(self.chain_id) - .map(|chain| { - assembly::order_uid(chain, &order, owner) - .as_slice() - .to_vec() - }) - .unwrap_or_default(); - Ok(SubmitOutcome::Accepted(receipt)) - } - Err(err) => Err(venue_fault(&err)), - } - } - - async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { - Err(VenueFault::Unsupported) - } - - async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { - Err(VenueFault::Unsupported) - } -} - -/// The server UID at its wire spelling; a non-hex receipt rides through -/// as raw bytes rather than failing an accepted submit. -fn receipt_bytes(uid: &str) -> Vec { - hex::decode(uid).unwrap_or_else(|_| uid.as_bytes().to_vec()) -} - -/// Project a legacy submission failure onto the venue fault the typed -/// client reports, mirroring the adapter: throttles keep their hint, -/// host and server failures stay retryable, and only a structured -/// rejection, folded through the shipped classification table, carries -/// a permanent venue verdict. -fn venue_fault(err: &CowApiError) -> VenueFault { - match err { - CowApiError::Fault(Fault::RateLimited(limit)) => VenueFault::RateLimited { - retry_after_ms: limit.retry_after_ms, - }, - CowApiError::Fault(Fault::Timeout) => VenueFault::Timeout, - // Any other host fault is infrastructure, not a venue verdict: - // it stays retryable so an unprovisioned capability or unknown - // chain never drops a still-valid order. - CowApiError::Fault(fault) => VenueFault::Unavailable(fault.to_string()), - CowApiError::Http(http) if http.status == 429 => VenueFault::RateLimited { - retry_after_ms: None, - }, - CowApiError::Http(http) => { - VenueFault::Unavailable(format!("orderbook http {}", http.status)) - } - CowApiError::Rejected(rejection) => { - let detail = format!("{}: {}", rejection.error_type, rejection.description); - match classify_api_error(rejection) { - RetryAction::TryNextBlock => VenueFault::Unavailable(detail), - RetryAction::Backoff { seconds } => VenueFault::RateLimited { - retry_after_ms: Some(seconds.saturating_mul(1000)), - }, - _ => VenueFault::Denied(detail), - } - } - } -} - -#[cfg(test)] -mod tests { - use nexum_sdk::host::RateLimit; - use videre_sdk::keeper::retry_action; - - use super::super::{HttpFailure, OrderRejection}; - use super::*; - - fn rejected(error_type: &str) -> CowApiError { - CowApiError::Rejected(OrderRejection { - status: 400, - error_type: error_type.into(), - description: "d".into(), - data: None, - }) - } - - #[test] - fn legacy_failures_project_onto_the_venue_fault_by_shape() { - assert!(matches!( - venue_fault(&rejected("InsufficientFee")), - VenueFault::Unavailable(detail) if detail.contains("InsufficientFee") - )); - assert!(matches!( - venue_fault(&rejected("TooManyLimitOrders")), - VenueFault::RateLimited { - retry_after_ms: Some(30_000) - } - )); - assert!(matches!( - venue_fault(&rejected("InvalidSignature")), - VenueFault::Denied(detail) if detail.contains("InvalidSignature") - )); - assert!(matches!( - venue_fault(&CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(2_500), - }))), - VenueFault::RateLimited { - retry_after_ms: Some(2_500) - } - )); - assert!(matches!( - venue_fault(&CowApiError::Fault(Fault::Timeout)), - VenueFault::Timeout - )); - assert!(matches!( - venue_fault(&CowApiError::Http(HttpFailure { - status: 429, - body: None, - })), - VenueFault::RateLimited { - retry_after_ms: None - } - )); - assert!(matches!( - venue_fault(&CowApiError::Http(HttpFailure { - status: 502, - body: None, - })), - VenueFault::Unavailable(_) - )); - } - - #[test] - fn host_faults_stay_retryable_and_never_drop_the_watch() { - for fault in [ - Fault::Unsupported("cow-api not provisioned".into()), - Fault::Denied("allowlist".into()), - Fault::Unavailable("rpc down".into()), - Fault::Internal("host bug".into()), - Fault::InvalidInput("mangled".into()), - ] { - let projected = venue_fault(&CowApiError::Fault(fault)); - assert!(matches!(projected, VenueFault::Unavailable(_))); - assert_eq!(retry_action(&projected), RetryAction::TryNextBlock); - } - assert_eq!( - retry_action(&venue_fault(&CowApiError::Fault(Fault::Timeout))), - RetryAction::TryNextBlock - ); - assert_eq!( - retry_action(&venue_fault(&CowApiError::Fault(Fault::RateLimited( - RateLimit { - retry_after_ms: Some(2_500), - } - )))), - RetryAction::Backoff { seconds: 3 } - ); - } - - #[test] - fn server_uid_decodes_to_wire_bytes_with_a_raw_fallback() { - assert_eq!(receipt_bytes("0xc0ffee"), vec![0xC0, 0xFF, 0xEE]); - assert_eq!(receipt_bytes("not-hex"), b"not-hex".to_vec()); - } -} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs deleted file mode 100644 index 2201ad35..00000000 --- a/crates/shepherd-sdk/src/lib.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! # shepherd-sdk -//! -//! CoW-domain SDK for Shepherd modules, layered on the generic -//! [`nexum_sdk`]. Everything host-neutral (the host trait seam, config -//! and address parsing, `eth_call` plumbing, HTTP, the tracing facade) -//! lives in `nexum-sdk`; this crate carries only the CoW Protocol -//! surface. Modules import both crates directly. -//! -//! ## What lives here -//! -//! - [`prelude`] - `use shepherd_sdk::prelude::*` imports cowprotocol's -//! order / signing surface ([`OrderCreation`], -//! [`OrderData`], [`OrderUid`], [`OrderKind`], [`Signature`], -//! [`Chain`], [`GPv2OrderData`], [`EMPTY_APP_DATA_JSON`]). -//! -//! - [`cow`] - [`run`], the poll -> outcome -> gate/journal/submit -//! composition over the keeper stores, dispatching the structured -//! [`Verdict`] from the `composable-cow` keeper crate and submitting -//! through the typed [`CowClient`] on the `videre:venue/client` -//! seam; `GPv2OrderData` -> `OrderData` bridging -//! ([`gpv2_to_order_data`]) and the classifiers mapping submit -//! failures into the keeper [`RetryAction`]. The legacy -//! [`CowApiHost`] trait for `shepherd:cow/cow-api` (and the -//! [`CowHost`] bound over the core [`Host`]) stays for the read -//! paths and the transitional [`CowApiTransport`] bridge. -//! -//! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - -//! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: -//! the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. -//! -//! ## Why no `wit_bindgen::generate!` here -//! -//! The macro emits types into the calling crate (the module's -//! cdylib). Re-exporting wit-bindgen output from a library crate -//! would duplicate symbols and break the component-export contract. -//! Helpers in this SDK therefore take primitive types (`&[u8]`, -//! `Option<&str>`, slices) rather than the per-module `Fault` -//! type; modules unpack their `Fault` on the way in. Trade-off -//! documented in ADR-0006 / ADR-0007 - the SDK stays on the guest -//! side, neutral to which world the module exports. -//! -//! [`OrderCreation`]: cowprotocol::OrderCreation -//! [`OrderData`]: cowprotocol::OrderData -//! [`OrderUid`]: cowprotocol::OrderUid -//! [`OrderKind`]: cowprotocol::OrderKind -//! [`Signature`]: cowprotocol::Signature -//! [`Chain`]: cowprotocol::Chain -//! [`GPv2OrderData`]: cowprotocol::GPv2OrderData -//! [`EMPTY_APP_DATA_JSON`]: cowprotocol::EMPTY_APP_DATA_JSON -//! [`CowApiHost`]: cow::CowApiHost -//! [`CowHost`]: cow::CowHost -//! [`CowClient`]: cow::CowClient -//! [`CowApiTransport`]: cow::CowApiTransport -//! [`Host`]: nexum_sdk::host::Host -//! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`Verdict`]: composable_cow::Verdict -//! [`RetryAction`]: cow::RetryAction -//! [`run`]: cow::run() - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] -#![cfg_attr(docsrs, feature(doc_cfg))] - -pub mod cow; -pub mod prelude; -pub mod wit_bindgen_macro; - -#[cfg(test)] -mod proptests; - -#[cfg(test)] -mod tests { - //! Locks the prelude's surface - the build itself proves the - //! re-exports compile against both `wasm32-wasip2` and the - //! host target. - - use crate::prelude::*; - - #[test] - fn prelude_re_exports_resolve() { - let _kind: OrderKind = OrderKind::Sell; - let _chain: Chain = Chain::Sepolia; - assert_eq!(EMPTY_APP_DATA_JSON, "{}"); - } -} diff --git a/crates/shepherd-sdk/src/prelude.rs b/crates/shepherd-sdk/src/prelude.rs deleted file mode 100644 index 6adbc23b..00000000 --- a/crates/shepherd-sdk/src/prelude.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Bulk-imports the CoW Protocol primitives every Shepherd module uses -//! on every other line. `use shepherd_sdk::prelude::*` covers -//! cowprotocol's order and signing surface; the alloy address / hash / -//! numeric types come from `nexum_sdk::prelude::*` alongside it. -//! -//! The wit-bindgen-generated types (`Guest`, `Fault`, `Event`, …) -//! are **not** re-exported here because they live in each module's own -//! crate (one `wit_bindgen::generate!` call per cdylib). The prelude -//! covers only the host-neutral protocol layer that the SDK helpers -//! consume by value. - -pub use cowprotocol::{ - BuyTokenDestination, - // App-data + chain + domain identity. - Chain, - DomainSeparator, - EMPTY_APP_DATA_HASH, - EMPTY_APP_DATA_JSON, - // Settlement primitives carried in event payloads and order bodies. - GPv2OrderData, - // Orderbook submission body + the parts every assembly path touches. - OrderCreation, - OrderData, - OrderKind, - // Order identity. - OrderUid, - SellTokenSource, - // Signing. - Signature, - SigningScheme, -}; diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs deleted file mode 100644 index c300c65f..00000000 --- a/crates/shepherd-sdk/src/proptests.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Property-based regression tests for the CoW-domain codec surface. -//! Lives behind `#[cfg(test)]` so neither the wasm32-wasip2 builds nor -//! downstream consumers pay the proptest dep cost. -//! -//! Covered here: -//! -//! - `gpv2_to_order_data` marker mapping (no-panic guard). -//! -//! The generic properties (`eth_call` round-trip, `scale_decimal`) -//! live in `nexum-sdk`; the revert-decode guard lives in -//! `composable-cow`. - -#![cfg(test)] - -use proptest::prelude::*; - -proptest! { - /// `gpv2_to_order_data` is exhaustive over the marker enum; - /// fuzzing the inputs as raw u8 (not the typed enum) is the only - /// way to exercise the fallback path. Strategy: feed any 4 marker - /// bytes (kind + sellTokenSource + buyTokenDestination + - /// partiallyFillable) and assert either `Some` (recognised) or - /// `None` (unknown marker), never a panic. - #[test] - fn gpv2_marker_dispatch_never_panics( - kind in any::(), - sell in any::(), - buy in any::(), - fillable in any::(), - ) { - let _ = (kind, sell, buy, fillable); - // We do not call `gpv2_to_order_data` here because building - // a `GPv2OrderData` requires a full alloy-sol-encoded struct - // and the generators for that are extensive. The property - // test for the marker dispatch lives in `cow_venue::assembly` - // example-based; this proptest stands in as a no-panic - // guard for the inputs the strategy ABI can produce. - } -} diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs deleted file mode 100644 index ff4b7e5d..00000000 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Declarative macro that generates the `WitBindgenHost` adapter for -//! CoW modules: the generic adapter plus the `CowApiHost` impl. -//! -//! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the -//! core adapter (`WitBindgenHost`, the six core host trait impls, the -//! fault and level `From` impls, and the tracing wiring). This macro -//! invokes it and adds the [`CowApiHost`](crate::cow::CowApiHost) -//! impl over the `shepherd:cow/cow-api` import shims. -//! -//! The macro assumes the module compiles against `shepherd:cow/shepherd` -//! with `wit_bindgen::generate!({ ..., generate_all })`, so both the -//! `nexum::host::*` and `shepherd::cow::cow_api` output paths are in -//! scope at the call site, and that the module crate also depends on -//! `nexum-sdk` (the expansion names `::nexum_sdk` directly). -//! -//! Usage in a module's `lib.rs`: -//! -//! ```ignore -//! wit_bindgen::generate!({ /* ... */ }); -//! shepherd_sdk::bind_cow_host_via_wit_bindgen!(); -//! // Everything the generic macro emits is in scope, plus the -//! // `CowApiHost` impl for `WitBindgenHost`. -//! ``` - -/// Generate the generic `WitBindgenHost` adapter plus the `CowApiHost` -/// impl. See module docs. -#[macro_export] -macro_rules! bind_cow_host_via_wit_bindgen { - () => { - ::nexum_sdk::bind_host_via_wit_bindgen!(); - - /// Lift the per-cdylib wit-bindgen `cow-api-error` into the - /// SDK's [`CowApiError`]( - /// $crate::cow::CowApiError), projecting each case onto the - /// host-neutral mirror. - fn convert_cow_err(e: shepherd::cow::cow_api::CowApiError) -> $crate::cow::CowApiError { - match e { - shepherd::cow::cow_api::CowApiError::Fault(f) => { - $crate::cow::CowApiError::Fault(::core::convert::Into::into(f)) - } - shepherd::cow::cow_api::CowApiError::Http(h) => { - $crate::cow::CowApiError::Http($crate::cow::HttpFailure { - status: h.status, - body: h.body, - }) - } - shepherd::cow::cow_api::CowApiError::Rejected(r) => { - $crate::cow::CowApiError::Rejected($crate::cow::OrderRejection { - status: r.status, - error_type: r.error_type, - description: r.description, - data: r.data, - }) - } - } - } - - impl $crate::cow::CowApiHost for WitBindgenHost { - fn submit_order( - &self, - chain_id: u64, - body: &[u8], - ) -> ::core::result::Result<::std::string::String, $crate::cow::CowApiError> { - shepherd::cow::cow_api::submit_order(chain_id, body).map_err(convert_cow_err) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: ::core::option::Option<&str>, - ) -> ::core::result::Result<::std::string::String, $crate::cow::CowApiError> { - shepherd::cow::cow_api::request(chain_id, method, path, body) - .map_err(convert_cow_err) - } - } - }; -} diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml index 2b5ed588..1de15410 100644 --- a/crates/shepherd/Cargo.toml +++ b/crates/shepherd/Cargo.toml @@ -15,7 +15,6 @@ path = "src/main.rs" [dependencies] nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } -shepherd-cow-host = { path = "../shepherd-cow-host" } videre-host = { path = "../videre-host" } anyhow.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index f7c629cd..b35ad0ee 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -1,7 +1,8 @@ -//! The `shepherd` binary: the cow composition root. Binds the reference -//! lattice with the cow-api extension payload in the `Ext` slot, registers -//! the videre venue platform, and hands it all to the generic launcher; -//! the engine itself stays venue- and cow-free. +//! The `shepherd` binary: the cow composition root. Boots the +//! reference backends, registers the videre venue platform, and hands +//! it all to the generic launcher; CoW enters only as the bundled +//! `cow-venue` adapter component, and the engine itself stays venue- +//! and cow-free. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -10,56 +11,35 @@ use std::sync::Arc; use nexum_runtime::addons::{AddOns, PrometheusAddOn}; use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::{ - ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, + ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, }; use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use nexum_runtime::preset::Runtime; -use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; +use nexum_runtime::preset::{CoreRuntime, Runtime}; -/// The reference lattice: the core backends with the cow-api payload in -/// the `Ext` slot. -#[derive(Debug, Clone, Copy, Default)] -struct ReferenceTypes; - -impl nexum_runtime::sealed::SealedRuntimeTypes for ReferenceTypes {} - -impl RuntimeTypes for ReferenceTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -/// The cow preset: reference backends, the videre venue platform, the -/// cow-api extension, and the Prometheus add-on. +/// The cow preset: the reference core backends with the videre venue +/// platform and the Prometheus add-on. #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; impl nexum_runtime::sealed::SealedRuntime for ShepherdRuntime {} impl Runtime for ShepherdRuntime { - type Types = ReferenceTypes; + type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; type StoreBuilder = LocalStoreBuilder; - type ExtBuilder = ReferenceExtBuilder; + type ExtBuilder = (); type LogsBuilder = LogPipelineBuilder; - fn components( - self, - ) -> ComponentsBuilder { - ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ReferenceExtBuilder) + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } fn add_ons(&self) -> AddOns { vec![Box::new(PrometheusAddOn)] } - fn extensions(&self, config: &EngineConfig) -> Vec>> { - vec![ - Arc::new(videre_host::platform(config)), - extension::(), - ] + fn extensions(&self, config: &EngineConfig) -> Vec>> { + vec![Arc::new(videre_host::platform(config))] } } diff --git a/docs/adr/0005-cow-api-via-cached-orderbookapi.md b/docs/adr/0005-cow-api-via-cached-orderbookapi.md index 37493689..6586e26a 100644 --- a/docs/adr/0005-cow-api-via-cached-orderbookapi.md +++ b/docs/adr/0005-cow-api-via-cached-orderbookapi.md @@ -1,10 +1,16 @@ --- -status: proposed +status: superseded implemented-in: nullislabs/shepherd#8 --- # `cow-api` host backend routes both `request` and `submit-order` through `cowprotocol::OrderBookApi` +> **Superseded by the videre venue-adapter architecture.** The +> `shepherd:cow/cow-api` host extension and its `OrderBookApi` backend +> are retired: orderbook submission and status ride the `cow-venue` +> adapter component over `wasi:http`, driven through the +> `videre:venue/client` pool seam. + ## Context `shepherd:cow/cow-api` exposes two operations: a generic REST passthrough (`request`) and a typed order submission (`submit-order`). Either could be implemented with raw `reqwest` against `api.cow.fi/{slug}/api/v1`, but the published `cowprotocol` crate already ships an `OrderBookApi` client that knows the chain-specific base URL, the canonical paths, and the `post_order` codec. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index a76a9faa..43600c13 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -1,9 +1,15 @@ --- -status: proposed +status: superseded --- # TWAP and EthFlow run as guest modules using low-level host primitives (no specialised `shepherd:cow` interfaces) +> **Superseded by the videre venue-adapter architecture.** The +> strategies-as-guest-modules line holds, but the protocol seam it +> assigned to `shepherd:cow/cow-api` is retired: modules submit typed +> intent bodies through the `videre:venue/client` pool seam and the +> `cow-venue` adapter owns the orderbook edge. + ## Context TWAP (over ComposableCoW) and EthFlow are the two CoW workflows the M2 grant ships modules for. The natural-seeming approach is to add `shepherd:cow/twap` and `shepherd:cow/ethflow` WIT interfaces that the host implements on top of `cowprotocol` crate primitives, so modules would call `twap.poll-and-submit(...)` and `ethflow.submit-from-log(...)` as host functions. This ADR rejects that direction. diff --git a/engine.load.toml b/engine.load.toml index 44924b49..4f8072b2 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -7,8 +7,8 @@ # # Differences vs engine.e2e.toml: # - chain points at the local Anvil fork on ws://localhost:8545 -# - [extensions.cow.orderbook_urls] points at tools/orderbook-mock -# (no live cow.fi) +# - the cow adapter's load-variant manifest points the orderbook at +# tools/orderbook-mock (no live cow.fi) # - state_dir is per-run (./data/load) so successive runs do not # inherit local-store rows from each other # - log level is debug for the supervisor-dispatch surface so the @@ -33,18 +33,13 @@ bind_addr = "127.0.0.1:9100" [chains.11155111] rpc_url = "ws://localhost:8545" -# Point the cow-api extension at tools/orderbook-mock. -[extensions.cow.orderbook_urls] -11155111 = "http://localhost:9999" - [[modules]] path = "./target/wasm32-wasip2/release/twap_monitor.wasm" manifest = "./modules/twap-monitor/module.toml" # The cow venue adapter twap-monitor submits through (`just # build-cow-venue`). Load-variant manifest: Sepolia chain id with the -# orderbook re-pointed at tools/orderbook-mock, matching -# [extensions.cow.orderbook_urls] above. +# orderbook re-pointed at tools/orderbook-mock. [[adapters]] path = "./target/wasm32-wasip2/release/cow_venue.wasm" manifest = "./crates/cow-venue/module.load.toml" diff --git a/engine.m3.toml b/engine.m3.toml index d5fb7e69..fc21fefe 100644 --- a/engine.m3.toml +++ b/engine.m3.toml @@ -3,7 +3,7 @@ # Boots the 3 M3 example modules (price-alert + balance-tracker + # stop-loss) against Sepolia. The 3 modules exercise the full SDK # helper surface (chain::request via Chainlink read, local-store -# diffing, cow-api submit with PreSign). +# diffing, pool submit through the cow adapter with PreSign). # # Usage: # just run-m3 @@ -11,7 +11,8 @@ # cargo build -p price-alert --target wasm32-wasip2 --release # cargo build -p balance-tracker --target wasm32-wasip2 --release # cargo build -p stop-loss --target wasm32-wasip2 --release -# cargo run -p nexum-cli -- --engine-config engine.m3.toml +# cargo build -p cow-venue --features adapter --target wasm32-wasip2 --release +# cargo run -p shepherd -- --engine-config engine.m3.toml [engine] # Separate from data/m2 and the M1 example state. @@ -34,3 +35,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter stop-loss submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain the oracle is read on. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/extensions.toml b/extensions.toml index 6a280f02..e836e923 100644 --- a/extensions.toml +++ b/extensions.toml @@ -7,7 +7,3 @@ [extensions.client] import = "videre:venue/client@0.1.0" packages = ["videre-value-flow", "videre-types", "videre-venue"] - -[extensions.cow-api] -import = "shepherd:cow/cow-api@0.1.0" -packages = ["shepherd-cow"] diff --git a/justfile b/justfile index 1d22c5c2..019fb0a6 100644 --- a/justfile +++ b/justfile @@ -57,7 +57,7 @@ build-m3: # (Sepolia, 3 example modules). See `docs/operations/m3-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m3: build-m3 build-engine +run-m3: build-m3 build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs # Build the http-probe example module (wasi:http fetch + allowlist diff --git a/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml index 0dab8781..82b3c35a 100644 --- a/modules/examples/stop-loss/Cargo.toml +++ b/modules/examples/stop-loss/Cargo.toml @@ -4,22 +4,23 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Shepherd example module: stop-loss order submitter. Watches a Chainlink oracle, submits a pre-signed CoW order when price drops below a configured trigger, dedups via submitted:{uid}." +description = "Shepherd example module: stop-loss order submitter. Watches a Chainlink oracle, submits a CoW order intent through the pool when price drops below a configured trigger, dedups via the venue-and-body intent-id." [lib] crate-type = ["cdylib"] [dependencies] +cow-venue = { path = "../../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../../crates/shepherd-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../../crates/shepherd-sdk-test" } +# The chain-edge projections back the pinned-UID regression test. +cow-venue = { path = "../../../crates/cow-venue", features = ["client", "assembly"] } nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } # Only used by tests in `strategy.rs` to encode a synthetic oracle # return body; the production code uses `nexum_sdk::chain::chainlink`. diff --git a/modules/examples/stop-loss/module.toml b/modules/examples/stop-loss/module.toml index 58065270..4bf0d667 100644 --- a/modules/examples/stop-loss/module.toml +++ b/modules/examples/stop-loss/module.toml @@ -1,7 +1,7 @@ # stop-loss example module: watches a Chainlink oracle and submits a -# CoW order when the price drops below the configured trigger. -# Demonstrates eth_call + OrderCreation + cow-api submit + local-store -# dedup, the full M3 SDK surface. +# CoW order intent through the pool when the price drops below the +# configured trigger. Demonstrates eth_call + the typed venue client + +# local-store dedup. [module] name = "stop-loss" @@ -9,10 +9,16 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -required = ["logging", "chain", "local-store", "cow-api"] +# - logging -> structured runtime logs +# - chain -> eth_call into the Chainlink aggregator +# - local-store -> submitted: / dropped: dedup markers +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "chain", "local-store", "client"] optional = [] [capabilities.http] +# All outbound HTTP is the cow adapter's; the module makes no direct +# `http` calls. allow = [] # --- subscriptions ---------------------------------------------------- @@ -21,6 +27,11 @@ allow = [] kind = "block" chain_id = 11155111 # Sepolia +# The one body-schema version this module encodes; install refuses the +# module unless every installed venue adapter decodes it. +[venue] +body_version = 1 + # --- config ----------------------------------------------------------- [config] @@ -35,14 +46,14 @@ decimals = "8" # on the first block. trigger_price = "2000.00" # Order parameters. The owner pre-signs via GPv2Signing.setPreSignature -# (on-chain, outside this module); the module submits the body with -# Signature::PreSign on trigger. +# (on-chain, outside this module); the cow adapter posts the unsigned +# body pre-sign on trigger. # # E2E run pinning: test EOA on Sepolia with 0.05 ETH # balance. Without a pre-sign + a WETH wrap the orderbook will reject -# with TransferSimulationFailed which the SDK classifies as -# TryNextBlock — that itself is a valid terminal marker (`backoff:` -# write to local-store) and proves the full submit path E2E. +# with TransferSimulationFailed, which classifies as retry-next-block; +# that itself is a valid terminal marker and proves the full submit +# path E2E. owner = "0x7bF140727D27ea64b607E042f1225680B40ECa6A" # WETH9 Sepolia (`wss://sepolia.etherscan.io/token/0xfff9976782d46cc05630d1f6ebab18b2324d6b14`). sell_token = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index 5e203753..81d23e04 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -1,51 +1,43 @@ //! # stop-loss (example Shepherd module) //! //! Watches a Chainlink price oracle on every block. When the price -//! drops at or below `trigger_price`, the module submits a pre-signed -//! CoW order using the parameters from `module.toml::[config]` and -//! persists `submitted:{uid}` to dedup re-poll attempts. The owner is -//! expected to have called `GPv2Signing.setPreSignature` on-chain -//! ahead of the trigger so the orderbook accepts the submission. +//! drops at or below `trigger_price`, the module submits a CoW order +//! intent through the pool using the parameters from +//! `module.toml::[config]` and persists a `submitted:` marker to dedup +//! re-poll attempts. The cow adapter posts the unsigned order +//! pre-sign; the owner is expected to call +//! `GPv2Signing.setPreSignature` on-chain ahead of the trigger so the +//! orderbook activates the submission. //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. -//! -//! Same recipe as `price-alert` - the wit-bindgen adapter -//! is intentionally mechanical and is a candidate for a future -//! declarative macro in the SDK. - +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. + +// wit_bindgen::generate! expands to host-import shims whose arity +// matches the WIT signatures, which can exceed clippy's +// too-many-arguments threshold. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - "../../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; -use nexum::host::types; - -// `WitBindgenHost` and the fault and level `From` impls are generated -// below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); +use cow_venue::CowClient; static SETTINGS: OnceLock = OnceLock::new(); struct StopLoss; -impl Guest for StopLoss { +#[videre_sdk::keeper] +impl StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config)?; @@ -60,15 +52,11 @@ impl Guest for StopLoss { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: nexum::host::types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; - } + strategy::on_block(&WitBindgenHost, &CowClient::new(), block.chain_id, cfg)?; Ok(()) } } - -export!(StopLoss); diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index 8c97f74f..7891f40d 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -1,20 +1,19 @@ //! Pure stop-loss strategy logic. Reads an oracle, optionally submits -//! a pre-signed CoW order, dedups via local-store. Every interaction -//! with the world flows through the [`CowHost`] trait so the tests can -//! drive it against `shepherd_sdk_test::MockHost`. +//! a CoW order intent through the typed venue client, dedups via +//! local-store. Every interaction with the world flows through the +//! `nexum_sdk::host` trait seams and the videre [`VenueTransport`] +//! under the typed [`CowClient`], so tests drive it against +//! `nexum_sdk_test::MockHost` and a scripted transport. use alloy_primitives::I256; +use cow_venue::{BuyToken, CowClient, CowIntent, CowIntentBody, OrderBody, SellToken, intent_id}; use nexum_sdk::chain::chainlink::read_latest_answer; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::Fault; -use nexum_sdk::prelude::{Address, Bytes, U256}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, RetryAction, classify_api_error, gpv2_to_order_data, is_already_submitted, -}; -use shepherd_sdk::prelude::{ - BuyTokenDestination, Chain, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, OrderKind, - OrderUid, SellTokenSource, Signature, -}; +use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost, LoggingHost}; +use nexum_sdk::keeper::RetryAction; +use nexum_sdk::prelude::{Address, U256, hex}; +use videre_sdk::keeper::retry_action; +use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; /// Resolved configuration parsed from `module.toml::[config]`. #[derive(Clone, Debug)] @@ -23,7 +22,8 @@ pub struct Settings { pub oracle_address: Address, /// Trigger price scaled to the oracle's native units. pub trigger_price_scaled: I256, - /// Order owner (= EIP-712 signer / PreSign caller). + /// Order owner (= the `setPreSignature` caller and buy-token + /// receiver). pub owner: Address, /// Sell side of the order. pub sell_token: Address, @@ -40,10 +40,19 @@ pub struct Settings { /// React to a new block. /// /// Returns `Ok(())` on success and on recoverable upstream failures -/// (oracle RPC error, decode failure). Only host-store errors bubble -/// up via `?` so the supervisor can surface persistence issues - all -/// other faults log and let the next block re-poll. -pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result<(), Fault> { +/// (oracle RPC error, decode failure, venue refusal). Only host-store +/// errors bubble up via `?` so the supervisor can surface persistence +/// issues - all other faults log and let the next block re-poll. +pub fn on_block( + host: &H, + venue: &CowClient, + chain_id: u64, + settings: &Settings, +) -> Result<(), Fault> +where + H: ChainHost + LoggingHost + LocalStoreHost, + T: VenueTransport, +{ let price = match read_latest_answer(host, chain_id, settings.oracle_address, "stop-loss") { Some(p) => p, None => return Ok(()), // logged inside read_latest_answer @@ -58,140 +67,98 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res return Ok(()); } - // Compute UID up-front so we can dedup before paying for the - // serialise + submit round trip. - let (creation, uid) = match build_creation(chain_id, settings) { - Ok(x) => x, + // Derive the venue-and-body intent-id up-front so the dedup guard + // runs before any network work. + let intent = build_intent(settings); + let id = match intent_id(&intent) { + Ok(id) => id, Err(e) => { - tracing::warn!(error = %e, "stop-loss skipped (build)"); + tracing::error!(error = %e, "intent body encode failed"); return Ok(()); } }; - let uid_hex = format!("{uid}"); - let dedup_key = format!("submitted:{uid_hex}"); + let dedup_key = format!("submitted:{id}"); if host.get(&dedup_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss already submitted, idle"); + tracing::info!(intent = %id, "stop-loss already submitted, idle"); return Ok(()); } - let dropped_key = format!("dropped:{uid_hex}"); + let dropped_key = format!("dropped:{id}"); if host.get(&dropped_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss previously dropped, idle"); + tracing::info!(intent = %id, "stop-loss previously dropped, idle"); return Ok(()); } - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!(error = %e, "OrderCreation JSON encode failed"); - return Ok(()); - } + let Some(outcome) = rt::complete(venue.submit(&intent)) else { + // Guest transports never suspend; retry on the next block. + tracing::error!("stop-loss submit future suspended; retrying next block"); + return Ok(()); }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - if server_uid != uid_hex { - tracing::warn!( - local = %uid_hex, - server = %server_uid, - "stop-loss uid drift", - ); - } - host.set(&format!("submitted:{server_uid}"), b"")?; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { + host.set(&dedup_key, b"")?; tracing::warn!( price = %price, trigger = %settings.trigger_price_scaled, - uid = %server_uid, + receipt = %hex::encode_prefixed(&receipt), "stop-loss TRIGGERED", ); } - Err(err) => { - // Success wearing an error status: the orderbook already - // holds this exact order. Record the receipt under the - // key the dedup guard reads, so the next block idles - // instead of re-posting until `validTo`. - if let CowApiError::Rejected(rejection) = &err - && is_already_submitted(rejection) - { - host.set(&dedup_key, b"")?; - tracing::info!( - uid = %uid_hex, - "stop-loss already on the orderbook; receipt recorded", - ); - return Ok(()); + Ok(SubmitOutcome::RequiresSigning(_)) => { + // The orderbook holds the order as signature-pending; the + // owner activates it with the on-chain `setPreSignature` + // call made ahead of the trigger. Journalled so the next + // block idles instead of re-posting. + host.set(&dedup_key, b"")?; + tracing::warn!( + price = %price, + trigger = %settings.trigger_price_scaled, + "stop-loss TRIGGERED (pre-sign pending on-chain activation)", + ); + } + Err(ClientError::Body(e)) => { + tracing::error!(error = %e, "intent body encode failed"); + } + Err(ClientError::Venue(fault)) => match retry_action(&fault) { + RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { + tracing::warn!(error = %fault, "stop-loss retry on next block"); } - // Only a typed orderbook rejection classifies; transport - // faults and raw HTTP errors are transient (retry next - // block) rather than a terminal drop. - let action = match &err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { - tracing::warn!(error = %err, "stop-loss retry on next block"); - } - RetryAction::Drop => { - host.set(&dropped_key, b"")?; - tracing::warn!(uid = %uid_hex, error = %err, "stop-loss dropped"); - } - // `RetryAction` is `#[non_exhaustive]`; treat unknown - // future variants like `TryNextBlock` rather than - // silently dropping the watch on an SDK bump. - _ => { - tracing::warn!( - error = %err, - "stop-loss unknown retry-action - retry on next block", - ); - } + RetryAction::Drop => { + host.set(&dropped_key, b"")?; + tracing::warn!(intent = %id, error = %fault, "stop-loss dropped"); } - } + // `RetryAction` is `#[non_exhaustive]`; treat unknown + // future variants like `TryNextBlock` rather than + // silently dropping the order on an SDK bump. + _ => { + tracing::warn!( + error = %fault, + "stop-loss unknown retry-action - retry on next block", + ); + } + }, + // `ClientError` is non-exhaustive; retry on the next block. + Err(e) => tracing::error!(error = %e, "stop-loss submit failed"), } Ok(()) } -// `read_oracle` moved into `nexum_sdk::chain::chainlink::read_latest_answer` -// (review consolidation): the same flow + `Option` return shape now serves -// price-alert + stop-loss from the SDK, with `domain: &str` carrying the -// module label into the Warn log. - -/// Assemble the `OrderCreation` body + canonical UID from settings. -/// Uses `Signature::PreSign` so the module ships zero ECDSA - the -/// owner is expected to have called `GPv2Signing.setPreSignature` -/// on-chain ahead of the trigger. -fn build_creation(chain_id: u64, settings: &Settings) -> Result<(OrderCreation, OrderUid), Fault> { - let chain = Chain::try_from(chain_id).map_err(|_| { - Fault::Unsupported(format!("chain {chain_id} not supported by cowprotocol")) - })?; - let domain = chain.settlement_domain(); - let gpv2 = GPv2OrderData { - sellToken: settings.sell_token, - buyToken: settings.buy_token, - receiver: settings.owner, - sellAmount: settings.sell_amount, - buyAmount: settings.buy_amount, - validTo: settings.valid_to, - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - }; - let order_data = gpv2_to_order_data(&gpv2).ok_or_else(|| { - Fault::InvalidInput("GPv2OrderData carried an unknown enum marker".into()) - })?; - let uid = order_data.uid(&domain, settings.owner); - let creation = OrderCreation::new( - &order_data, - Signature::PreSign, - settings.owner, - EMPTY_APP_DATA_JSON.to_string(), - None, +/// Assemble the order intent from settings: an unsigned order the cow +/// adapter posts pre-sign. The owner receives the buy token and the +/// app-data hash pins the canonical empty document. +fn build_intent(settings: &Settings) -> CowIntentBody { + let order = OrderBody::sell( + SellToken(settings.sell_token.into_array()), + settings.sell_amount.to_be_bytes(), ) - .map_err(|e| Fault::InvalidInput(format!("cowprotocol rejected the body: {e}")))?; - // Silence the unused `Bytes` import on builds where `Signature:: - // PreSign` is the only signature variant we construct. - let _: Option = None; - Ok((creation, uid)) + .for_at_least( + BuyToken(settings.buy_token.into_array()), + settings.buy_amount.to_be_bytes(), + ) + .valid_to(settings.valid_to) + .receiver(settings.owner.into_array()) + .app_data(cowprotocol::EMPTY_APP_DATA_HASH.0) + .build(); + CowIntentBody::V1(CowIntent::Order(order)) } /// Parse `module.toml::[config]` into a typed [`Settings`]. @@ -266,19 +233,78 @@ fn config_err(e: ConfigError) -> Fault { #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use alloy_primitives::hex; use alloy_sol_types::SolCall; use nexum_sdk::Level; use nexum_sdk::chain::chainlink::AggregatorV3; use nexum_sdk::chain::eth_call_params; - use nexum_sdk::host::{ChainError, Fault}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::OrderRejection; - use shepherd_sdk_test::MockHost; + use nexum_sdk::host::ChainError; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{IntentStatus, Quotation, UnsignedTx, VenueFault, VenueId}; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// Scripted venue transport: one submit outcome per queued entry, + /// every submit recorded. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } + } + + impl SealedTransport for &MockVenue {} + + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + fn client(venue: &MockVenue) -> CowClient<&MockVenue> { + CowClient::with_transport(venue) + } + fn settings_below(trigger_scaled: i128) -> Settings { Settings { oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306" @@ -320,12 +346,11 @@ mod tests { host.chain.respond_to("eth_call", ¶ms, response); } - fn programmed_uid(settings: &Settings) -> String { - let (_creation, uid) = build_creation(SEPOLIA, settings).unwrap(); - format!("{uid}") + fn programmed_id(settings: &Settings) -> String { + intent_id(&build_intent(settings)).unwrap() } - /// Regression test pinning the OrderUid produced by the + /// Regression test pinning the orderbook UID derived from the /// E2E run's `modules/examples/stop-loss/module.toml` config so an /// operator can `setPreSignature(uid, true)` ahead of the run /// without re-deriving the UID from the EIP-712 / domain- @@ -353,8 +378,17 @@ mod tests { buy_amount: U256::from(20_000_000_000_000_000_000_u128), valid_to: u32::MAX, }; + let CowIntentBody::V1(CowIntent::Order(body)) = build_intent(&settings) else { + panic!("stop-loss emits an unsigned order intent"); + }; + let order = cow_venue::assembly::body_to_order_data(&body); + let uid = cow_venue::assembly::order_uid( + cowprotocol::Chain::try_from(SEPOLIA).unwrap(), + &order, + settings.owner, + ); assert_eq!( - programmed_uid(&settings), + format!("{uid}"), "0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff", ); } @@ -362,6 +396,7 @@ mod tests { #[test] fn idle_when_price_above_trigger() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(/*trigger*/ 250_000_000_000); program_oracle( &host, @@ -369,9 +404,9 @@ mod tests { Ok(oracle_response_json(300_000_000_000)), ); - on_block(&host, SEPOLIA, &s).unwrap(); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert_eq!(host.store.len(), 0); assert_eq!( host.chain.call_count(), @@ -383,27 +418,28 @@ mod tests { #[test] fn triggers_and_submits_once_then_dedups() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, s.oracle_address, Ok(oracle_response_json(200_000_000_000)), ); - let uid = programmed_uid(&s); - host.cow_api.respond(Ok(uid.clone())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(vec![0xAA; 56]))); // First block: submits. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); + let id = programmed_id(&s); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{uid}")) + .contains_key(&format!("submitted:{id}")) ); // Second block at the same price: dedup'd, no new submit. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); assert_eq!( host.chain.call_count(), 2, @@ -411,52 +447,43 @@ mod tests { ); } + /// The adapter posts the unsigned order pre-sign and asks for the + /// on-chain activation: the intent is journalled so the next block + /// idles instead of re-posting. #[test] - fn permanent_submit_error_marks_dropped() { + fn requires_signing_outcome_records_the_marker_and_idles() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, s.oracle_address, Ok(oracle_response_json(200_000_000_000)), ); + venue.enqueue_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: SEPOLIA, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0x22], + }))); + + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - // Orderbook returns InvalidSignature - permanent per the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); - - on_block(&host, SEPOLIA, &s).unwrap(); - let uid = programmed_uid(&s); + let id = programmed_id(&s); assert!( host.store .snapshot() - .contains_key(&format!("dropped:{uid}")) - ); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("submitted:{uid}")) + .contains_key(&format!("submitted:{id}")) ); - // Second block: dropped marker idles the loop. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); // no resubmit + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); } - /// A duplicate rejection is success wearing an error status: the - /// orderbook already holds the order (e.g. the local marker was - /// lost), so the receipt must be recorded and the next block must - /// idle instead of re-posting until `validTo`. #[test] - fn duplicate_rejection_records_receipt_and_idles() { + fn permanent_submit_error_marks_dropped() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -464,39 +491,29 @@ mod tests { Ok(oracle_response_json(200_000_000_000)), ); - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "DuplicatedOrder".into(), - description: "order already exists".into(), - data: None, - }))); - - on_block(&host, SEPOLIA, &s).unwrap(); + // A structured permanent refusal - `Denied` classifies as + // `Drop` in the videre retry table. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); - let uid = programmed_uid(&s); - assert!( - host.store - .snapshot() - .contains_key(&format!("submitted:{uid}")), - "the receipt must land under the key the dedup guard reads", - ); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + let id = programmed_id(&s); + assert!(host.store.snapshot().contains_key(&format!("dropped:{id}"))); assert!( !host .store .snapshot() - .contains_key(&format!("dropped:{uid}")), - "already-submitted must never mark the order dropped", + .contains_key(&format!("submitted:{id}")) ); - // Second block: the receipt idles the loop, no re-POST. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + // Second block: dropped marker idles the loop. + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); // no resubmit } #[test] fn transient_submit_error_leaves_state_unchanged() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -504,26 +521,21 @@ mod tests { Ok(oracle_response_json(200_000_000_000)), ); - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); - let (result, logs) = capture_tracing(|| on_block(&host, SEPOLIA, &s)); + let (result, logs) = capture_tracing(|| on_block(&host, &client(&venue), SEPOLIA, &s)); result.unwrap(); // No persistence flag - next block will retry. assert_eq!(host.store.len(), 0); - assert_eq!(host.cow_api.call_count(), 1, "the submit was attempted"); + assert_eq!(venue.submit_count(), 1, "the submit was attempted"); logs.expect_one(|e| e.level == Level::WARN && e.message.contains("retry on next block")); } #[test] fn oracle_rpc_error_is_warn_and_continue() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -531,9 +543,9 @@ mod tests { Err(ChainError::Fault(Fault::Timeout)), ); - on_block(&host, SEPOLIA, &s).unwrap(); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert_eq!(host.store.len(), 0); assert!(host.logging.contains("oracle eth_call failed")); } diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index acacdadb..26dc396e 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -9,10 +9,9 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -composable-cow = { path = "../../crates/composable-cow" } +composable-cow = { path = "../../crates/composable-cow", features = ["sweep"] } cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } videre-sdk = { path = "../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 15affe60..8629c90a 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -14,11 +14,11 @@ //! keeper watch set, and the `getTradeableOrderWithSignature` poll //! behind [`ConditionalSource`]. Gate discipline, the `submitted:` //! journal, submission through the pool, and retry dispatch live in -//! the shared composition (`shepherd_sdk::cow::run`). +//! the shared composition (`composable_cow::run`). use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; -use composable_cow::{LegacyRevertAdapter, Verdict}; +use composable_cow::{LegacyRevertAdapter, Verdict, run}; use cow_venue::CowClient; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, @@ -27,7 +27,6 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{events, run}; use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. @@ -93,10 +92,10 @@ where // ---- indexing path ---- -/// Topic-0 resolves from the `shepherd:cow/cow-events` package of -/// record before the ABI decode. +/// Topic-0 gates before the ABI decode; the pin is parity-tested +/// against the `shepherd:cow/cow-events` package of record. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { - if log.topics().first() != Some(&events::CONDITIONAL_ORDER_CREATED.topic0) { + if log.topics().first() != Some(&ConditionalOrderCreated::SIGNATURE_HASH) { return None; } let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; @@ -932,25 +931,25 @@ mod tests { /// `shepherd:cow/cow-events` package of record. A typo or ABI /// drift would silently miss every registration event. #[test] - fn topic0_matches_conditional_order_created_canonical_signature() { - assert_eq!( - ConditionalOrderCreated::SIGNATURE_HASH, - events::CONDITIONAL_ORDER_CREATED.topic0, - "sol! topic-0 must match the shepherd:cow/cow-events pin", + fn topic0_matches_the_cow_events_package_of_record() { + let wit = include_str!("../../../wit/shepherd-cow/cow-events.wit"); + let expected = format!("{:#x}", ConditionalOrderCreated::SIGNATURE_HASH); + assert!( + wit.contains(&expected), + "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", ); } - /// Stronger guard than the constant check above: read the shipped - /// `module.toml` and assert its pinned `event_signature` actually - /// equals the package-of-record topic-0 - catches a manifest/code - /// drift the decoder assertion cannot see. + /// Read the shipped `module.toml` and assert its pinned + /// `event_signature` equals the decoder topic-0 - catches a + /// manifest/code drift the wit assertion cannot see. #[test] fn manifest_topic0_matches_conditional_order_created_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("{:#x}", events::CONDITIONAL_ORDER_CREATED.topic0); + let expected = format!("{:#x}", ConditionalOrderCreated::SIGNATURE_HASH); assert!( manifest.contains(&expected), - "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", + "module.toml event_signature must equal the decoder topic-0 ({expected})", ); } } diff --git a/scripts/e2e-report-gen.sh b/scripts/e2e-report-gen.sh index 219a1a98..29f156bf 100755 --- a/scripts/e2e-report-gen.sh +++ b/scripts/e2e-report-gen.sh @@ -155,7 +155,9 @@ shepherd_keys = [ "shepherd_module_restarts_total", "shepherd_module_poisoned", "shepherd_chain_request_total", - "shepherd_cow_api_submit_total", + "shepherd_adapter_errors_total", + "shepherd_adapter_restarts_total", + "shepherd_adapter_poisoned", "shepherd_stream_reconnects_total", ] diff --git a/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs index 0ad9d4aa..9e7b6b0e 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/tools/orderbook-mock/src/main.rs @@ -1,7 +1,7 @@ //! Mock CoW orderbook for shepherd load tests. //! -//! Serves the one endpoint shepherd's `cow-api` host backend hits on -//! every order submission: +//! Serves the one endpoint the cow venue adapter hits on every order +//! submission: //! //! - `POST /api/v1/orders` - accepts any body, returns a synthetic //! 56-byte OrderUid as a JSON-encoded hex string. Counts a request @@ -147,7 +147,7 @@ async fn post_orders(State(state): State>, body: String) -> impl I state.counters.submits_err.fetch_add(1, Ordering::Relaxed); // Alternate transient + permanent so the load test exercises // both `TryNextBlock` and `Drop` paths through - // `shepherd_sdk::cow::classify_api_error`. + // `cow_venue::classification::classify`. let n = state.counters.submits_err.load(Ordering::Relaxed); let api = if n.is_multiple_of(2) { ApiError { diff --git a/wit/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit deleted file mode 100644 index 0dbaa940..00000000 --- a/wit/shepherd-cow/cow-api.wit +++ /dev/null @@ -1,62 +0,0 @@ -package shepherd:cow@0.1.0; - -/// Legacy host-extension surface: retiring. Submission moves to the -/// generic videre pool seam; deleted at the fork-gated poll wire-swap. -interface cow-api { - use nexum:host/types@0.1.0.{chain-id, fault}; - - /// A non-2xx reply from the orderbook that carries no typed - /// rejection envelope. `body` is the raw response text, foreign - /// orderbook JSON kept as a string deliberately: the host does not - /// parse it, so a caller matches on `status` (e.g. 404 for a - /// resource the orderbook has not indexed yet) and reads `body` - /// only for diagnostics. - record http-failure { - status: u16, - body: option, - } - - /// A typed orderbook rejection of a submitted order. Parsed once, - /// host-side, from the orderbook's `{errorType, description, data}` - /// envelope so the guest dispatches on `error-type` without a - /// second JSON decode. `data` is the envelope's optional structured - /// payload (e.g. a minimum-fee quote), re-encoded as a JSON string. - record order-rejection { - status: u16, - error-type: string, - description: string, - data: option, - } - - /// A cow-api call failure: 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). - /// - /// 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 an order to the CoW Protocol. - /// - /// `order-data`: the serialised order payload. - /// Returns the order UID on success. - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} diff --git a/wit/shepherd-cow/cow-ext.wit b/wit/shepherd-cow/cow-ext.wit deleted file mode 100644 index 2a81b5f7..00000000 --- a/wit/shepherd-cow/cow-ext.wit +++ /dev/null @@ -1,9 +0,0 @@ -package shepherd:cow@0.1.0; - -/// Extension world: the cow-api interface alone, wired into a module -/// linker by the cow extension. Kept separate from `shepherd` so the -/// extension contributes only its own import, never the core interfaces. -/// Retiring with `cow-api`. -world cow-ext { - import cow-api; -} diff --git a/wit/shepherd-cow/shepherd.wit b/wit/shepherd-cow/shepherd.wit deleted file mode 100644 index 729bcd0f..00000000 --- a/wit/shepherd-cow/shepherd.wit +++ /dev/null @@ -1,7 +0,0 @@ -package shepherd:cow@0.1.0; - -/// Shepherd module — event-driven Nexum module with CoW Protocol extensions. -world shepherd { - include nexum:host/event-module@0.1.0; - import cow-api; -} From b2c6c4da6345641e213ca7b0bb517901528caa5c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 18 Jul 2026 00:33:26 +0000 Subject: [PATCH 50/53] twap: index the v2 ConditionalOrderRemoved event and drop the watch Subscribe the twap-monitor to the ComposableCoW v2 ConditionalOrderRemoved topic-0 and pin it in the shepherd:cow/cow-events package of record. The created and removed subscription streams merge in arrival order, not chain order, so each watch row carries the create log's (block, log-index) stamp and a removal drops the watch (with its gates) only when it postdates the stamp: a stale removal for a re-registered order is ignored, and an unprovable ordering keeps the watch for the poll path's self-healing drop. --- modules/twap-monitor/module.toml | 12 + modules/twap-monitor/src/lib.rs | 7 +- modules/twap-monitor/src/strategy.rs | 406 +++++++++++++++++++++++---- wit/shepherd-cow/cow-events.wit | 4 + 4 files changed, 377 insertions(+), 52 deletions(-) diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index d7f45ca2..0b8e0b3d 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -36,6 +36,18 @@ chain_id = 11155111 address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" +# ComposableCoW v2 ConditionalOrderRemoved emissions. topic-0 = +# keccak256("ConditionalOrderRemoved(address,bytes32)"), pinned in +# wit/shepherd-cow/cow-events.wit and parity-tested in strategy.rs. +# This stream and the created stream merge in arrival order, not +# chain order, so a removal drops the watch (with its gates) only +# when it postdates the watch's indexed create. +[[subscription]] +kind = "chain-log" +chain_id = 11155111 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x67e0f2b23e842ce65d7edff49765689c9c0931f911fc5971d09bb598cc1af4a9" + # New-block ticks drive the TWAP poll loop (`getTradeableOrderWithSignature`). [[subscription]] kind = "block" diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 562a86d8..561f72dc 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -1,8 +1,9 @@ //! # twap-monitor (Shepherd keeper module) //! -//! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to the -//! CoW venue through the pool as they go live. +//! Indexes `ComposableCoW.ConditionalOrderCreated` and v2 +//! `ConditionalOrderRemoved` logs and polls each watched conditional +//! order on every block, submitting tranches to the CoW venue through +//! the pool as they go live. //! //! ## Module layout //! diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 8629c90a..f4dca14a 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -16,17 +16,19 @@ //! journal, submission through the pool, and retry dispatch live in //! the shared composition (`composable_cow::run`). -use alloy_primitives::{Address, Bytes, keccak256}; +use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use composable_cow::{LegacyRevertAdapter, Verdict, run}; use cow_venue::CowClient; use cowprotocol::{ - COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, + COMPOSABLE_COW, + ComposableCoW::{ConditionalOrderCreated, ConditionalOrderRemoved}, + ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; -use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet, watch_key}; use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. @@ -62,12 +64,19 @@ mod abi { } } -/// Indexer entry: decode every `ComposableCoW.ConditionalOrderCreated` -/// chain-log in a dispatch batch and persist its watch. +/// Indexer entry: decode every ComposableCoW registration and removal +/// chain-log in a dispatch batch. A `ConditionalOrderCreated` persists +/// its watch stamped with the log's chain position; a v2 +/// `ConditionalOrderRemoved` drops the watch (with its gates) only +/// when it postdates that stamp. The two subscription streams merge in +/// arrival order, not chain order, so the stamp is what keeps a stale +/// removal from dropping a re-registered watch. pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { for log in logs { if let Some((owner, params)) = decode_conditional_order_created(log) { - persist_watch(host, owner, ¶ms)?; + persist_watch(host, owner, ¶ms, LogPosition::of(log))?; + } else if let Some((owner, hash)) = decode_conditional_order_removed(log) { + remove_watch(host, owner, &hash, LogPosition::of(log))?; } } Ok(()) @@ -92,6 +101,60 @@ where // ---- indexing path ---- +/// Chain position of a mined log, ordered as the chain orders logs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct LogPosition { + block: u64, + index: u64, +} + +impl LogPosition { + /// `None` for a pending log. + fn of(log: &Log) -> Option { + Some(Self { + block: log.block_number?, + index: log.log_index?, + }) + } +} + +/// Header tag + `(block, log-index)` little-endian words. +const ROW_HEADER_LEN: usize = 17; + +/// Watch row payload: a position header ahead of the ABI-encoded +/// `ConditionalOrderParams`. The header pins where the indexed +/// `ConditionalOrderCreated` sits on chain. +fn encode_row(indexed_at: Option, params: &[u8]) -> Vec { + let mut row = Vec::with_capacity(ROW_HEADER_LEN + params.len()); + match indexed_at { + Some(at) => { + row.push(1); + row.extend_from_slice(&at.block.to_le_bytes()); + row.extend_from_slice(&at.index.to_le_bytes()); + } + None => row.extend_from_slice(&[0; ROW_HEADER_LEN]), + } + row.extend_from_slice(params); + row +} + +/// Split a watch row into its position stamp and params bytes; `None` +/// on a malformed row. +fn decode_row(row: &[u8]) -> Option<(Option, &[u8])> { + let (header, params) = row.split_first_chunk::()?; + let [tag, position @ ..] = header; + let (block, index) = position.split_first_chunk::<8>()?; + let indexed_at = match tag { + 0 => None, + 1 => Some(LogPosition { + block: u64::from_le_bytes(*block), + index: u64::from_le_bytes(index.try_into().ok()?), + }), + _ => return None, + }; + Some((indexed_at, params)) +} + /// Topic-0 gates before the ABI decode; the pin is parity-tested /// against the `shepherd:cow/cow-events` package of record. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { @@ -102,32 +165,89 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr Some((decoded.data.owner, decoded.data.params)) } -/// The watch set overwrites in place, so re-indexing the same log -/// (re-org replay, overlapping subscription windows) produces no -/// observable side effect. +/// The watch set overwrites in place and the stamp keeps the latest +/// indexed position, so re-indexing (re-org replay, overlapping +/// subscription windows, cursor rewind) never ages the row. fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, + indexed_at: Option, ) -> Result<(), Fault> { let encoded = params.abi_encode(); - let key = WatchSet::new(host).put(&owner, &keccak256(&encoded), &encoded)?; + let hash = keccak256(&encoded); + let watches = WatchSet::new(host); + let prior = WatchRef::parse(&watch_key(&owner, &hash)) + .map(|watch| watches.get(watch)) + .transpose()? + .flatten() + .and_then(|row| decode_row(&row).and_then(|(at, _)| at)); + let row = encode_row(prior.max(indexed_at), &encoded); + let key = watches.put(&owner, &hash, &row)?; tracing::info!("indexed {key}"); Ok(()) } +/// Topic-0 gates before the ABI decode; the pin is parity-tested +/// against the `shepherd:cow/cow-events` package of record. +fn decode_conditional_order_removed(log: &Log) -> Option<(Address, B256)> { + if log.topics().first() != Some(&ConditionalOrderRemoved::SIGNATURE_HASH) { + return None; + } + let decoded = ConditionalOrderRemoved::decode_log(&log.inner).ok()?; + Some((decoded.data.owner, decoded.data.singleOrderHash)) +} + +/// `singleOrderHash` is `keccak256(abi.encode(params))`, exactly the +/// hash the watch key carries. The removal lands only when it provably +/// postdates the watch's stamp: `remove(hash)` + `create(same params)` +/// in one call re-registers the same hash, and the earlier remove can +/// arrive after the later create, so an unprovable ordering keeps the +/// watch (a wrongly-kept watch self-heals through the poll's drop +/// path; a wrongly-dropped one is never polled again). A removal for +/// an order this keeper never indexed (or already dropped) replays +/// cleanly as a no-op. +fn remove_watch( + host: &H, + owner: Address, + hash: &B256, + removed_at: Option, +) -> Result<(), Fault> { + let key = watch_key(&owner, hash); + let Some(watch) = WatchRef::parse(&key) else { + return Ok(()); + }; + let watches = WatchSet::new(host); + let Some(row) = watches.get(watch)? else { + return Ok(()); + }; + let indexed_at = decode_row(&row).and_then(|(at, _)| at); + match (indexed_at, removed_at) { + (Some(indexed_at), Some(removed_at)) if indexed_at < removed_at => { + watches.remove(watch)?; + tracing::info!("removed {key}"); + } + _ => tracing::info!("kept {key}: removal does not postdate its create"), + } + Ok(()) +} + // ---- poll path ---- -/// TWAP conditional source: decode the stored `ConditionalOrderParams` -/// and evaluate `getTradeableOrderWithSignature` on chain. A row this -/// source cannot decode polls again next block rather than tearing -/// down the sweep. +/// TWAP conditional source: decode the stored row's +/// `ConditionalOrderParams` and evaluate +/// `getTradeableOrderWithSignature` on chain. A row this source cannot +/// decode polls again next block rather than tearing down the sweep. struct TwapSource; impl ConditionalSource for TwapSource { type Outcome = Verdict; fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { + let Some((_, params)) = decode_row(params) else { + tracing::warn!("watch {} carried an unparseable row; skipping", watch.key()); + return Verdict::TryNextBlock { reason: [0; 4] }; + }; let Ok(params) = ConditionalOrderParams::abi_decode(params) else { tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); return Verdict::TryNextBlock { reason: [0; 4] }; @@ -234,9 +354,6 @@ fn outcome_label(o: &Verdict) -> &'static str { // Thin views over the keeper / venue canon so the dispatch tests can // seed and inspect the store in the exact shapes production writes. -#[cfg(test)] -use nexum_sdk::keeper::watch_key; - #[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { let watch = WatchRef::parse(key)?; @@ -405,7 +522,7 @@ mod tests { fn decodes_well_formed_log() { let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - let log = make_log(owner, ¶ms); + let log = make_log(owner, ¶ms, at(1, 0)); let (decoded_owner, decoded_params) = decode_conditional_order_created(&log).expect("decode succeeds"); @@ -470,26 +587,48 @@ mod tests { // ---- MockHost + MockVenue dispatch tests ---- - /// Build the alloy log the indexer expects from a well-formed - /// `ConditionalOrderCreated`, assembled through the same WIT-edge - /// path the bind macro uses at runtime. - fn make_log(owner: Address, params: &ConditionalOrderParams) -> Log { + /// Chain position shorthand for the log builders. + fn at(block: u64, index: u64) -> LogPosition { + LogPosition { block, index } + } + + /// Assemble a mined ComposableCoW log at `position` through the + /// same WIT-edge path the bind macro uses at runtime. + fn make_event_log(owner: Address, topic0: B256, data: &[u8], position: LogPosition) -> Log { let mut owner_topic = vec![0u8; 12]; owner_topic.extend_from_slice(owner.as_slice()); - let topics = vec![ - ConditionalOrderCreated::SIGNATURE_HASH.to_vec(), - owner_topic, - ]; - let data = params.abi_encode(); + let topics = vec![topic0.to_vec(), owner_topic]; nexum_sdk::events::ChainLogParts { address: COMPOSABLE_COW.as_slice(), topics: &topics, - data: &data, + data, + block_number: Some(position.block), + log_index: Some(position.index), ..Default::default() } .into() } + /// A well-formed `ConditionalOrderCreated` mined at `position`. + fn make_log(owner: Address, params: &ConditionalOrderParams, position: LogPosition) -> Log { + make_event_log( + owner, + ConditionalOrderCreated::SIGNATURE_HASH, + ¶ms.abi_encode(), + position, + ) + } + + /// A well-formed v2 `ConditionalOrderRemoved` mined at `position`. + fn make_removed_log(owner: Address, hash: B256, position: LogPosition) -> Log { + make_event_log( + owner, + ConditionalOrderRemoved::SIGNATURE_HASH, + &hash.abi_encode(), + position, + ) + } + /// Build the `params_json` `poll_one` passes to `host.request`. fn programmed_eth_call_params(owner: Address, params: &ConditionalOrderParams) -> String { let call = abi::getTradeableOrderWithSignatureCall { @@ -513,11 +652,13 @@ mod tests { } /// Pre-seed a `watch:` row identical to what the indexer would - /// write. + /// write for a create mined at block 1, index 0. fn seed_watch(host: &MockHost, owner: Address, params: &ConditionalOrderParams) -> String { let encoded = params.abi_encode(); let key = watch_key(&owner, &keccak256(&encoded)); - host.store.set(&key, &encoded).unwrap(); + host.store + .set(&key, &encode_row(Some(at(1, 0)), &encoded)) + .unwrap(); key } @@ -534,13 +675,17 @@ mod tests { let host = MockHost::new(); let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - let log = make_log(owner, ¶ms); + let log = make_log(owner, ¶ms, at(42, 9)); on_chain_logs(&host, &[log]).unwrap(); let expected_key = watch_key(&owner, &keccak256(params.abi_encode())); assert_eq!(host.store.len(), 1); - assert!(host.store.snapshot().contains_key(&expected_key)); + let store = host.store.snapshot(); + let row = store.get(&expected_key).expect("watch row present"); + let (indexed_at, stored) = decode_row(row).expect("row decodes"); + assert_eq!(indexed_at, Some(at(42, 9)), "row carries the log position"); + assert_eq!(stored, params.abi_encode(), "row carries the params"); } #[test] @@ -552,13 +697,168 @@ mod tests { let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - on_chain_logs(&host, &[make_log(owner, ¶ms)]).unwrap(); + on_chain_logs(&host, &[make_log(owner, ¶ms, at(42, 9))]).unwrap(); // Re-deliver the same log. - on_chain_logs(&host, &[make_log(owner, ¶ms)]).unwrap(); + on_chain_logs(&host, &[make_log(owner, ¶ms, at(42, 9))]).unwrap(); assert_eq!(host.store.len(), 1, "redelivery must not duplicate watches"); } + #[test] + fn decodes_well_formed_removed_log() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = b256!("0303030303030303030303030303030303030303030303030303030303030303"); + let log = make_removed_log(owner, hash, at(1, 0)); + + let (decoded_owner, decoded_hash) = + decode_conditional_order_removed(&log).expect("decode succeeds"); + assert_eq!(decoded_owner, owner); + assert_eq!(decoded_hash, hash); + // The two decoders never cross-match: topic-0 keeps them apart. + assert!(decode_conditional_order_created(&log).is_none()); + assert!( + decode_conditional_order_removed(&make_log(owner, &sample_params(), at(1, 0))) + .is_none() + ); + } + + #[test] + fn watch_row_codec_round_trips() { + for indexed_at in [None, Some(at(3, 7))] { + let row = encode_row(indexed_at, b"payload"); + let (decoded_at, params) = decode_row(&row).expect("row decodes"); + assert_eq!(decoded_at, indexed_at); + assert_eq!(params, b"payload"); + } + assert!(decode_row(&[]).is_none(), "short row is malformed"); + let mut bad_tag = encode_row(None, b"payload"); + bad_tag[0] = 2; + assert!(decode_row(&bad_tag).is_none(), "unknown tag is malformed"); + } + + #[test] + fn removal_drops_watch_and_gates_and_spares_the_rest() { + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let key = seed_watch(&host, owner, ¶ms); + let (owner_hex, hash_hex) = parse_watch_key(&key).unwrap(); + host.store + .set( + &format!("next_block:{owner_hex}:{hash_hex}"), + &500u64.to_le_bytes(), + ) + .unwrap(); + host.store + .set( + &format!("next_epoch:{owner_hex}:{hash_hex}"), + &1_700_000_000u64.to_le_bytes(), + ) + .unwrap(); + // A sibling watch under a different hash must survive. + let mut other = sample_params(); + other.salt = b256!("0202020202020202020202020202020202020202020202020202020202020202"); + let other_key = seed_watch(&host, owner, &other); + + let hash = keccak256(params.abi_encode()); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(2, 0))]).unwrap(); + + let store = host.store.snapshot(); + assert!(!store.contains_key(&key), "removal must drop the watch"); + assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); + assert!(!store.contains_key(&format!("next_epoch:{owner_hex}:{hash_hex}"))); + assert!(store.contains_key(&other_key), "sibling watch survives"); + } + + #[test] + fn removal_of_an_unindexed_watch_is_a_no_op() { + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = keccak256(sample_params().abi_encode()); + + on_chain_logs(&host, &[make_removed_log(owner, hash, at(2, 0))]).unwrap(); + + assert_eq!(host.store.len(), 0); + } + + #[test] + fn later_removal_in_its_own_dispatch_drops_the_watch() { + // The runtime dispatches each log singly; a create and its + // genuine removal always arrive as two separate calls. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(9, 0))]).unwrap(); + + assert_eq!(host.store.len(), 0, "a postdating removal lands"); + } + + #[test] + fn stale_removal_arriving_after_a_re_registered_create_is_ignored() { + // `remove(hash)` + `create(same params)` in one call + // re-registers the same hash at a later log index. The two + // subscription streams merge in arrival order, so the earlier + // remove can arrive after the later create, each as its own + // single-log dispatch. The live watch must survive. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 4))]).unwrap(); + + assert!( + host.store.snapshot().contains_key(&key), + "a stale removal must not drop the re-registered watch", + ); + } + + #[test] + fn redelivered_older_create_keeps_the_later_stamp() { + // A cursor rewind can redeliver an old create after a newer + // registration of the same params; the stamp must not age, or + // the stale removal between them would land. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_log(owner, ¶ms, at(2, 0))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 4))]).unwrap(); + + assert!( + host.store.snapshot().contains_key(&key), + "the stamp keeps the latest indexed position", + ); + } + + #[test] + fn removal_without_a_mined_position_keeps_the_watch() { + // A removal whose position cannot be proven later than the + // create is ignored; the poll path's drop verdict is the + // self-healing teardown for a truly removed order. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + let mut pending = make_removed_log(owner, hash, at(9, 0)); + pending.block_number = None; + pending.log_index = None; + on_chain_logs(&host, &[pending]).unwrap(); + + assert!(host.store.snapshot().contains_key(&key)); + } + #[test] fn poll_skips_when_next_block_gate_is_in_future() { let host = MockHost::new(); @@ -927,29 +1227,37 @@ mod tests { }); } - /// Guard: the `sol!` decoder's topic-0 matches the + /// Guard: the `sol!` decoders' topic-0s match the /// `shepherd:cow/cow-events` package of record. A typo or ABI - /// drift would silently miss every registration event. + /// drift would silently miss every registration or removal event. #[test] fn topic0_matches_the_cow_events_package_of_record() { let wit = include_str!("../../../wit/shepherd-cow/cow-events.wit"); - let expected = format!("{:#x}", ConditionalOrderCreated::SIGNATURE_HASH); - assert!( - wit.contains(&expected), - "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", - ); + for expected in [ + format!("{:#x}", ConditionalOrderCreated::SIGNATURE_HASH), + format!("{:#x}", ConditionalOrderRemoved::SIGNATURE_HASH), + ] { + assert!( + wit.contains(&expected), + "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", + ); + } } - /// Read the shipped `module.toml` and assert its pinned - /// `event_signature` equals the decoder topic-0 - catches a + /// Read the shipped `module.toml` and assert each pinned + /// `event_signature` equals its decoder topic-0 - catches a /// manifest/code drift the wit assertion cannot see. #[test] - fn manifest_topic0_matches_conditional_order_created_signature_hash() { + fn manifest_topic0_matches_the_decoder_signature_hashes() { let manifest = include_str!("../module.toml"); - let expected = format!("{:#x}", ConditionalOrderCreated::SIGNATURE_HASH); - assert!( - manifest.contains(&expected), - "module.toml event_signature must equal the decoder topic-0 ({expected})", - ); + for expected in [ + format!("{:#x}", ConditionalOrderCreated::SIGNATURE_HASH), + format!("{:#x}", ConditionalOrderRemoved::SIGNATURE_HASH), + ] { + assert!( + manifest.contains(&expected), + "module.toml event_signature must equal the decoder topic-0 ({expected})", + ); + } } } diff --git a/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit index b5f54bc1..3b5505c1 100644 --- a/wit/shepherd-cow/cow-events.wit +++ b/wit/shepherd-cow/cow-events.wit @@ -12,6 +12,10 @@ interface cow-events { /// signature: ConditionalOrderCreated(address,(address,bytes32,bytes)) /// topic0: 0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361 conditional-order-created, + /// ComposableCoW v2 single-order removal. + /// signature: ConditionalOrderRemoved(address,bytes32) + /// topic0: 0x67e0f2b23e842ce65d7edff49765689c9c0931f911fc5971d09bb598cc1af4a9 + conditional-order-removed, /// CoWSwapOnchainOrders (EthFlow) placement. /// signature: OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes) /// topic0: 0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9 From 428527b087f6a0d42744b202f269cdbce3d52713 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 18 Jul 2026 00:40:16 +0000 Subject: [PATCH 51/53] twap: grant one next-block retry before dropping on InvalidEip1271Signature A first-time user's Safe wiring and ComposableCoW create() land in one block, so the orderbook rejects the first submission against its own head and the keeper dropped a valid watch. The classification table gains a drop-on-repeat action, the retry ledger records the refused block and gates one next-block retry, and a denied refusal re-enters the table by its errorType prefix so the grace survives the coarse venue-error collapse. A repeat on a later block still drops. --- Cargo.lock | 1 + crates/composable-cow/src/sweep.rs | 23 ++- crates/composable-cow/tests/sweep.rs | 150 ++++++++++++++++++++ crates/cow-venue/build.rs | 1 + crates/cow-venue/data/classification.toml | 23 ++- crates/cow-venue/src/adapter.rs | 12 ++ crates/cow-venue/src/classification.rs | 47 +++++- crates/cow-venue/src/classification_data.rs | 5 +- crates/cow-venue/src/lib.rs | 2 +- crates/nexum-sdk/src/keeper.rs | 95 +++++++++---- crates/nexum-sdk/tests/keeper.rs | 122 +++++++++++++++- crates/videre-sdk/Cargo.toml | 2 + crates/videre-sdk/src/keeper.rs | 61 +++++++- 13 files changed, 490 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bc62a3d..9935776e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6001,6 +6001,7 @@ dependencies = [ "nexum-sdk-test", "strum", "thiserror 2.0.18", + "tracing", "videre-macros", "wit-bindgen 0.59.0", ] diff --git a/crates/composable-cow/src/sweep.rs b/crates/composable-cow/src/sweep.rs index d3fa5dd7..768d51c4 100644 --- a/crates/composable-cow/src/sweep.rs +++ b/crates/composable-cow/src/sweep.rs @@ -13,21 +13,24 @@ //! Store faults abort the sweep (the next tick replays it); //! submission failures never do - they fold into a //! [`RetryAction`] through the videre -//! [`retry_action`] table, the ledger applies the effect, and the +//! [`retry_action`] table, a `denied` refusal re-entering the CoW +//! classification through its errorType prefix +//! ([`classify_denied`]) so a one-shot row survives the coarse +//! collapse, the ledger applies the effect, and the //! sweep moves on. Diagnostics go through the guest `tracing` facade - //! the same channel strategy code logs on - so module tests observe //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes, hex}; use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; -use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, intent_id}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, classify_denied, intent_id}; use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, }; use videre_sdk::keeper::retry_action; -use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; +use videre_sdk::{ClientError, SubmitOutcome, VenueFault, VenueTransport, rt}; use crate::Verdict; @@ -141,6 +144,12 @@ where }; match outcome { Ok(SubmitOutcome::Accepted(receipt)) => { + // An acceptance ends any refusal episode: clear the + // first-refusal marker so a later independent refusal + // earns a fresh one-block grace. + if let Err(fault) = Retrier::new(host).clear_refusal(watch) { + tracing::error!("submitted {intent_id} but refusal-marker clear failed: {fault}"); + } // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the @@ -162,13 +171,17 @@ where tracing::error!("intent body encode failed: {err}"); } Err(ClientError::Venue(fault)) => { - let action = retry_action(&fault); - Retrier::new(host).apply(watch, action, tick.epoch_s)?; + let action = match &fault { + VenueFault::Denied(detail) => classify_denied(detail), + other => retry_action(other), + }; + Retrier::new(host).apply(watch, action, tick)?; match action { RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {fault}"), RetryAction::Backoff { seconds } => { tracing::warn!("submit backoff {seconds}s: {fault}"); } + RetryAction::DropOnRepeat => tracing::warn!("submit drop-on-repeat: {fault}"), RetryAction::Drop => tracing::warn!("submit dropped watch: {fault}"), // `RetryAction` is non-exhaustive; the ledger already // ran the effect, so the log needs only the name. diff --git a/crates/composable-cow/tests/sweep.rs b/crates/composable-cow/tests/sweep.rs index 7d400f4c..567a1384 100644 --- a/crates/composable-cow/tests/sweep.rs +++ b/crates/composable-cow/tests/sweep.rs @@ -512,6 +512,156 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } +/// The adapter's projection of the same-block wiring+create race: a +/// first-time user's Safe wiring and `create()` land in one block, so +/// the orderbook rejects the first submission against its own head. +fn eip1271_rejection() -> Result { + Err(VenueFault::Denied( + "InvalidEip1271Signature: signature for computed order hash 0x7ee5 is not valid".into(), + )) +} + +/// Same-block wiring+create race: the first rejection gates the watch +/// to the next block instead of dropping it, re-polls within the block +/// stay gated, and the retried submission one block later lands. +#[test] +fn first_eip1271_rejection_retries_on_the_next_block() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + let tick = sample_tick(); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &tick)); + result.unwrap(); + + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&key), + "first rejection keeps the watch" + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &(tick.block + 1).to_le_bytes().to_vec(), + "the watch gates to the next block", + ); + assert!(logs.any(|e| e.message.contains("drop-on-repeat"))); + + // Sub-block re-polls stay gated: the race is not hammered. + run(&host, &client(&venue), &source, &tick).unwrap(); + assert_eq!(venue.submit_count(), 1); + + // One block later the wiring is visible and the retry lands. + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + assert_eq!(venue.submit_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + +/// A rejection that repeats on a later block is a genuinely broken +/// signature: the watch and every derived key go. +#[test] +fn repeated_eip1271_rejection_on_a_later_block_drops_the_watch() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(eip1271_rejection()); + + let source = src(move |_, _, _, _| ready_outcome(&order)); + let tick = sample_tick(); + run(&host, &client(&venue), &source, &tick).unwrap(); + + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + + assert_eq!(venue.submit_count(), 2); + assert!( + host.store.is_empty(), + "a repeated rejection must drop the watch, its gates, and the marker", + ); +} + +/// An accepted submission ends the refusal episode: a later tranche's +/// own first rejection earns a fresh one-block grace instead of an +/// immediate drop on the stale marker from an earlier tranche. +#[test] +fn acceptance_resets_the_one_block_grace_for_later_tranches() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tranche_one = submittable_order(); + let mut tranche_two = submittable_order(); + tranche_two.buyAmount = U256::from(1_001_u64); + + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(accepted()); + venue.enqueue_submit(eip1271_rejection()); + + let tick = sample_tick(); + let boundary = tick.block + 5; + let source = src(move |_, _, _, t: &Tick| { + if t.block < boundary { + ready_outcome(&tranche_one) + } else { + ready_outcome(&tranche_two) + } + }); + + // Tranche one: refused at the tick block, accepted one block later. + run(&host, &client(&venue), &source, &tick).unwrap(); + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + assert_eq!(venue.submit_count(), 2); + assert!( + !host.store.snapshot().contains_key(&watch.refused_key()), + "acceptance must clear the first-refusal marker", + ); + + // Tranche two: its own first rejection at a later block keeps the + // watch and gates it to the next block. + let later = Tick { + block: boundary, + ..tick + }; + run(&host, &client(&venue), &source, &later).unwrap(); + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&key), + "a fresh refusal after an acceptance must keep the watch", + ); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &later.block.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &(later.block + 1).to_le_bytes().to_vec(), + ); +} + /// Restart regression: a keeper that posted, journalled, and then /// restarted over the same persistent local store must not post the /// same order again - one venue submit across both lives. diff --git a/crates/cow-venue/build.rs b/crates/cow-venue/build.rs index 10122c97..f619f788 100644 --- a/crates/cow-venue/build.rs +++ b/crates/cow-venue/build.rs @@ -28,6 +28,7 @@ fn main() { let action = match e.action { Action::TryNextBlock => "GenAction::TryNextBlock", Action::Backoff => "GenAction::Backoff", + Action::DropOnRepeat => "GenAction::DropOnRepeat", Action::Drop => "GenAction::Drop", }; out.push_str(&format!( diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index f2cbb40b..59683b32 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -18,6 +18,12 @@ # watch for `backoff-seconds` before the next # attempt. `backoff-seconds` is required and # must be at least 1. +# action = "drop-on-repeat" Permanent unless first-seen: the first +# rejection retries on the next block (a +# same-block state race can make a valid +# order verify late); a repeat on a later +# block removes the watch. +# # action = "drop" Permanent: no retry can succeed. Remove the # watch and its gates. # @@ -41,7 +47,7 @@ # (a permanent-looking contract rejection is dropped rather than # retried, and the limit-order backoff is shorter) are exactly: # -# InvalidEip1271Signature drop upstream: retry next block +# InvalidEip1271Signature drop-on-repeat upstream: retry next block # InsufficientBalance drop upstream: backoff 10 min # InsufficientAllowance drop upstream: backoff 10 min # InvalidAppData drop upstream: backoff 60 s @@ -85,6 +91,17 @@ error-type = "DuplicateOrder" action = "try-next-block" already-submitted = true +# --- One-block grace: retry once, then drop -------------------------- + +# A first-time user's Safe wiring and ComposableCoW create() can land +# in the same block as the indexed event; an orderbook node verifying +# against its own head then rejects a signature that is valid one block +# later. The first rejection retries next block; a repeat on a later +# block is a genuinely broken signature and drops the watch. +[[entry]] +error-type = "InvalidEip1271Signature" +action = "drop-on-repeat" + # --- Permanent: drop the watch --------------------------------------- # # Listed for documentation; each is also the default for any unlisted @@ -95,10 +112,6 @@ already-submitted = true error-type = "InvalidSignature" action = "drop" -[[entry]] -error-type = "InvalidEip1271Signature" -action = "drop" - [[entry]] error-type = "WrongOwner" action = "drop" diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 7a7bb633..e95acf0b 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -420,6 +420,9 @@ fn classified(api: &ApiError) -> VenueError { RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit { retry_after_ms: Some(seconds.saturating_mul(1000)), }), + // The one-shot grace is client-side; the wire stays `denied`, + // and the errorType prefix in the detail carries it across. + RetryAction::DropOnRepeat => VenueError::Denied(detail), RetryAction::Drop => VenueError::Denied(detail), _ => VenueError::Denied(detail), } @@ -750,6 +753,15 @@ mod tests { Err(VenueError::Denied(detail)) if detail.contains("InvalidSignature") )); + // A drop-on-repeat row stays `denied` on the wire; the + // errorType prefix carries the one-shot grace to the client. + reject(&fetch, "InvalidEip1271Signature"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(detail)) + if detail.starts_with("InvalidEip1271Signature:") + )); + reject(&fetch, "TooManyLimitOrders"); assert!(matches!( submit_with(&fetch, &config, &signed_bytes()), diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index aabda91f..a3c6861b 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -32,6 +32,7 @@ pub const CLASSIFICATION_TOML: &str = include_str!("../data/classification.toml" enum GenAction { TryNextBlock, Backoff, + DropOnRepeat, Drop, } @@ -53,6 +54,7 @@ impl GeneratedRow { GenAction::Backoff => RetryAction::Backoff { seconds: self.backoff_seconds.max(1), }, + GenAction::DropOnRepeat => RetryAction::DropOnRepeat, GenAction::Drop => RetryAction::Drop, } } @@ -116,6 +118,19 @@ pub fn is_already_submitted(error_type: &str) -> bool { table().is_already_submitted(error_type) } +/// Retry action for a coarse `denied` refusal. The adapter spells a +/// classified rejection as `{errorType}: {description}`; the prefix +/// re-enters the table so a [`RetryAction::DropOnRepeat`] row survives +/// the collapse to the wire `venue-error`. Every other denial is +/// permanent. +pub fn classify_denied(detail: &str) -> RetryAction { + let error_type = detail.split_once(':').map_or(detail, |(prefix, _)| prefix); + match classify(error_type) { + RetryAction::DropOnRepeat => RetryAction::DropOnRepeat, + _ => RetryAction::Drop, + } +} + #[cfg(test)] mod tests { use super::*; @@ -141,6 +156,7 @@ mod tests { Action::Backoff => RetryAction::Backoff { seconds: entry.backoff_seconds.max(1), }, + Action::DropOnRepeat => RetryAction::DropOnRepeat, Action::Drop => RetryAction::Drop, }; assert_eq!( @@ -168,6 +184,10 @@ mod tests { classify("TooManyLimitOrders"), RetryAction::Backoff { seconds: 30 }, ); + assert_eq!( + classify("InvalidEip1271Signature"), + RetryAction::DropOnRepeat, + ); assert_eq!(classify("InvalidSignature"), RetryAction::Drop); assert!(is_already_submitted("DuplicatedOrder")); assert!(is_already_submitted("DuplicateOrder")); @@ -181,7 +201,7 @@ mod tests { assert!(!is_already_submitted("NewlyMintedErrorType")); } - /// All three retry arms are reachable from the table alone. + /// Every retry arm is reachable from the table alone. #[test] fn table_reaches_every_arm() { assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); @@ -189,9 +209,34 @@ mod tests { classify("TooManyLimitOrders"), RetryAction::Backoff { .. } )); + assert_eq!( + classify("InvalidEip1271Signature"), + RetryAction::DropOnRepeat, + ); assert_eq!(classify("InvalidSignature"), RetryAction::Drop); } + /// A denied detail re-enters the table by its `errorType` prefix: + /// only a drop-on-repeat row escapes the permanent default, and a + /// transient row cannot be smuggled through a denial. + #[test] + fn denied_detail_refines_by_error_type_prefix() { + assert_eq!( + classify_denied("InvalidEip1271Signature: signature is not valid"), + RetryAction::DropOnRepeat, + ); + assert_eq!( + classify_denied("InvalidSignature: bad sig"), + RetryAction::Drop, + ); + assert_eq!( + classify_denied("InsufficientFee: too low"), + RetryAction::Drop, + ); + assert_eq!(classify_denied("policy refusal"), RetryAction::Drop); + assert_eq!(classify_denied(""), RetryAction::Drop); + } + #[test] fn duplicate_type_is_rejected() { let toml = r#" diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs index 49162e0b..bb96cc3f 100644 --- a/crates/cow-venue/src/classification_data.rs +++ b/crates/cow-venue/src/classification_data.rs @@ -10,7 +10,7 @@ use serde::Deserialize; -/// One of the three retry actions an `errorType` maps to on the wire. +/// One of the retry actions an `errorType` maps to on the wire. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Action { @@ -18,6 +18,9 @@ pub enum Action { TryNextBlock, /// Throttle: gate the watch for `backoff_seconds` before retrying. Backoff, + /// Permanent unless first-seen: retry on the next block once, then + /// drop on a repeat at a later block. + DropOnRepeat, /// Permanent: remove the watch and its gates. Drop, } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 82ff5aaa..8cafcd00 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -81,6 +81,6 @@ pub use order::{ pub use adapter::CowAdapter; #[cfg(feature = "client")] -pub use classification::{ClassificationTable, classify, is_already_submitted}; +pub use classification::{ClassificationTable, classify, classify_denied, is_already_submitted}; #[cfg(feature = "client")] pub use client::{CowClient, CowVenue, intent_id}; diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 637e7985..726bdae4 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -26,10 +26,10 @@ //! - [`Retrier`] - runs a [`RetryAction`]'s effect through the //! stores after a failed keeper run attempt. //! -//! [`WatchRef`] ties the first two together: gate keys are derived -//! from the exact hex substrings of the stored watch key, and -//! [`WatchSet::remove`] drops a watch together with all of its gate -//! keys so no failure path can orphan a gate. +//! [`WatchRef`] ties the first two together: gate and marker keys are +//! derived from the exact hex substrings of the stored watch key, and +//! [`WatchSet::remove`] drops a watch together with all of its derived +//! keys so no failure path can orphan one. //! //! ``` //! use nexum_sdk::keeper::{Gates, Journal, WatchRef, WatchSet}; @@ -99,6 +99,10 @@ pub const SUBMITTED_PREFIX: &str = "submitted:"; /// the observe-and-verify path (e.g. ethflow) recorded an existing /// upstream order as seen. pub const OBSERVED_PREFIX: &str = "observed:"; +/// Prefix of the first-refusal marker paired with a watch: the block a +/// [`RetryAction::DropOnRepeat`] refusal was first seen at, u64 +/// little-endian. +pub const REFUSED_PREFIX: &str = "refused:"; /// Canonical watch key for an owner / commitment-hash pair (lowercase /// `0x`-prefixed hex on both halves). Free-standing because the key @@ -159,6 +163,11 @@ impl<'k> WatchRef<'k> { pub fn next_epoch_key(&self) -> String { format!("{NEXT_EPOCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) } + + /// The `refused:` first-refusal marker key paired with this watch. + pub fn refused_key(&self) -> String { + format!("{REFUSED_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } } /// Watch-set registry: one row per conditional commitment, keyed @@ -199,11 +208,13 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { self.host.list_keys(WATCH_PREFIX) } - /// Drop the watch together with both of its gate keys. Gates go - /// first: a fault part-way leaves the watch row behind so a retry - /// re-drops it, and a gate key can never outlive its watch. + /// Drop the watch together with its gate and refusal keys. The + /// derived keys go first: a fault part-way leaves the watch row + /// behind so a retry re-drops it, and a derived key can never + /// outlive its watch. pub fn remove(&self, watch: WatchRef<'_>) -> Result<(), Fault> { Gates::new(self.host).clear(watch)?; + self.host.delete(&watch.refused_key())?; self.host.delete(&watch.key()) } } @@ -238,12 +249,12 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { /// inclusive at its threshold. #[must_use = "the readiness verdict gates the poll; `?` alone drops the inner bool"] pub fn is_ready(&self, watch: WatchRef<'_>, block: u64, epoch_s: u64) -> Result { - if let Some(next) = self.read_u64(&watch.next_block_key())? + if let Some(next) = read_u64(self.host, &watch.next_block_key())? && block < next { return Ok(false); } - if let Some(next) = self.read_u64(&watch.next_epoch_key())? + if let Some(next) = read_u64(self.host, &watch.next_epoch_key())? && epoch_s < next { return Ok(false); @@ -256,21 +267,22 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { self.host.delete(&watch.next_block_key())?; self.host.delete(&watch.next_epoch_key()) } +} - fn read_u64(&self, key: &str) -> Result, Fault> { - // Absent key: silently no gate. Present but wrong length: the - // value is corrupt, so warn before falling open to no gate - - // fail-open is deliberate (a corrupt value can only make the - // watch poll sooner), but it must not pass unobserved. - let Some(b) = self.host.get(key)? else { - return Ok(None); - }; - match <[u8; 8]>::try_from(b.as_slice()) { - Ok(bytes) => Ok(Some(u64::from_le_bytes(bytes))), - Err(_) => { - tracing::warn!(%key, len = b.len(), "gate value corrupt; treating as absent"); - Ok(None) - } +/// Little-endian u64 row read shared by the gate and refusal-marker +/// keys. Absent key: silently `None`. Present but wrong length: the +/// value is corrupt, so warn before falling open to `None` - fail-open +/// is deliberate (a corrupt value can only make the watch act sooner, +/// never wedge it), but it must not pass unobserved. +fn read_u64(host: &H, key: &str) -> Result, Fault> { + let Some(b) = host.get(key)? else { + return Ok(None); + }; + match <[u8; 8]>::try_from(b.as_slice()) { + Ok(bytes) => Ok(Some(u64::from_le_bytes(bytes))), + Err(_) => { + tracing::warn!(%key, len = b.len(), "stored value corrupt; treating as absent"); + Ok(None) } } } @@ -373,14 +385,20 @@ pub enum RetryAction { /// Seconds to wait before retrying. seconds: u64, }, + /// Grant one next-block retry: the first application records the + /// block and gates the watch to the next one; a repeat at a later + /// block removes the watch. + DropOnRepeat, /// Remove the watch and its gates; no retry can succeed. Drop, } /// Retry ledger: runs a [`RetryAction`]'s effect through the keeper /// stores. `Backoff` saturates at `u64::MAX` on the epoch clock; -/// `Drop` delegates to [`WatchSet::remove`], so gates go first and no -/// failure path can orphan one. +/// `DropOnRepeat` keeps a `refused:` block marker so only a repeat on +/// a later block removes the watch; `Drop` delegates to +/// [`WatchSet::remove`], so derived keys go first and no failure path +/// can orphan one. pub struct Retrier<'h, H> { host: &'h H, } @@ -391,20 +409,39 @@ impl<'h, H: LocalStoreHost> Retrier<'h, H> { Self { host } } - /// Apply `action` to the watch, with `now_epoch_s` as the backoff - /// origin. + /// Apply `action` to the watch at `tick`: the backoff origin and + /// the repeat-detection block both read from it. pub fn apply( &self, watch: WatchRef<'_>, action: RetryAction, - now_epoch_s: u64, + tick: &Tick, ) -> Result<(), Fault> { match action { RetryAction::TryNextBlock => Ok(()), RetryAction::Backoff { seconds } => { - Gates::new(self.host).set_next_epoch(watch, now_epoch_s.saturating_add(seconds)) + Gates::new(self.host).set_next_epoch(watch, tick.epoch_s.saturating_add(seconds)) + } + RetryAction::DropOnRepeat => { + let key = watch.refused_key(); + match read_u64(self.host, &key)? { + Some(block) if tick.block > block => WatchSet::new(self.host).remove(watch), + Some(_) => Ok(()), + None => { + self.host.set(&key, &tick.block.to_le_bytes())?; + Gates::new(self.host).set_next_block(watch, tick.block.saturating_add(1)) + } + } } RetryAction::Drop => WatchSet::new(self.host).remove(watch), } } + + /// Clear the watch's first-refusal marker: an accepted submit ends + /// the refusal episode, so a later [`RetryAction::DropOnRepeat`] + /// refusal earns a fresh one-block grace. No-op when no marker is + /// set. + pub fn clear_refusal(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + self.host.delete(&watch.refused_key()) + } } diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index e271fa41..f73c9adf 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -8,8 +8,8 @@ use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Retrier, RetryAction, - Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, + ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, REFUSED_PREFIX, + Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; use nexum_sdk_test::MockHost; @@ -362,6 +362,14 @@ fn seeded_watch(host: &MockHost) -> String { .unwrap() } +fn tick_at(block: u64, epoch_s: u64) -> Tick { + Tick { + chain_id: 1, + block, + epoch_s, + } +} + #[test] fn ledger_try_next_block_leaves_the_store_untouched() { let host = MockHost::new(); @@ -372,7 +380,7 @@ fn ledger_try_next_block_leaves_the_store_untouched() { .apply( WatchRef::parse(&key).unwrap(), RetryAction::TryNextBlock, - 1_000, + &tick_at(100, 1_000), ) .unwrap(); @@ -387,7 +395,11 @@ fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { let ledger = Retrier::new(&host); ledger - .apply(watch, RetryAction::Backoff { seconds: 30 }, 1_000) + .apply( + watch, + RetryAction::Backoff { seconds: 30 }, + &tick_at(100, 1_000), + ) .unwrap(); let gates = Gates::new(&host); @@ -410,7 +422,11 @@ fn ledger_backoff_saturates_on_the_epoch_clock() { let watch = WatchRef::parse(&key).unwrap(); Retrier::new(&host) - .apply(watch, RetryAction::Backoff { seconds: u64::MAX }, 1_000) + .apply( + watch, + RetryAction::Backoff { seconds: u64::MAX }, + &tick_at(100, 1_000), + ) .unwrap(); assert_eq!( @@ -427,17 +443,109 @@ fn ledger_drop_removes_the_watch_and_its_gates() { Gates::new(&host).set_next_block(watch, 500).unwrap(); Retrier::new(&host) - .apply(watch, RetryAction::Drop, 1_000) + .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) .unwrap(); assert!(host.store.is_empty(), "watch and gates must go"); } +#[test] +fn ledger_drop_on_repeat_grants_one_next_block_retry() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + // First refusal: the block is recorded and the watch gates to the + // next block instead of dropping. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "first refusal keeps the watch"); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &100_u64.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &101_u64.to_le_bytes().to_vec(), + ); + + // A repeat at the same block leaves the store untouched. + let before = host.store.snapshot(); + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + assert_eq!(host.store.snapshot(), before); + + // A repeat on a later block removes the watch and every derived key. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(101, 1_012)) + .unwrap(); + assert!(host.store.is_empty(), "watch, gates, and marker must go"); +} + +#[test] +fn ledger_clear_refusal_resets_the_one_block_grace() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + ledger.clear_refusal(watch).unwrap(); + assert!(!host.store.snapshot().contains_key(&watch.refused_key())); + + // A refusal at a later block after the clear is a fresh first + // refusal: the watch survives and the marker records the new block. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(105, 1_060)) + .unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "the watch must survive"); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &105_u64.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &106_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_refused_marker() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + let ledger = Retrier::new(&host); + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + ledger + .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) + .unwrap(); + + assert!( + !host + .store + .snapshot() + .keys() + .any(|k| k.starts_with(REFUSED_PREFIX)), + "drop must not orphan the refusal marker", + ); +} + #[test] fn retry_action_labels_are_stable_snake_case() { - let cases: [(RetryAction, &str); 3] = [ + let cases: [(RetryAction, &str); 4] = [ (RetryAction::TryNextBlock, "try_next_block"), (RetryAction::Backoff { seconds: 1 }, "backoff"), + (RetryAction::DropOnRepeat, "drop_on_repeat"), (RetryAction::Drop, "drop"), ]; for (action, label) in cases { diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index bbf43928..9ba2d160 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -33,6 +33,8 @@ nexum-sdk = { path = "../nexum-sdk" } http.workspace = true strum.workspace = true thiserror.workspace = true +# Best-effort fault logs on the sweep's non-critical store cleanups. +tracing.workspace = true wit-bindgen.workspace = true [dev-dependencies] diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 8ca91a3e..b073ea71 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -65,10 +65,12 @@ impl Keeper { /// run every other outcome and every venue refusal through the /// [`Retrier`]. The [`submission_key`] is checked against the /// `submitted:` [`Journal`] before every submit and recorded on - /// acceptance, so an accepted body never reaches the venue twice; - /// a `requires-signing` answer journals nothing and is surfaced - /// afresh each sweep. Store faults abort the sweep; venue refusals - /// never do - they fold into per-watch retry actions. + /// acceptance - the watch's first-refusal marker is then cleared + /// best-effort - so an accepted body never reaches the venue + /// twice; a `requires-signing` answer journals nothing and is + /// surfaced afresh each sweep. Store faults abort the sweep, bar + /// the post-acceptance marker clear; venue refusals never do - + /// they fold into per-watch retry actions. pub async fn sweep(&self, host: &H, tick: &Tick) -> Result where H: LocalStoreHost, @@ -105,6 +107,15 @@ impl Keeper { match self.venues.submit(&self.venue, body).await { Ok(SubmitOutcome::Accepted(_)) => { journal.record(&key)?; + // The acceptance is journalled; the marker + // clear is cleanup and must not abort the + // sweep. + if let Err(fault) = retrier.clear_refusal(watch) { + tracing::error!( + %fault, + "refusal-marker clear failed after journalled acceptance", + ); + } report.submitted += 1; continue; } @@ -123,7 +134,7 @@ impl Keeper { RetryAction::Drop => report.dropped += 1, _ => report.retried += 1, } - retrier.apply(watch, action, tick.epoch_s)?; + retrier.apply(watch, action, tick)?; } Ok(report) } @@ -191,6 +202,7 @@ pub fn retry_action(fault: &VenueFault) -> RetryAction { mod tests { use std::cell::RefCell; + use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk::prelude::{Address, B256, hex, keccak256}; use nexum_sdk_test::MockLocalStore; @@ -310,6 +322,45 @@ mod tests { assert_eq!(venue.submitted.borrow().len(), 1); } + #[test] + fn accepted_body_clears_the_refusal_marker() { + let host = MockLocalStore::default(); + let key = put_watch(&host); + let watch = WatchRef::parse(&key).expect("well-formed key"); + host.set(&watch.refused_key(), &50_u64.to_le_bytes()) + .expect("marker writes"); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("sweep runs"); + assert_eq!(report.submitted, 1); + assert!( + host.get(&watch.refused_key()) + .expect("marker reads") + .is_none(), + "acceptance must clear the first-refusal marker", + ); + } + + #[test] + fn refusal_marker_clear_fault_does_not_abort_a_journalled_acceptance() { + let host = MockLocalStore::default(); + put_watch(&host); + host.fail_on("refused:", Fault::Unavailable("store down".into())); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("marker-clear fault must not abort the sweep"); + assert_eq!(report.submitted, 1); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!( + Journal::submitted(&host) + .contains(&key) + .expect("journal reads"), + "acceptance must be journalled before the marker clear", + ); + } + #[test] fn a_changed_body_submits_afresh() { let host = MockLocalStore::default(); From d57e9b5c5106a00a0837fa71fbe44e19fb55cff0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 18 Jul 2026 00:39:52 +0000 Subject: [PATCH 52/53] docs: rewrite the platform docs as the shipped-venue source of truth Doc 05 documents both personas as shipped: the videre-sdk surface, the venue and keeper macros, the typed client, and an author-a-venue walkthrough on the echo pair, with the CoW crates as the production instance. Doc 08 names venue adapters as the domain-extension mechanism, replaces the Layer-3 world-include pattern with the videre:venue contract and the registry route, marks shepherd:cow as the retired legacy read path (cow-events stays as the event-ABI package of record), and squares the WIT layout, server table, and SDK layering with the tree. One inbound sdk.md anchor follows the doc 05 heading. --- docs/05-sdk-design.md | 543 +++++++++++++++++++---------- docs/08-platform-generalisation.md | 141 ++++---- docs/sdk.md | 2 +- 3 files changed, 419 insertions(+), 267 deletions(-) diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index eff5eed4..38109fb6 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,107 +1,101 @@ -# SDK Design: The Two-Persona SDK Plan - -This document describes the guest-side SDK crates and the plan that -shapes them: a **module-author persona** and a **venue-adapter -persona**, each with its own crate pair and attribute macro. The -module-author persona is shipped and is what this document mostly -describes; the venue-adapter persona is design intent tracked by a -separate epic and is called out explicitly as such wherever it -appears below. - -For the architectural decision behind the host-trait seam that the -module-author persona builds on, see [ADR-0009](adr/0009-host-trait-surface.md). -For the rustdoc-level API reference (the source of truth once you are -writing module code), see [`sdk.md`](sdk.md) and the rustdoc under -`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and -`crates/nexum-module-macros/`. +# SDK Design: The Two-Persona SDK -## The two personas +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 runtime has two kinds of guest authors, and they need different -things from the SDK: +## The two personas 1. **Module author.** Writes an automation module against - `nexum:host/event-module` (or the CoW-extended `shepherd:cow/shepherd` - world): react to blocks, chain logs, ticks, or messages; read and - write local state; submit orders. This persona is served today by - `nexum-sdk` (+ the `#[nexum::module]` macro, spelled - `#[nexum_sdk::module]` in code) and, for CoW-specific modules, - `shepherd-sdk` on top. - -2. **Venue adapter author.** Writes an adapter that exposes a trading - venue (CoW Protocol, a DEX, a lending market, ...) to modules - through a common intent surface, so a module author does not need - to know the venue's wire format. This persona is planned but not - yet shipped: the crate (`videre-sdk`), the per-venue crates - (e.g. a `cow-venue` crate carrying CoW's intent-body codec), the - `#[nexum::venue]` macro, and the `videre-test` conformance kit - are all tracked by the SDK-surfaces epic and have no code in the - tree yet. See [Venue-adapter persona (planned)](#venue-adapter-persona-planned) - below for the shape of the plan. - -Each persona has its own proc-macro crate (`nexum-module-macros` for -modules, `videre-macros` for venue adapters) and both share the + `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. -## Module-author persona (shipped): `nexum-sdk` + `shepherd-sdk` - -### Crate structure +## Crate layout ``` -nexum-sdk/ -├── Cargo.toml +nexum-sdk/ # universal module SDK (host-neutral, domain-free) └── src/ - ├── lib.rs # crate docs, `pub use nexum_module_macros::module` - ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) + ├── lib.rs # crate docs, `pub use nexum_module_macros::module` + ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) ├── host.rs # ChainHost / LocalStoreHost / LoggingHost + supertrait Host; Fault, ChainError, RpcError - ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters - ├── keeper.rs # WatchSet, Gates, Journal, ConditionalSource, Retrier - ├── chain/ # eth_call_params, parse_eth_call_result, chainlink AggregatorV3 reader + ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters + ├── keeper.rs # WatchSet, Gates, Journal, ConditionalSource, Retrier + ├── chain/ # eth_call_params, parse_eth_call_result, chainlink AggregatorV3 reader ├── events.rs # native alloy Log assembly from the wire ChainLog record ├── config.rs # (key, value) config-table lookups, decimal scaling ├── address.rs # EVM address parsing with typed errors ├── http.rs # Fetch trait seam, WasiFetch, FetchError (wasi:http) - ├── tracing.rs # guest tracing facade + panic hook over a LogSink seam - └── proptests.rs # cfg(test) property tests (not part of the public surface) + └── tracing.rs # guest tracing facade + panic hook over a LogSink seam -nexum-module-macros/ -├── Cargo.toml # proc-macro = true -└── src/ - └── lib.rs # #[module] attribute macro +nexum-module-macros/ # #[module] attribute macro (proc-macro) -shepherd-sdk/ -├── Cargo.toml +videre-sdk/ # venue SDK: both venue sides └── src/ - ├── lib.rs # crate docs; no re-export of nexum-sdk - ├── prelude.rs # cowprotocol order/signing/orderbook re-exports - ├── wit_bindgen_macro.rs # bind_cow_host_via_wit_bindgen! - layers CowApiHost onto WitBindgenHost - ├── cow/ # CowApiHost trait, gpv2_to_order_data, Verdict, LegacyRevertAdapter, - │ # RetryAction classifiers, run() (poll -> gate/journal/submit) - └── proptests.rs # cfg(test) property tests (not part of the public surface) + ├── 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 + ├── keeper.rs # Keeper::sweep - the generic sweep assembler; Sweep, SweepReport + ├── transport.rs # HostChain, HostMessaging, http, TimedFetch + ├── faults.rs # VenueFault + conversions across wire fault / SDK fault / VenueError + ├── rt.rs # completes async keeper handlers on the sync guest boundary + └── bindings.rs # the shared import-only bindgen the macros remap onto + +videre-macros/ # #[venue], #[keeper], derive(IntentBody) (proc-macro) + +videre-test/ # venue conformance kit +└── src/ # CodecVectors, HeaderGoldens, MockTransport, reference venue + +cow-venue/ # the CoW venue, as feature slices +composable-cow/ # ComposableCoW keeper machinery (body, poll seam, sweep) ``` `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. `shepherd-sdk` depends on `nexum-sdk` and -layers the CoW Protocol domain on top; modules that touch the -orderbook import both crates directly (nothing is re-exported between -them). `shepherd-sdk` has not been retired - the clean break described -in the SDK epic (folding its CoW surface into a future `cow-venue` -crate) is deferred to a follow-on train, sequenced after the -venue-adapter persona lands. +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`) and `shepherd-sdk-test` -(composes those mocks with `MockCowApi`). See -[Testing](#testing-nexum-sdk-test-and-shepherd-sdk-test) below. +`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 -Neither crate calls `wit_bindgen`-generated functions directly. -Instead `nexum-sdk::host` exposes small traits that mirror the WIT +`nexum-sdk` never calls `wit_bindgen`-generated functions directly. +Instead `nexum_sdk::host` exposes small traits that mirror the WIT interfaces: ```rust @@ -121,39 +115,33 @@ pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} impl Host for T {} ``` -`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`, -`cow_api_request`), and -its own supertrait `CowHost: Host + CowApiHost`. 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 (four traits over one fat trait, the -`strategy.rs` / `lib.rs` split, and the world-neutral `HostError` -predecessor that per-interface typed errors later replaced - see -[ADR-0011](adr/0011-per-interface-typed-errors.md)). +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 still 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 +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 `ChainHost` / -`LocalStoreHost` / `LoggingHost` impls over the generated import +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` <-> wit-bindgen `logging::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. `shepherd-sdk::bind_cow_host_via_wit_bindgen!` -layers the `CowApiHost` impl on top of the same `WitBindgenHost` type. - -### The `#[nexum::module]` macro +`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 @@ -190,37 +178,130 @@ impl HttpProbe { } ``` -Two things worth being precise about, since they differ from earlier -drafts of this plan: - -- **One macro, not two.** There is no separate `#[shepherd::module]`. - The macro reads the crate's `module.toml` and generates against a - per-module world whose imports are exactly the - `[capabilities].required`/`optional` declarations (a chain + - local-store module simply has no `cow-api` or `identity` bindings to - call). This retires the import-elision dependency ADR-0009 flagged - for macro-built modules: their imports equal their declarations by - construction, and the runtime's capability check is a backstop - rather than a consumer of toolchain dead-import elision. Declaring - `cow-api` (or `pool`) pulls that import into the world, and the - module layers its own domain adapter (a `CowApiHost` impl over the - generated shims) on top of the emitted core one. +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. - **Handlers are synchronous.** `init` and the named handlers are - plain `fn`, called directly with no `block_on` wrapper. There is no - `async fn` handler support and no injected `&RootProvider` - modules + 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`, +`ConditionalSource`, `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 +sweep. + +## 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. + +### 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)]`: -The `Guest`/`export!` shape the macro emits still follows the -`strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` -(handlers plus the macro attribute) split from ADR-0009. The keeper -helpers in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, -`ConditionalSource`, `Retrier` - give conditional-commitment -modules (watchers that poll a set of pending commitments) a shared set -of `LocalStoreHost` conventions instead of hand-rolled key schemes. +```rust +#[derive(videre_sdk::IntentBody)] +enum EchoBody { + V1(u64), +} +``` + +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 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: + +```rust +struct CowVenue; +impl Venue for CowVenue { + const ID: VenueId = VenueId::from_static("cow"); + type Body = CowIntentBody; +} +``` -### Testing: `nexum-sdk-test` and `shepherd-sdk-test` +`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::rt` +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::sweep` assembles the world-neutral +`nexum_sdk::keeper` stores - `WatchSet` to `Gates` to +`ConditionalSource::poll` to `Retrier` to `Journal` - over the +`VenueTransport` seam, so a conditional-commitment keeper writes one +`poll` producing the shared `Sweep` 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. ```rust use nexum_sdk::host::*; @@ -233,79 +314,159 @@ assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); assert_eq!(host.chain.calls().len(), 1); ``` -`MockHost` composes one mock per trait (`chain`, `store`, `logging` -in `nexum-sdk-test`; `shepherd-sdk-test` adds a `cow_api` field on the -`shepherd:cow/cow-api` seam, backed by `MockCowApi` by default or the -per-call-scriptable `MockVenue` via `MockHost::with_venue()`), each -recording calls and letting tests program responses. Tests run as -plain native Rust against the traits - no `wasm32-wasip2` target, no -wasmtime instance, no network round-trip. This is the whole SDK-side -testing story today. (The runtime crate separately ships a -feature-gated component-level harness - `nexum-runtime`'s -`test_utils::TestRuntime`, behind the `test-utils` feature - that -loads a compiled `.wasm` plus manifest under real wasmtime and -dispatches events to it; that is runtime-internal tooling, not part -of the module-author SDK contract.) - -## Venue-adapter persona (planned) - -The venue-adapter persona is the other half of the two-persona plan -and is **not shipped**. It is tracked by a set of open issues under -the SDK-surfaces epic and depends on a venue-adapter WIT world that -does not exist yet either. Nothing below this heading describes code -in this repository; it is recorded here so the module-author persona -above is read in the context of where the SDK is going, not as a -competing vision. - -The planned shape: - -- **`videre-sdk`** - a new crate carrying the guest-side - `VenueAdapter` trait over the (also planned) adapter-world bindgen, - a `borsh`-backed `IntentBody` derive that enforces a per-venue - version enum (an adapter rejects an intent body tagged with an - unknown version rather than misinterpreting it), and typed wrappers - over the scoped transport imports (`http`, `messaging`, `chain`) an - adapter is granted. -- **Per-venue crates** - e.g. a `cow-venue` crate that would carry - CoW Protocol's intent-body codec and become the eventual home for - the CoW helpers `shepherd-sdk::cow` carries today, once the clean - break happens. -- **`#[nexum::venue]`** - a second attribute macro, in `videre-macros`, - parallel to `#[nexum::module]`: it would emit the per-cdylib export - glue for an adapter and a per-component world matching the - manifest's declared capabilities (retiring the import-elision - dependency for the venue side from day one, rather than as - follow-on work). -- **`videre-test`** - a conformance kit: published codec - round-trip vectors (so a non-Rust adapter author can prove - byte-exact `IntentBody` encoding without linking Rust), header- - derivation golden fixtures, and a `MockTransport` for adapter unit - tests. - -Once this lands, `shepherd-sdk`'s CoW surface is expected to move into -the `cow-venue` crate as a single clean-break migration - the same -no-deprecation-window reasoning [ADR-0011](adr/0011-per-interface-typed-errors.md) -gives for pre-1.0 wire breaks applies here - and this document should -be revisited to describe the venue-adapter persona as shipped rather -than planned. +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. +- **`HeaderGoldens`** - published bodies paired with the header a + 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. + +(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`. + +1. **Declare the manifest.** A venue adapter is a component with a + `module.toml` whose kind names it: + + ```toml + [module] + name = "echo-venue" # the venue id the registry installs it under + version = "0.1.0" + kind = "venue-adapter" + component = "sha256:..." + + [capabilities] + required = ["chain"] # scoped transport only: chain, messaging, http + + [capabilities.http] + allow = [] + + [venue] + body_versions = [1] # must equal the body-versions export; install asserts it + ``` + +2. **Implement `VenueAdapter`.** One impl block under the macro; the + five intent functions plus `init` and `body_versions`: + + ```rust + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, VenueError}; + + struct EchoVenue; + + #[videre_sdk::venue] + impl VenueAdapter for EchoVenue { + fn init(_config: Config) -> Result<(), Fault> { Ok(()) } + fn body_versions() -> Vec { vec![1] } + fn derive_header(body: Vec) -> Result { /* pure, no I/O */ } + fn quote(body: Vec) -> Result { /* ... */ } + fn submit(body: Vec) -> Result { /* ... */ } + fn status(receipt: Vec) -> Result { /* ... */ } + fn cancel(receipt: Vec) -> Result<(), VenueError> { /* ... */ } + } + ``` + + `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. + +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: + + ```toml + [[adapters]] + path = "target/wasm32-wasip2/release/echo_venue.wasm" + manifest = "modules/examples/echo-venue/module.toml" + 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)). + +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: + + ```rust + #[videre_sdk::keeper] + impl EchoKeeper { + async fn on_block(block: types::Block) -> Result<(), Fault> { + let venue = VenueClient::::new(); + let quoted = venue.quote(&EchoBody::V1(block.number)).await?; + if let SubmitOutcome::Accepted(receipt) = quoted.submit().await? { + let _status = venue.status(&receipt).await?; + } + Ok(()) + } + } + ``` + + 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-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`). +- **`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 + `sweep` slice composing the poll loop over the typed `CowClient`. + +The shipped CoW keepers - `modules/twap-monitor`, +`modules/ethflow-watcher`, `modules/examples/stop-loss` - 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`. The WIT is -the universal contract; both Rust SDKs are an ergonomics layer on top -of it, not a requirement. +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 for module authors. + point. - [ADR-0009](adr/0009-host-trait-surface.md) - the host-trait seam decision this document builds on. - [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed - error model (`Fault`, `ChainError`, `CowApiError`) 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 rather - than through an injected provider. + 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. diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index 110bf3c0..fc9023d9 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -39,22 +39,23 @@ These six primitives are orthogonal: Together they cover the full spectrum: persistent truth (chain), cryptographic agency (identity), local scratch (local-store), shared content (remote-store), real-time coordination (messaging), and diagnostics (logging). -The 0.2 `event-module` world imports all six. (In 0.1 the WIT inadvertently omitted `identity` from the world definition despite the docs claiming six primitives; 0.2 makes the contract match the taxonomy.) One additional **additive** capability - `http` (allowlisted) - is declared via the manifest's `[capabilities]` section but is not part of the six-primitive core; it is serviced by the standard `wasi:http/outgoing-handler` interface rather than a `nexum:host` one. +The 0.2 `event-module` world imports all six. (In 0.1 the WIT inadvertently omitted `identity` from the world definition despite the docs claiming six primitives; 0.2 makes the contract match the taxonomy.) Two additional **additive** capabilities are declared via the manifest's `[capabilities]` section but are not part of the six-primitive core: `http` (allowlisted), serviced by the standard `wasi:http/outgoing-handler` interface rather than a `nexum:host` one, and `client`, the `videre:venue/client` intent surface (see [Layer 3](#layer-3-domain-extensions-venue-adapters)). ## Architectural Principle: Layered WIT Worlds -The current `shepherd` world conflates universal blockchain runtime capabilities with CoW Protocol domain-specific interfaces. To enable reuse across platforms and domains, the WIT is split into layers: +Universal runtime capabilities and domain-specific surfaces live in separate layers: ```mermaid graph TD - subgraph L3["Layer 3: Application-Specific Worlds"] - COW["shepherd:cow - cow + order (CoW Protocol automation)"] - DEFI["myapp:defi - vault + strategy (DeFi yield app)"] - GAME["game:engine - physics + assets (on-chain game)"] + subgraph L3["Layer 3: Domain Venues (adapter components)"] + COW["cow-venue - CoW Protocol orderbook"] + DEX["dex-venue - a DEX (hypothetical)"] + LEND["lend-venue - a lending market (hypothetical)"] end subgraph L2["Layer 2: Capability Extensions (optional, composable)"] - UI["ui - user interface bridge (interactive modules)"] + VC["videre:venue/client - typed intent access to installed venues"] + UI["ui - user interface bridge (planned)"] end subgraph L1["Layer 1: Universal Runtime Interfaces"] @@ -67,11 +68,11 @@ graph TD EXP["Exports: init(config) + on-event(event)"] end - L3 -->|"builds on via WIT include"| L2 - L2 -->|"builds on via WIT include"| L1 + L3 -->|"exports videre:venue/adapter, routed by the host through"| L2 + L2 -->|"adds imports to"| L1 ``` -Each layer builds on the one below via WIT `include`. A module compiled against Layer 1 alone runs on any conforming host. A module compiled against Layer 3 (e.g. `shepherd:cow`) requires a host that implements Layers 1 + the CoW extension. +Layer 1 is the world every module compiles against. A Layer 2 capability adds an import to a module's manifest-derived world: a keeper module declares `client` and gains `videre:venue/client`. Layer 3 is not a world at all: a domain enters the system as a **venue adapter component** installed into the host's venue registry, and modules reach it through the Layer 2 client interface. No module compiles against a domain world - see [Layer 3](#layer-3-domain-extensions-venue-adapters). ## Layer 1: Universal Interfaces @@ -576,54 +577,48 @@ price-dashboard/ The host loads `index.html` into a WebView and injects the bridge JavaScript that connects DOM events to `on-interact` and `ui::render` calls to DOM updates. -## Layer 3: Domain Extensions +## Layer 3: Domain Extensions (Venue Adapters) -Domain-specific interfaces extend the universal layer for particular use cases. The pattern: +A domain (CoW Protocol, a DEX, a lending market) extends the platform as a **venue adapter**: a component authored with `#[videre_sdk::venue]` that exports the `videre:venue/adapter` interface and imports scoped transport only (`chain`, `messaging`, allowlisted `wasi:http`). This is the domain-extension mechanism - the domain's wire protocol, body codec, and error projection live inside the adapter component, and nothing domain-specific enters the host or any module world. ```wit -package shepherd:cow@0.1.0; - -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - record http-failure { status: u16, body: option } - record order-rejection { status: u16, error-type: string, description: string, data: option } - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - submit-order: func(chain-id: chain-id, order-data: list) - -> result; +package videre:venue@0.1.0; + +/// Worker (keeper) face. The host holds the venue registry; the keeper +/// names a venue by string. +interface client { + use videre:types/types.{quotation, receipt, intent-status, submit-outcome, venue-error}; + + quote: func(venue: string, body: list) -> result; + submit: func(venue: string, body: list) -> result; + observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; } -world shepherd { - include nexum:host/event-module; - import cow-api; +/// Provider (venue) face. Mirrors `client` without the venue selector: +/// one installed adapter answers for exactly one venue. +interface adapter { + use videre:types/types.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + + body-versions: func() -> list; + derive-header: func(body: list) -> result; + quote: func(body: list) -> result; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; } ``` -Other domains follow the same pattern: +The two faces meet in the host. The venue platform (`crates/videre-host`, one `nexum-runtime` extension registered at the composition root) holds the `VenueRegistry`, links `videre:venue/client` into keeper worlds, and routes each call: resolve the venue id to its installed adapter, run the advisory egress guard over the adapter's pure `derive-header` projection, then invoke the adapter face. Accepted submits go under a status watch; the platform polls the adapter's `status` and fans transitions back to subscribed modules as `intent-status` events. Intent bodies are opaque on this whole path - typing is a guest-side agreement between keeper and adapter over the venue's published `IntentBody` schema ([doc 05](05-sdk-design.md#bodies-the-intentbody-derive)). -```wit -// Hypothetical DeFi yield module -package defi:yield@0.1.0; +A new domain therefore adds a component and (usually) a body crate, never a WIT package or a host change: write the adapter with `#[videre_sdk::venue]`, publish its codec vectors and header goldens, and install it via the engine's `[[adapters]]` table. The `shepherd` binary is exactly this composition: the core lattice plus the videre platform, with CoW entering only as the bundled `cow-venue` adapter - the engine itself stays venue- and cow-free. -interface vault { /* ... */ } -interface strategy { /* ... */ } +### The legacy read path: `shepherd:cow` -world yield-module { - include nexum:host/event-module; - import vault; - import strategy; -} -``` +The retired predecessor to venue adapters was a Layer-3 *world* extension: the `shepherd:cow/cow-api` interface (orderbook passthrough plus `submit-order`), a `shepherd` world including `event-module` and importing it, and a host-side extension cone implementing the interface over a cached orderbook client. That model put the domain in the module's own world and a backend in every host that ran it; each new domain would have needed its own world, host cone, and SDK glue. -The `include` mechanism ensures that any domain-specific module inherits the full universal interface set. A `shepherd` module can call `chain::request`, `identity::sign`, `local-store::get`, `remote-store::upload`, `messaging::publish`, and `logging::log` - plus the CoW-specific `cow-api::request` and `cow-api::submit-order`. +The path is deleted: the `cow-api` interface, the `shepherd`/`cow-ext` worlds, and the host cone are gone, and orderbook I/O lives in the `cow-venue` adapter behind `videre:venue/client`. The `shepherd:cow` package remains only as `cow-events`, the package of record for the CoW on-chain event ABIs (signatures and topic-0 hashes) that keeper manifests and decoders are parity-tested against. [ADR-0005](adr/0005-cow-api-via-cached-orderbookapi.md) and [ADR-0006](adr/0006-cow-twap-ethflow-host-helpers.md) are superseded accordingly. ## Complete WIT Package Layout @@ -637,23 +632,29 @@ wit/ │ ├── remote-store.wit # remote-store interface (Swarm) │ ├── messaging.wit # messaging interface (Waku) │ ├── logging.wit # logging interface -│ ├── ui.wit # ui interface + host-capabilities (planned hosts only) │ ├── event-module.wit # event-module world (6 imports) -│ ├── query-module.wit # experimental: query-module world (no host impl in 0.2) -│ └── app-module.wit # app-module world (includes ui) - design only +│ └── query-module.wit # experimental: query-module world (no host impl in 0.2) +│ +├── videre-value-flow/ +│ └── types.wit # asset + asset-amount vocabulary +│ +├── videre-types/ +│ └── types.wit # intent-header, quotation, receipt, submit-outcome, intent-status, venue-error +│ +├── videre-venue/ +│ └── venue.wit # client + adapter interfaces, venue-adapter world │ └── shepherd-cow/ - ├── cow-api.wit # merged cow-api interface (request + submit-order) - └── shepherd.wit # shepherd world (includes event-module + cow-api) + └── cow-events.wit # CoW event-ABI package of record (legacy package name) ``` -The `nexum-host` package is domain-agnostic and reusable. The `shepherd-cow` package is the CoW Protocol extension. New domains add new packages without touching the universal layer. +The `nexum-host` package is domain-agnostic and reusable. The `videre` packages are the venue-neutral intent contract. `shepherd-cow` carries only the CoW event ABIs. New domains add adapter components, not packages: the universal and venue layers are closed. (The `ui` interface and `app-module` world are design-only and ship no WIT yet.) ## Platform Targets ### Server Runtime (Reference Implementation - Nexum) -This is the current design (docs 01-07), adapted for the layered WIT. Shepherd is the Nexum distribution with CoW Protocol support. +This is the current design (docs 01-07), adapted for the layered WIT. Shepherd is the Nexum composition root that registers the videre venue platform and bundles the `cow-venue` adapter. | Interface | Implementation | |-----------|---------------| @@ -663,7 +664,7 @@ This is the current design (docs 01-07), adapted for the layered WIT. Shepherd i | `remote-store` | Bee API (`http://localhost:1633`) - operator runs a Bee node | | `messaging` | Waku node (nwaku) via JSON-RPC or REST API | | `logging` | `tracing` crate -> JSON structured logs | -| `cow-api` | reqwest HTTP client -> CoW Protocol API (REST passthrough + typed `submit-order`) | +| `videre:venue/client` | videre-host `VenueRegistry` -> installed venue adapter components (CoW via the bundled `cow-venue` adapter over allowlisted `wasi:http`) | | Event sources | `eth_subscribe` (blocks, logs), cron (Tokio interval), Waku relay (messages) | | WASM engine | wasmtime 45.x (Component Model, fuel, epoch metering) | @@ -867,7 +868,7 @@ The super app adds a capability-grant layer on top of the WIT world. When a modu ✓ remote-store - read/write to Swarm network ✓ messaging - send/receive messages (topics: /nexum/1/twap-*) ✗ ui - (not requested - event-driven module) - ✓ cow-api - interact with CoW Protocol API and submit orders + ✓ client - submit intents to installed venues (cow) [Allow] [Deny] ``` @@ -970,26 +971,15 @@ The content hash is the trust anchor. The transport is interchangeable. ## SDK Layering -The SDK is designed to mirror the WIT layering. **The two-crate split is shipped: `nexum-sdk` carries the universal surface (host-trait seam, bind macro, chain / config / address helpers, `http::fetch`, tracing facade) and `shepherd-sdk` layers the CoW domain on top, with no re-export between them.** The host-trait seam is from [ADR-0009](adr/0009-host-trait-surface.md). The diagram below describes the 0.3+ target for the typed-client layer: +The SDK mirrors the architecture, one crate per layer, with no re-export between them. See [doc 05](05-sdk-design.md) for the full treatment. -```mermaid -graph TD - subgraph ShepherdSDK["shepherd-sdk (Domain-specific: CoW Protocol)"] - COW_ITEMS["Cow client,\n#[shepherd::module] macro\n(imports cow-api)"] - end - - subgraph NexumSDK["nexum-sdk (Universal: any blockchain app)"] - NEXUM_ITEMS["HostTransport, provider(),\nTypedState, RemoteStore,\nMessaging, Signer,\nlogging macros,\nFault / HostFault / ChainError,\n#[nexum::module] macro\n(imports chain + identity\n+ local-store\n+ remote-store + messaging\n+ logging)"] - end - - ShepherdSDK -->|"extends"| NexumSDK -``` +- **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, the `#[nexum_sdk::module]` attribute macro, chain / config / address helpers, the `http` fetch seam over wasi:http, the keeper store primitives, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore`, `Messaging`, and `Signer` typed wrappers as future direction. Any module author - CoW, DeFi, gaming, whatever - uses this. -- **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `HostFault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, chain / config / address helpers, the `http::fetch` helper over wasi:http, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`). Any module author - CoW, DeFi, gaming, whatever - uses this. +- **`videre-sdk` (shipped)** - the venue layer, serving both venue sides: the `VenueAdapter` trait under `#[videre_sdk::venue]` for adapter authors, and the `IntentBody` codec, typed `VenueClient` and `#[videre_sdk::keeper]` for keeper authors, plus the generic sweep assembler and the `videre-test` conformance kit alongside. -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`Verdict`, `RetryAction`, `gpv2_to_order_data`, `LegacyRevertAdapter`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. +- **Per-venue crates (shipped for CoW)** - each domain ships as crates on the venue layer, not as an SDK layer: `cow-venue` (body codec, typed client, adapter component) and `composable-cow` (conditional-order keeper machinery). A new domain adds its own. -A module author building a generic blockchain automation module depends only on `nexum-sdk`; a CoW Protocol module depends on both `nexum-sdk` and `shepherd-sdk` and imports each directly. +A generic automation module depends only on `nexum-sdk`; a keeper adds `videre-sdk` and the venue's body crate; a venue adapter depends on `videre-sdk` and its own crate. For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnecessary - they use `wit-bindgen` directly against the WIT package for their target world. The WIT is the universal contract; the SDK is a Rust ergonomics layer on top. @@ -1014,10 +1004,11 @@ For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnece | `event-module` world (0.2, shipping) | Event-driven modules - server today, mobile/background planned | | `query-module` world (0.2 experimental) | Request/response modules - WIT published, no host impl in 0.2 | | `app-module` world | Interactive modules - design only; planned hosts | -| `shepherd:cow` WIT package | CoW Protocol domain extension | -| `shepherd` world | CoW automation modules (includes event-module + cow-api) | -| `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / HostFault / ChainError, bind macro, chain / config / address helpers, guest `http` helper, tracing facade. HostTransport, TypedState, RemoteStore, Messaging, Signer remain future direction | -| `shepherd-sdk` crate (shipped) | CoW-domain Rust SDK: cow-api trait + CoW helpers on top of `nexum-sdk`, no re-export between the layers. | +| `videre:types` / `videre:value-flow` / `videre:venue` WIT packages | Venue-neutral intent contract: types, asset vocabulary, client + adapter faces, venue-adapter world | +| `shepherd:cow` WIT package | CoW event-ABI package of record (`cow-events` only; the legacy `cow-api` read path and `shepherd` world are retired) | +| Venue adapter components | The domain-extension mechanism: `#[videre_sdk::venue]` components installed into the videre platform (`cow-venue` shipped) | +| `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / ChainError, bind macro, module macro, chain / config / address helpers, keeper store primitives, guest `http` helper, tracing facade | +| `videre-sdk` crate (shipped) | Venue Rust SDK: VenueAdapter + venue macro, IntentBody codec, typed VenueClient + keeper macro, sweep assembler; `videre-test` conformance kit alongside | | Content-addressed distribution | Platform-agnostic (Swarm/IPFS, ENS discovery, hash verification) | | Host Adapter | Platform-specific implementation of universal interfaces | diff --git a/docs/sdk.md b/docs/sdk.md index c4f07f2f..391d49ac 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -27,7 +27,7 @@ The macro generates the rest of the per-cdylib glue: the `wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` adapter, a `Guest` impl whose `on_event` dispatches to whichever handlers are present (absent handlers no-op), and `export!`. See -[doc 05](05-sdk-design.md#the-nexummodule-macro) for a worked +[doc 05](05-sdk-design.md#the-nexum_sdkmodule-macro) for a worked example and the `nexum-module-macros` rustdoc for the fine print. ## Supported host capabilities From ada55d30e3eee66efb3dc6695de01917aeff46e1 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 18 Jul 2026 02:49:23 +0000 Subject: [PATCH 53/53] ci: fail open when the R2 sccache secrets are absent Fork pull_request runs receive no repository secrets, so the R2 endpoint interpolated an empty account ID and sccache server startup failed before compiling anything. Gate RUSTC_WRAPPER and SCCACHE_BUCKET on the secret: without it the wrapper is disabled and sccache falls back to local disk, so fork PRs build uncached but green. --- .github/workflows/ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e0f3b9f..e2b8e2ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,15 @@ env: # deterministic, so PR builds writing to the shared bucket write correct objects # under correct keys (no poisoning); bound storage with an R2 lifecycle-expiry # rule on the bucket (sccache does not evict cloud backends itself). - RUSTC_WRAPPER: sccache + # Secrets do not flow to fork pull_request runs: R2_ACCOUNT_ID interpolates + # empty and the R2 backend would fail sccache server startup before anything + # compiles. Fail open on forks: an empty RUSTC_WRAPPER disables the wrapper + # (cargo treats empty as unset) and an empty SCCACHE_BUCKET drops sccache to + # its local-disk default, so the action's post-run stats step still starts a + # server. Fork PRs build uncached but green. + RUSTC_WRAPPER: ${{ secrets.R2_ACCOUNT_ID != '' && 'sccache' || '' }} CARGO_INCREMENTAL: "0" - SCCACHE_BUCKET: shepherd-sccache + SCCACHE_BUCKET: ${{ secrets.R2_ACCOUNT_ID != '' && 'shepherd-sccache' || '' }} SCCACHE_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com SCCACHE_REGION: auto SCCACHE_S3_KEY_PREFIX: ci/rust-1.94